diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 8602250cf..ff38f79ad 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 ff0bb303d..296491b4d 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 000000000..57ded1a2c --- /dev/null +++ b/astrbot/core/provider/sources/anthropic_oauth_source.py @@ -0,0 +1,140 @@ +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: + # 禁用父类的 API key 客户端初始化,避免重复构造客户端 + super().__init__(provider_config, provider_settings, use_api_key=False) + + # 手动解析 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, + 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 80684aca6..ec3c395a4 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 not thinking_type and 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 6d60fb6de..4a72853ec 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -40,6 +40,23 @@ 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 + overrides_map = override_fn(model_ids) or {} + for mid, overrides in overrides_map.items(): + merged = dict(metadata_map.get(mid, {})) + for key, value in overrides.items(): + if isinstance(value, dict): + merged[key] = {**merged.get(key, {}), **value} + else: + merged[key] = value + metadata_map[mid] = merged + + def try_cast(value: Any, type_: str): if type_ == "int": try: @@ -727,6 +744,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 +891,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 97eb044da..5329c150b 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 c61be33ef..ecc1cd4b5 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 f36053f72..5d6e935c7 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 a51723d59..89077d7a1 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 cf3cdba0c..3e5c0109d 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 内网通信。" },