From a27ae06bfa0e6993325bb72a37a99101104bfccb Mon Sep 17 00:00:00 2001 From: Minidoracat Date: Wed, 18 Feb 2026 08:39:52 +0800 Subject: [PATCH 1/3] feat: add Anthropic Claude Code OAuth provider and adaptive thinking support --- astrbot/core/config/default.py | 32 ++++- astrbot/core/provider/manager.py | 4 + .../sources/anthropic_oauth_source.py | 136 ++++++++++++++++++ .../core/provider/sources/anthropic_source.py | 47 +++--- astrbot/dashboard/routes/config.py | 17 +++ .../src/composables/useProviderSources.ts | 4 +- .../en-US/features/config-metadata.json | 10 +- .../i18n/locales/en-US/features/provider.json | 1 + .../zh-CN/features/config-metadata.json | 10 +- .../i18n/locales/zh-CN/features/provider.json | 1 + 10 files changed, 238 insertions(+), 24 deletions(-) create mode 100644 astrbot/core/provider/sources/anthropic_oauth_source.py diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 8602250cf0..ff38f79ad1 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -978,7 +978,19 @@ class ChatProviderTemplate(TypedDict): "api_base": "https://api.anthropic.com/v1", "timeout": 120, "proxy": "", - "anth_thinking_config": {"budget": 0}, + "anth_thinking_config": {"type": "", "budget": 0, "effort": ""}, + }, + "Anthropic (Claude Code OAuth)": { + "id": "anthropic_claude_code_oauth", + "provider": "anthropic", + "type": "anthropic_oauth", + "provider_type": "chat_completion", + "enable": True, + "api_base": "https://api.anthropic.com", + "timeout": 120, + "proxy": "", + "anth_thinking_config": {"type": "", "budget": 0, "effort": ""}, + "key": [], }, "Moonshot": { "id": "moonshot", @@ -1939,13 +1951,25 @@ class ChatProviderTemplate(TypedDict): }, }, "anth_thinking_config": { - "description": "Thinking Config", + "description": "思考配置", "type": "object", "items": { + "type": { + "description": "思考类型", + "type": "string", + "options": ["", "adaptive"], + "hint": "Opus 4.6+ / Sonnet 4.6+ 推荐设为 'adaptive'。留空则使用手动 budget 模式。参见: https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking", + }, "budget": { - "description": "Thinking Budget", + "description": "思考预算", "type": "int", - "hint": "Anthropic thinking.budget_tokens param. Must >= 1024. See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking", + "hint": "手动 budget_tokens,需 >= 1024。仅在 type 为空时生效。Opus 4.6 / Sonnet 4.6 上已弃用。参见: https://platform.claude.com/docs/en/build-with-claude/extended-thinking", + }, + "effort": { + "description": "思考深度", + "type": "string", + "options": ["", "low", "medium", "high", "max"], + "hint": "type 为 'adaptive' 时控制思考深度。默认 'high'。'max' 仅限 Opus 4.6。参见: https://platform.claude.com/docs/en/build-with-claude/effort", }, }, }, diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index ff0bb303dd..296491b4d8 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -299,6 +299,10 @@ def dynamic_import_provider(self, type: str) -> None: from .sources.anthropic_source import ( ProviderAnthropic as ProviderAnthropic, ) + case "anthropic_oauth": + from .sources.anthropic_oauth_source import ( + ProviderAnthropicOAuth as ProviderAnthropicOAuth, + ) case "googlegenai_chat_completion": from .sources.gemini_source import ( ProviderGoogleGenAI as ProviderGoogleGenAI, diff --git a/astrbot/core/provider/sources/anthropic_oauth_source.py b/astrbot/core/provider/sources/anthropic_oauth_source.py new file mode 100644 index 0000000000..0698bb4005 --- /dev/null +++ b/astrbot/core/provider/sources/anthropic_oauth_source.py @@ -0,0 +1,136 @@ +from collections.abc import AsyncGenerator + +from anthropic import AsyncAnthropic + +from astrbot.core.provider.entities import LLMResponse + +from ..register import register_provider_adapter +from .anthropic_source import ProviderAnthropic + +_OAUTH_DEFAULT_HEADERS = { + "anthropic-beta": "claude-code-20250219,oauth-2025-04-20,context-1m-2025-08-07", + "user-agent": "claude-cli/1.0.0 (external, cli)", + "x-app": "cli", + "anthropic-dangerous-direct-browser-access": "true", +} + +_CLAUDE_CODE_SYSTEM_PREFIX = ( + "You are Claude Code, Anthropic's official CLI for Claude.\n\n" +) + +# 支持 1M 上下文窗口的模型前缀(需配合 context-1m beta header)。 +# 新增 4.6+ 模型时需同步更新此列表。 +_1M_CONTEXT_MODEL_PREFIXES = ( + "claude-opus-4-6", + "claude-sonnet-4-6", +) + + +@register_provider_adapter( + "anthropic_oauth", + "Anthropic Claude Code OAuth provider adapter", +) +class ProviderAnthropicOAuth(ProviderAnthropic): + def __init__( + self, + provider_config: dict, + provider_settings: dict, + ) -> None: + # 让父类通过 key 字段解析 API keys 列表(支持多组轮询) + super().__init__(provider_config, provider_settings) + + # 使用 auth_token 替换父类的 api_key 客户端(OAuth 使用 Bearer 认证) + self.client = AsyncAnthropic( + auth_token=self.chosen_api_key, + timeout=self.timeout, + base_url=self.base_url, + default_headers=_OAUTH_DEFAULT_HEADERS, + http_client=self._create_http_client(provider_config), + ) + + def set_model(self, model_name: str) -> None: + super().set_model(model_name) + if any(model_name.startswith(p) for p in _1M_CONTEXT_MODEL_PREFIXES): + if self.provider_config.get("max_context_tokens", 0) <= 0: + self.provider_config["max_context_tokens"] = 1_000_000 + + def get_model_metadata_overrides(self, model_ids: list[str]) -> dict[str, dict]: + overrides = {} + for mid in model_ids: + if any(mid.startswith(p) for p in _1M_CONTEXT_MODEL_PREFIXES): + overrides[mid] = {"limit": {"context": 1_000_000}} + return overrides + + def set_key(self, key: str) -> None: + self.chosen_api_key = key + # 切换 key 时需要重建客户端以使用新的 auth_token + self.client = AsyncAnthropic( + auth_token=key, + timeout=self.timeout, + base_url=self.base_url, + default_headers=_OAUTH_DEFAULT_HEADERS, + http_client=self._create_http_client(self.provider_config), + ) + + async def get_models(self) -> list[str]: + return await super().get_models() + + async def test(self, timeout: float = 45.0) -> None: + await super().test(timeout) + + async def text_chat( + self, + prompt=None, + session_id=None, + image_urls=None, + func_tool=None, + contexts=None, + system_prompt=None, + tool_calls_result=None, + model=None, + extra_user_content_parts=None, + **kwargs, + ) -> LLMResponse: + system_prompt = _CLAUDE_CODE_SYSTEM_PREFIX + (system_prompt or "") + + return await super().text_chat( + prompt=prompt, + session_id=session_id, + image_urls=image_urls, + func_tool=func_tool, + contexts=contexts, + system_prompt=system_prompt, + tool_calls_result=tool_calls_result, + model=model, + extra_user_content_parts=extra_user_content_parts, + **kwargs, + ) + + async def text_chat_stream( + self, + prompt=None, + session_id=None, + image_urls=None, + func_tool=None, + contexts=None, + system_prompt=None, + tool_calls_result=None, + model=None, + extra_user_content_parts=None, + **kwargs, + ) -> AsyncGenerator[LLMResponse, None]: + system_prompt = _CLAUDE_CODE_SYSTEM_PREFIX + (system_prompt or "") + + async for llm_response in super().text_chat_stream( + prompt=prompt, + session_id=session_id, + image_urls=image_urls, + func_tool=func_tool, + contexts=contexts, + system_prompt=system_prompt, + tool_calls_result=tool_calls_result, + model=model, + extra_user_content_parts=extra_user_content_parts, + **kwargs, + ): + yield llm_response diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index 80684aca6d..a73581e5ef 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -33,20 +33,29 @@ def __init__( self, provider_config, provider_settings, + *, + use_api_key: bool = True, ) -> None: super().__init__( provider_config, provider_settings, ) - self.chosen_api_key: str = "" - self.api_keys: list = super().get_keys() - self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else "" self.base_url = provider_config.get("api_base", "https://api.anthropic.com") self.timeout = provider_config.get("timeout", 120) if isinstance(self.timeout, str): self.timeout = int(self.timeout) + self.thinking_config = provider_config.get("anth_thinking_config", {}) + + if use_api_key: + self._init_api_key(provider_config) + + self.set_model(provider_config.get("model", "unknown")) + def _init_api_key(self, provider_config: dict) -> None: + self.chosen_api_key: str = "" + self.api_keys: list = super().get_keys() + self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else "" self.client = AsyncAnthropic( api_key=self.chosen_api_key, timeout=self.timeout, @@ -54,15 +63,27 @@ def __init__( http_client=self._create_http_client(provider_config), ) - self.thinking_config = provider_config.get("anth_thinking_config", {}) - - self.set_model(provider_config.get("model", "unknown")) - def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None: """创建带代理的 HTTP 客户端""" 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", "") + output_cfg = dict(payloads.get("output_config", {})) + if effort: + output_cfg["effort"] = effort + if output_cfg: + payloads["output_config"] = output_cfg + elif self.thinking_config.get("budget"): + payloads["thinking"] = { + "budget_tokens": self.thinking_config.get("budget"), + "type": "enabled", + } + def _prepare_payload(self, messages: list[dict]): """准备 Anthropic API 的请求 payload @@ -213,11 +234,7 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: if "max_tokens" not in payloads: payloads["max_tokens"] = 1024 - if self.thinking_config.get("budget"): - payloads["thinking"] = { - "budget_tokens": self.thinking_config.get("budget"), - "type": "enabled", - } + self._apply_thinking_config(payloads) try: completion = await self.client.messages.create( @@ -287,11 +304,7 @@ async def _query_stream( if "max_tokens" not in payloads: payloads["max_tokens"] = 1024 - if self.thinking_config.get("budget"): - payloads["thinking"] = { - "budget_tokens": self.thinking_config.get("budget"), - "type": "enabled", - } + self._apply_thinking_config(payloads) async with self.client.messages.stream( **payloads, extra_body=extra_body diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index 6d60fb6de0..c2d6c308f4 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -40,6 +40,19 @@ MAX_FILE_BYTES = 500 * 1024 * 1024 +def _apply_provider_metadata_overrides( + provider: Any, model_ids: list[str], metadata_map: dict +) -> None: + override_fn = getattr(provider, "get_model_metadata_overrides", None) + if not callable(override_fn): + return + for mid, overrides in override_fn(model_ids).items(): + merged = dict(metadata_map.get(mid, {})) + if "limit" in overrides: + merged["limit"] = {**merged.get("limit", {}), **overrides["limit"]} + metadata_map[mid] = merged + + def try_cast(value: Any, type_: str): if type_ == "int": try: @@ -727,6 +740,8 @@ async def get_provider_model_list(self): if meta: metadata_map[model_id] = meta + _apply_provider_metadata_overrides(provider, models, metadata_map) + ret = { "models": models, "provider_id": provider_id, @@ -872,6 +887,8 @@ async def get_provider_source_models(self): if meta: metadata_map[model_id] = meta + _apply_provider_metadata_overrides(inst, models, metadata_map) + # 销毁实例(如果有 terminate 方法) terminate_fn = getattr(inst, "terminate", None) if inspect.iscoroutinefunction(terminate_fn): diff --git a/dashboard/src/composables/useProviderSources.ts b/dashboard/src/composables/useProviderSources.ts index 97eb044da2..5329c150b6 100644 --- a/dashboard/src/composables/useProviderSources.ts +++ b/dashboard/src/composables/useProviderSources.ts @@ -241,7 +241,9 @@ export function useProviderSources(options: UseProviderSourcesOptions) { // 为 provider source 的 id 字段添加自定义 hint if (customSchema.provider?.items?.id) { customSchema.provider.items.id.hint = tm('providerSources.hints.id') - customSchema.provider.items.key.hint = tm('providerSources.hints.key') + customSchema.provider.items.key.hint = editableProviderSource.value?.type === 'anthropic_oauth' + ? tm('providerSources.hints.oauthToken') + : tm('providerSources.hints.key') customSchema.provider.items.api_base.hint = tm('providerSources.hints.apiBase') } // 为 proxy 字段添加描述和提示 diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index c61be33ef4..ecc1cd4b58 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -1174,9 +1174,17 @@ }, "anth_thinking_config": { "description": "Thinking Config", + "type": { + "description": "Thinking Type", + "hint": "Set 'adaptive' for Opus 4.6+ / Sonnet 4.6+ (recommended). Leave empty to use manual budget mode. See: https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking" + }, "budget": { "description": "Thinking Budget", - "hint": "Anthropic thinking.budget_tokens param. Must >= 1024. See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking" + "hint": "Anthropic thinking.budget_tokens param. Must >= 1024. Only used when type is empty. Deprecated on Opus 4.6 / Sonnet 4.6. See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking" + }, + "effort": { + "description": "Effort Level", + "hint": "Controls thinking depth when type is 'adaptive'. 'high' is the default. 'max' is Opus 4.6 only. See: https://platform.claude.com/docs/en/build-with-claude/effort" } }, "minimax-group-id": { diff --git a/dashboard/src/i18n/locales/en-US/features/provider.json b/dashboard/src/i18n/locales/en-US/features/provider.json index f36053f72d..5d6e935c7e 100644 --- a/dashboard/src/i18n/locales/en-US/features/provider.json +++ b/dashboard/src/i18n/locales/en-US/features/provider.json @@ -114,6 +114,7 @@ "hints": { "id": "Provider source ID (not provider ID)", "key": "API key for authentication", + "oauthToken": "Run `claude setup-token` in your terminal to get a long-lived OAuth token, then paste it here. Token is valid for 1 year.", "apiBase": "Custom API endpoint URL", "proxy": "HTTP/HTTPS proxy address, e.g. http://127.0.0.1:7890. Only affects this provider's API requests, doesn't interfere with Docker internal networking." }, diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index a51723d59d..89077d7a1e 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -1177,9 +1177,17 @@ }, "anth_thinking_config": { "description": "思考配置", + "type": { + "description": "思考类型", + "hint": "设为 'adaptive' 以使用自适应思考(推荐 Opus 4.6+ / Sonnet 4.6+)。留空则使用手动预算模式。参见: https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking" + }, "budget": { "description": "思考预算", - "hint": "Anthropic thinking.budget_tokens 参数。必须 >= 1024。参见: https://platform.claude.com/docs/en/build-with-claude/extended-thinking" + "hint": "Anthropic thinking.budget_tokens 参数。必须 >= 1024。仅在思考类型为空时生效。Opus 4.6 / Sonnet 4.6 已弃用。参见: https://platform.claude.com/docs/en/build-with-claude/extended-thinking" + }, + "effort": { + "description": "思考深度", + "hint": "当思考类型为 'adaptive' 时控制思考深度。'high' 为默认值。'max' 仅限 Opus 4.6。参见: https://platform.claude.com/docs/en/build-with-claude/effort" } }, "minimax-group-id": { diff --git a/dashboard/src/i18n/locales/zh-CN/features/provider.json b/dashboard/src/i18n/locales/zh-CN/features/provider.json index cf3cdba0c2..3e5c0109d6 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/provider.json +++ b/dashboard/src/i18n/locales/zh-CN/features/provider.json @@ -115,6 +115,7 @@ "hints": { "id": "提供商源唯一 ID(不是提供商 ID)", "key": "API 密钥", + "oauthToken": "在终端运行 `claude setup-token` 获取长期有效的 OAuth Token,然后粘贴到此处。Token 有效期为 1 年。", "apiBase": "自定义 API 端点 URL", "proxy": "HTTP/HTTPS 代理地址,格式如 http://127.0.0.1:7890。仅对该提供商的 API 请求生效,不影响 Docker 内网通信。" }, From 96b720856062af8e402b8cdc029cca186a0104e6 Mon Sep 17 00:00:00 2001 From: Minidoracat Date: Wed, 18 Feb 2026 21:39:38 +0800 Subject: [PATCH 2/3] fix: add defensive guard for metadata overrides and align budget condition with docs --- astrbot/core/provider/sources/anthropic_source.py | 2 +- astrbot/dashboard/routes/config.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index a73581e5ef..ec3c395a46 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -78,7 +78,7 @@ def _apply_thinking_config(self, payloads: dict) -> None: output_cfg["effort"] = effort if output_cfg: payloads["output_config"] = output_cfg - elif self.thinking_config.get("budget"): + elif not thinking_type and self.thinking_config.get("budget"): payloads["thinking"] = { "budget_tokens": self.thinking_config.get("budget"), "type": "enabled", diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index c2d6c308f4..4d6a80d039 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -46,7 +46,8 @@ def _apply_provider_metadata_overrides( override_fn = getattr(provider, "get_model_metadata_overrides", None) if not callable(override_fn): return - for mid, overrides in override_fn(model_ids).items(): + overrides_map = override_fn(model_ids) or {} + for mid, overrides in overrides_map.items(): merged = dict(metadata_map.get(mid, {})) if "limit" in overrides: merged["limit"] = {**merged.get("limit", {}), **overrides["limit"]} From 54e204db7686aade2f55d5971ee0a1a7ca7c8440 Mon Sep 17 00:00:00 2001 From: Minidoracat Date: Wed, 18 Feb 2026 21:53:44 +0800 Subject: [PATCH 3/3] refactor: adopt sourcery-ai suggestions for OAuth provider - Use use_api_key=False in OAuth subclass to avoid redundant API-key client construction before replacing with auth_token client - Generalize metadata override helper to merge all dict keys instead of only handling 'limit', improving extensibility --- .../core/provider/sources/anthropic_oauth_source.py | 10 +++++++--- astrbot/dashboard/routes/config.py | 7 +++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/astrbot/core/provider/sources/anthropic_oauth_source.py b/astrbot/core/provider/sources/anthropic_oauth_source.py index 0698bb4005..57ded1a2ca 100644 --- a/astrbot/core/provider/sources/anthropic_oauth_source.py +++ b/astrbot/core/provider/sources/anthropic_oauth_source.py @@ -36,10 +36,14 @@ def __init__( provider_config: dict, provider_settings: dict, ) -> None: - # 让父类通过 key 字段解析 API keys 列表(支持多组轮询) - super().__init__(provider_config, provider_settings) + # 禁用父类的 API key 客户端初始化,避免重复构造客户端 + super().__init__(provider_config, provider_settings, use_api_key=False) - # 使用 auth_token 替换父类的 api_key 客户端(OAuth 使用 Bearer 认证) + # 手动解析 key 列表(父类跳过了 _init_api_key) + self.api_keys: list = self.get_keys() + self.chosen_api_key: str = self.api_keys[0] if self.api_keys else "" + + # 使用 auth_token(OAuth Bearer 认证)构建客户端 self.client = AsyncAnthropic( auth_token=self.chosen_api_key, timeout=self.timeout, diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index 4d6a80d039..4a72853ec9 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -49,8 +49,11 @@ def _apply_provider_metadata_overrides( overrides_map = override_fn(model_ids) or {} for mid, overrides in overrides_map.items(): merged = dict(metadata_map.get(mid, {})) - if "limit" in overrides: - merged["limit"] = {**merged.get("limit", {}), **overrides["limit"]} + for key, value in overrides.items(): + if isinstance(value, dict): + merged[key] = {**merged.get(key, {}), **value} + else: + merged[key] = value metadata_map[mid] = merged