feat: add Anthropic Claude Code OAuth provider and adaptive thinking support#5175
Closed
Minidoracat wants to merge 1 commit intoAstrBotDevs:masterfrom
Closed
feat: add Anthropic Claude Code OAuth provider and adaptive thinking support#5175Minidoracat wants to merge 1 commit intoAstrBotDevs:masterfrom
Minidoracat wants to merge 1 commit intoAstrBotDevs:masterfrom
Conversation
Contributor
There was a problem hiding this comment.
Hey - 我发现了两个问题,并给出了一些整体性的反馈:
- 在
_apply_thinking_config中,设置payloads["output_config"] = {"effort": effort}会覆盖调用方可能已经设置好的output_config;建议将配置合并到已有的字典中,而不是直接替换。 - 常量
_OAUTH_DEFAULT_HEADERS将user-agent和一些 beta 头版本(claude-code-20250219、oauth-2025-04-20、context-1m-2025-08-07)写死在代码中;更稳妥的做法可能是将这些字符串集中管理或做成可配置项,这样以后协议/头信息变更时就不需要修改这个 provider 的实现。
供 AI 代理使用的提示词
请根据本次代码评审中的评论进行修改:
## 整体评价
- 在 `_apply_thinking_config` 中,设置 `payloads["output_config"] = {"effort": effort}` 会覆盖调用方可能已经设置好的 `output_config`;建议将配置合并到已有的字典中,而不是直接替换。
- 常量 `_OAUTH_DEFAULT_HEADERS` 将 `user-agent` 和一些 beta 头版本(`claude-code-20250219`、`oauth-2025-04-20`、`context-1m-2025-08-07`)写死在代码中;更稳妥的做法可能是将这些字符串集中管理或做成可配置项,这样以后协议/头信息变更时就不需要修改这个 provider 的实现。
## 逐条评论
### 评论 1
<location> `astrbot/core/provider/sources/anthropic_source.py:68-72` </location>
<code_context>
proxy = provider_config.get("proxy", "")
return create_proxy_client("Anthropic", proxy)
+ def _apply_thinking_config(self, payloads: dict) -> None:
+ thinking_type = self.thinking_config.get("type", "")
+ if thinking_type == "adaptive":
+ payloads["thinking"] = {"type": "adaptive"}
+ effort = self.thinking_config.get("effort", "")
+ if effort:
+ payloads["output_config"] = {"effort": effort}
+ elif self.thinking_config.get("budget"):
+ payloads["thinking"] = {
</code_context>
<issue_to_address>
**suggestion (bug_risk):** 在应用 thinking 配置时,避免覆盖已有的 `output_config` 条目。
当 `thinking_type == "adaptive"` 时,这段代码会用只包含 `effort` 的字典覆盖已有的 `payloads["output_config"]`,从而悄悄丢弃上游设置的其他选项。为了保留既有设置,建议改为合并到当前字典中,例如:
```python
output_cfg = dict(payloads.get("output_config", {}))
if effort:
output_cfg["effort"] = effort
if output_cfg:
payloads["output_config"] = output_cfg
```
```suggestion
if thinking_type == "adaptive":
payloads["thinking"] = {"type": "adaptive"}
effort = self.thinking_config.get("effort", "")
output_cfg = dict(payloads.get("output_config", {}))
if effort:
output_cfg["effort"] = effort
if output_cfg:
payloads["output_config"] = output_cfg
```
</issue_to_address>
### 评论 2
<location> `astrbot/core/provider/sources/anthropic_oauth_source.py:226` </location>
<code_context>
+ exc_info=True,
+ )
+
+ async def _ensure_valid_token(self) -> None:
+ if not self._is_token_expired():
+ return
</code_context>
<issue_to_address>
**issue (complexity):** 建议简化 token 处理、凭据写入以及基类初始化的逻辑,以降低控制流复杂度,让 OAuth provider 更容易理解和扩展。
主要的复杂点在 `_ensure_valid_token`、`_save_credentials` 以及跳过的基类 `__init__`。这三处都可以在不改变行为的前提下进行简化。
### 1. 简化 `_ensure_valid_token` 的状态处理
当前实现会处理一组 `prev_*` 变量和一个 `disk_adopted` 标志,同时在异常时回滚。可以采用一种更简单的模式,既保持“磁盘数据不能覆盖更新的内存数据”这一性质,又更易于理解:
- 始终在锁下从磁盘读取。
- 如果读取失败,则恢复为之前的内存值。
- 根据最终状态决定是刷新 token 还是重建 client。
例如:
```python
async def _ensure_valid_token(self) -> None:
if not self._is_token_expired():
return
if ProviderAnthropicOAuth._refresh_lock is None:
ProviderAnthropicOAuth._refresh_lock = asyncio.Lock()
async with ProviderAnthropicOAuth._refresh_lock:
prev_access = self.access_token
prev_refresh = self.refresh_token
prev_expires = self.expires_at
# Try to reload from disk; if it fails, keep in-memory tokens
try:
self._load_credentials()
except Exception:
logger.debug(
"Failed to reload credentials from disk; using in-memory tokens.",
exc_info=True,
)
self.access_token = prev_access
self.refresh_token = prev_refresh
self.expires_at = prev_expires
# Avoid adopting an older expires_at from disk
if self.expires_at < prev_expires:
self.access_token = prev_access
self.refresh_token = prev_refresh
self.expires_at = prev_expires
if self._is_token_expired():
logger.info("Anthropic OAuth token expired or expiring soon, refreshing...")
await self._refresh_token()
elif self.access_token != prev_access:
await self._recreate_client()
```
这可以去掉 `disk_adopted` 和嵌套的恢复逻辑,同时仍然保证:
- “如果磁盘上的凭据更新,则以磁盘为准”。
- “如果磁盘缺失/无效/更旧,则保留内存中的 token”。
- 当最终使用的 token 发生变化时,会重建 client。
### 2. 抽取可复用的更高层级的原子写入工具函数
`_save_credentials` 目前手动实现了较底层的原子文件 IO,有嵌套的 `try` 块和 `fd_closed` 标记。可以通过一个小的通用工具函数来表达相同的语义,并使用 `with open(...)` 和 `Path`:
```python
def _atomic_write_json(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(path.suffix + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
try:
os.fchmod(f.fileno(), stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
os.replace(tmp_path, path)
```
这样 `_save_credentials` 就可以变为:
```python
def _save_credentials(self) -> None:
path = self._credentials_path
try:
if path.exists():
data = json.loads(path.read_text(encoding="utf-8"))
else:
data = {}
oauth = data.get("claudeAiOauth", {})
oauth["accessToken"] = self.access_token
oauth["refreshToken"] = self.refresh_token
oauth["expiresAt"] = self.expires_at
data["claudeAiOauth"] = oauth
_atomic_write_json(path, data)
except Exception:
logger.warning(
"Failed to save OAuth credentials to disk; in-memory tokens remain valid.",
exc_info=True,
)
```
这样既保留了原子性、权限设置和错误处理,又去掉了嵌套的 `try`/`except` 和手动的文件描述符跟踪,而且还能在其他地方复用。
### 3. 通过 hook 避免绕过 `ProviderAnthropic.__init__`
直接调用 `Provider.__init__` 会让继承链变得不直观;阅读者必须回过头去检查 `ProviderAnthropic.__init__`,以确认没有跳过任何关键初始化逻辑。
如果可以修改 `ProviderAnthropic`,一种既能保持当前行为又能简化子类的方式是引入一个 hook 或标志位:
```python
# in ProviderAnthropic
class ProviderAnthropic(Provider):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
*,
use_api_key: bool = True,
) -> None:
super().__init__(provider_config, provider_settings)
if use_api_key:
self._init_api_key(provider_config)
def _init_api_key(self, provider_config: dict) -> None:
# existing API key setup logic
...
```
然后 `ProviderAnthropicOAuth` 可以这样调用:
```python
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
super().__init__(provider_config, provider_settings, use_api_key=False)
# OAuth-specific initialization follows...
```
这样可以保持基类初始化路径的一致性,把公共初始化逻辑集中在 `ProviderAnthropic` 中,同时也更清晰地表明:对于 OAuth,这里是“显式地关闭 API key 逻辑”,而不是“隐式地跳过”。
</issue_to_address>帮我变得更有用!请在每条评论上点 👍 或 👎,我会根据你的反馈改进后续的评审。
Original comment in English
Hey - I've found 2 issues, and left some high level feedback:
- In
_apply_thinking_config, settingpayloads["output_config"] = {"effort": effort}will overwrite any existingoutput_configa caller may have populated; consider merging into an existing dict instead of replacing it. - The
_OAUTH_DEFAULT_HEADERSconstant hard-codes auser-agentand beta header versions (claude-code-20250219,oauth-2025-04-20,context-1m-2025-08-07); it might be safer to centralize these strings or make them configurable so future protocol/header changes don’t require touching this provider implementation.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `_apply_thinking_config`, setting `payloads["output_config"] = {"effort": effort}` will overwrite any existing `output_config` a caller may have populated; consider merging into an existing dict instead of replacing it.
- The `_OAUTH_DEFAULT_HEADERS` constant hard-codes a `user-agent` and beta header versions (`claude-code-20250219`, `oauth-2025-04-20`, `context-1m-2025-08-07`); it might be safer to centralize these strings or make them configurable so future protocol/header changes don’t require touching this provider implementation.
## Individual Comments
### Comment 1
<location> `astrbot/core/provider/sources/anthropic_source.py:68-72` </location>
<code_context>
proxy = provider_config.get("proxy", "")
return create_proxy_client("Anthropic", proxy)
+ def _apply_thinking_config(self, payloads: dict) -> None:
+ thinking_type = self.thinking_config.get("type", "")
+ if thinking_type == "adaptive":
+ payloads["thinking"] = {"type": "adaptive"}
+ effort = self.thinking_config.get("effort", "")
+ if effort:
+ payloads["output_config"] = {"effort": effort}
+ elif self.thinking_config.get("budget"):
+ payloads["thinking"] = {
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Avoid clobbering any pre-existing `output_config` entries when applying thinking configuration.
When `thinking_type == "adaptive"`, this overwrites any existing `payloads["output_config"]` with a dict containing only `effort`, silently dropping any other options set upstream. To preserve existing settings, merge into the current dict instead, e.g.:
```python
output_cfg = dict(payloads.get("output_config", {}))
if effort:
output_cfg["effort"] = effort
if output_cfg:
payloads["output_config"] = output_cfg
```
```suggestion
if thinking_type == "adaptive":
payloads["thinking"] = {"type": "adaptive"}
effort = self.thinking_config.get("effort", "")
output_cfg = dict(payloads.get("output_config", {}))
if effort:
output_cfg["effort"] = effort
if output_cfg:
payloads["output_config"] = output_cfg
```
</issue_to_address>
### Comment 2
<location> `astrbot/core/provider/sources/anthropic_oauth_source.py:226` </location>
<code_context>
+ exc_info=True,
+ )
+
+ async def _ensure_valid_token(self) -> None:
+ if not self._is_token_expired():
+ return
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying token handling, credential writes, and base-class initialization to reduce control-flow complexity and make the OAuth provider easier to reason about and extend.
The main complexity hotspots are `_ensure_valid_token`, `_save_credentials`, and the skipped base-class `__init__`. All three can be simplified without changing behavior.
### 1. Simplify `_ensure_valid_token` state juggling
You currently juggle `prev_*` values and a `disk_adopted` flag, plus rollback on exceptions. A simpler pattern keeps the “disk can’t overwrite newer in-memory” property, but is easier to follow:
- Always read from disk under the lock.
- If read fails, restore previous in-memory values.
- Decide whether to refresh or recreate the client based on final state.
For example:
```python
async def _ensure_valid_token(self) -> None:
if not self._is_token_expired():
return
if ProviderAnthropicOAuth._refresh_lock is None:
ProviderAnthropicOAuth._refresh_lock = asyncio.Lock()
async with ProviderAnthropicOAuth._refresh_lock:
prev_access = self.access_token
prev_refresh = self.refresh_token
prev_expires = self.expires_at
# Try to reload from disk; if it fails, keep in-memory tokens
try:
self._load_credentials()
except Exception:
logger.debug(
"Failed to reload credentials from disk; using in-memory tokens.",
exc_info=True,
)
self.access_token = prev_access
self.refresh_token = prev_refresh
self.expires_at = prev_expires
# Avoid adopting an older expires_at from disk
if self.expires_at < prev_expires:
self.access_token = prev_access
self.refresh_token = prev_refresh
self.expires_at = prev_expires
if self._is_token_expired():
logger.info("Anthropic OAuth token expired or expiring soon, refreshing...")
await self._refresh_token()
elif self.access_token != prev_access:
await self._recreate_client()
```
This removes `disk_adopted` and the nested restore logic, while preserving:
- “Disk credentials win if newer.”
- “In-memory tokens are kept if disk is missing/invalid/older.”
- Client recreation when the effective token changes.
### 2. Extract a reusable, higher-level atomic write helper
`_save_credentials` hand-rolls low-level atomic file IO with nested `try` blocks and `fd_closed` tracking. The same semantics can be expressed with a small shared helper that uses `with open(...)` and `Path`:
```python
def _atomic_write_json(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(path.suffix + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
try:
os.fchmod(f.fileno(), stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
os.replace(tmp_path, path)
```
Then `_save_credentials` becomes:
```python
def _save_credentials(self) -> None:
path = self._credentials_path
try:
if path.exists():
data = json.loads(path.read_text(encoding="utf-8"))
else:
data = {}
oauth = data.get("claudeAiOauth", {})
oauth["accessToken"] = self.access_token
oauth["refreshToken"] = self.refresh_token
oauth["expiresAt"] = self.expires_at
data["claudeAiOauth"] = oauth
_atomic_write_json(path, data)
except Exception:
logger.warning(
"Failed to save OAuth credentials to disk; in-memory tokens remain valid.",
exc_info=True,
)
```
This retains atomicity, permissions, and error handling, but removes nested `try`/`except` and manual `fd` tracking, and is reusable elsewhere.
### 3. Avoid bypassing `ProviderAnthropic.__init__` via a hook
Calling `Provider.__init__` directly makes the inheritance chain non-obvious; readers must audit `ProviderAnthropic.__init__` to ensure nothing critical is skipped.
If you can change `ProviderAnthropic`, a straightforward way to keep behavior but simplify this subclass is to introduce a hook or flag:
```python
# in ProviderAnthropic
class ProviderAnthropic(Provider):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
*,
use_api_key: bool = True,
) -> None:
super().__init__(provider_config, provider_settings)
if use_api_key:
self._init_api_key(provider_config)
def _init_api_key(self, provider_config: dict) -> None:
# existing API key setup logic
...
```
Then `ProviderAnthropicOAuth` can call:
```python
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
super().__init__(provider_config, provider_settings, use_api_key=False)
# OAuth-specific initialization follows...
```
This keeps the base-class initialization path consistent, centralizes shared setup in `ProviderAnthropic`, and makes it clearer that API-key logic is intentionally disabled for OAuth rather than implicitly skipped.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
7107a69 to
6d1ed99
Compare
Member
|
没怎么用过 Claude Code(个人主要使用 Codex 哈哈),是不是可以直接通过运行 |
Contributor
Author
6d1ed99 to
3d0e58b
Compare
3d0e58b to
026d96d
Compare
Contributor
Author
|
Superseded by a new PR with simplified implementation (reusing key field instead of separate oauth_token field). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


支持使用 Anthropic Claude Code OAuth 订阅作为 AstrBot 的 LLM Provider,同时为 Claude 4.6+ 模型增加自适应思考(adaptive thinking)配置。
改动点
新增文件:
astrbot/core/provider/sources/anthropic_oauth_source.py(约 350 行)~/.claude/.credentials.json(Claude Code CLI)读取凭证auth_token=使用 Bearer 认证,附带必要的 OAuth headers(claude-code-20250219、oauth-2025-04-20、context-1m-2025-08-07)mkstemp+os.replace)_ensure_valid_token():asyncio.Lock、磁盘重载与回滚、覆盖 6 种边界场景(过期 / 新鲜 / 损坏 / 竞态等)"You are Claude Code...")修改:
astrbot/core/provider/sources/anthropic_source.py_init_api_key()方法,新增use_api_key=True参数供子类控制_apply_thinking_config()共享方法,同时支持adaptive(4.6+ 推荐)和budget_tokens(传统)两种思考模式_query和_query_stream中重复的内联思考逻辑修改:
astrbot/core/provider/manager.pycase "anthropic_oauth"分支用于动态加载 Provider修改:
astrbot/core/config/default.pyAnthropic (Claude Code OAuth)Provider 模板,key: []以兼容 Dashboardanth_thinking_config:新增type(adaptive / 留空)、effort(low / medium / high / max)、budget子字段claude_code_credentials_path元数据,带condition: {"type": "anthropic_oauth"}修改:
astrbot/dashboard/routes/config.py_apply_provider_metadata_overrides()辅助函数,将 Provider 特定的元数据(如 1M 上下文)合并到模型列表 API 响应中修改:i18n 文件(zh-CN、en-US)
运行截图或测试结果
text_chat、text_chat_stream和模型列表均正常工作ruff check .和ruff format .均通过检查清单
requirements.txt和pyproject.toml文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations inrequirements.txtandpyproject.toml.