Skip to content

Comments

feat: add Anthropic Claude Code OAuth provider and adaptive thinking support#5175

Closed
Minidoracat wants to merge 1 commit intoAstrBotDevs:masterfrom
Minidoracat:feat/anthropic-claude-code-oauth
Closed

feat: add Anthropic Claude Code OAuth provider and adaptive thinking support#5175
Minidoracat wants to merge 1 commit intoAstrBotDevs:masterfrom
Minidoracat:feat/anthropic-claude-code-oauth

Conversation

@Minidoracat
Copy link
Contributor

@Minidoracat Minidoracat commented Feb 18, 2026

支持使用 Anthropic Claude Code OAuth 订阅作为 AstrBot 的 LLM Provider,同时为 Claude 4.6+ 模型增加自适应思考(adaptive thinking)配置。

改动点

新增文件:astrbot/core/provider/sources/anthropic_oauth_source.py(约 350 行)

  • 完整的 OAuth Provider 适配器,从 ~/.claude/.credentials.json(Claude Code CLI)读取凭证
  • 通过 auth_token= 使用 Bearer 认证,附带必要的 OAuth headers(claude-code-20250219oauth-2025-04-20context-1m-2025-08-07
  • 自动刷新 token,原子写入凭证文件(mkstemp + os.replace
  • 并发安全的 _ensure_valid_token()asyncio.Lock、磁盘重载与回滚、覆盖 6 种边界场景(过期 / 新鲜 / 损坏 / 竞态等)
  • Opus 4.6 / Sonnet 4.6 模型自动检测 1M 上下文窗口
  • Dashboard 元数据覆盖,正确显示上下文窗口大小
  • 注入 OAuth 端点所需的系统提示前缀("You are Claude Code..."

修改:astrbot/core/provider/sources/anthropic_source.py

  • 将 API key 初始化逻辑抽取为 _init_api_key() 方法,新增 use_api_key=True 参数供子类控制
  • 抽取 _apply_thinking_config() 共享方法,同时支持 adaptive(4.6+ 推荐)和 budget_tokens(传统)两种思考模式
  • 替换了 _query_query_stream 中重复的内联思考逻辑

修改:astrbot/core/provider/manager.py

  • 新增 case "anthropic_oauth" 分支用于动态加载 Provider

修改:astrbot/core/config/default.py

  • 新增 Anthropic (Claude Code OAuth) Provider 模板,key: [] 以兼容 Dashboard
  • 扩展 anth_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)

  • 为所有新配置字段添加翻译

运行截图或测试结果

  • 在远程服务器上完成 E2E 测试:text_chattext_chat_stream 和模型列表均正常工作
  • Dashboard UI 验证通过:所有新字段(思考类型、effort、凭证路径)正确渲染并支持 i18n
  • ruff check .ruff format . 均通过
  • 经过 6 轮深度 Code Review,共发现并修复 20 个问题(并发、原子性、回滚、客户端同步等)
image image image

检查清单

  • 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
  • 👀 我的更改经过了良好的测试,并已在上方提供了"验证步骤"和"运行截图"。/ My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
  • 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 requirements.txtpyproject.toml 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
  • 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.

@auto-assign auto-assign bot requested review from Soulter and anka-afk February 18, 2026 00:41
@dosubot dosubot bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Feb 18, 2026
@dosubot
Copy link

dosubot bot commented Feb 18, 2026

Related Documentation

Checked 1 published document(s) in 1 knowledge base(s). No updates required.

How did I do? Any feedback?  Join Discord

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - 我发现了两个问题,并给出了一些整体性的反馈:

  • _apply_thinking_config 中,设置 payloads["output_config"] = {"effort": effort} 会覆盖调用方可能已经设置好的 output_config;建议将配置合并到已有的字典中,而不是直接替换。
  • 常量 _OAUTH_DEFAULT_HEADERSuser-agent 和一些 beta 头版本(claude-code-20250219oauth-2025-04-20context-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>

Sourcery 对开源项目完全免费——如果你喜欢我们的代码评审,欢迎分享 ✨
帮我变得更有用!请在每条评论上点 👍 或 👎,我会根据你的反馈改进后续的评审。
Original comment in English

Hey - I've found 2 issues, and left some high level feedback:

  • 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.
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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@dosubot dosubot bot added the area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. label Feb 18, 2026
@Minidoracat Minidoracat force-pushed the feat/anthropic-claude-code-oauth branch 2 times, most recently from 7107a69 to 6d1ed99 Compare February 18, 2026 00:55
@Soulter
Copy link
Member

Soulter commented Feb 18, 2026

没怎么用过 Claude Code(个人主要使用 Codex 哈哈),是不是可以直接通过运行 claude setup-token 得到 Anthropic token 来鉴权,不需要传递 credential.json?

@Minidoracat
Copy link
Contributor Author

没怎么用过 Claude Code(个人主要使用 Codex 哈哈),是不是可以直接通过运行 claude setup-token 得到 Anthropic token 来鉴权,不需要传递 credential.json?

openclaw 確實是這樣,然後填入 token
image

image

要改這樣的方式也是可以

@Minidoracat Minidoracat force-pushed the feat/anthropic-claude-code-oauth branch from 6d1ed99 to 3d0e58b Compare February 18, 2026 12:32
@Minidoracat Minidoracat force-pushed the feat/anthropic-claude-code-oauth branch from 3d0e58b to 026d96d Compare February 18, 2026 13:12
@Minidoracat
Copy link
Contributor Author

Superseded by a new PR with simplified implementation (reusing key field instead of separate oauth_token field).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants