Skip to content

Commit 7b30244

Browse files
authored
feat: add Anthropic Claude Code OAuth provider and adaptive thinking support (#5209)
* feat: add Anthropic Claude Code OAuth provider and adaptive thinking support * fix: add defensive guard for metadata overrides and align budget condition with docs * 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
1 parent ae839ef commit 7b30244

10 files changed

Lines changed: 246 additions & 24 deletions

File tree

astrbot/core/config/default.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -979,7 +979,19 @@ class ChatProviderTemplate(TypedDict):
979979
"api_base": "https://api.anthropic.com/v1",
980980
"timeout": 120,
981981
"proxy": "",
982-
"anth_thinking_config": {"budget": 0},
982+
"anth_thinking_config": {"type": "", "budget": 0, "effort": ""},
983+
},
984+
"Anthropic (Claude Code OAuth)": {
985+
"id": "anthropic_claude_code_oauth",
986+
"provider": "anthropic",
987+
"type": "anthropic_oauth",
988+
"provider_type": "chat_completion",
989+
"enable": True,
990+
"api_base": "https://api.anthropic.com",
991+
"timeout": 120,
992+
"proxy": "",
993+
"anth_thinking_config": {"type": "", "budget": 0, "effort": ""},
994+
"key": [],
983995
},
984996
"Moonshot": {
985997
"id": "moonshot",
@@ -1964,13 +1976,25 @@ class ChatProviderTemplate(TypedDict):
19641976
},
19651977
},
19661978
"anth_thinking_config": {
1967-
"description": "Thinking Config",
1979+
"description": "思考配置",
19681980
"type": "object",
19691981
"items": {
1982+
"type": {
1983+
"description": "思考类型",
1984+
"type": "string",
1985+
"options": ["", "adaptive"],
1986+
"hint": "Opus 4.6+ / Sonnet 4.6+ 推荐设为 'adaptive'。留空则使用手动 budget 模式。参见: https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking",
1987+
},
19701988
"budget": {
1971-
"description": "Thinking Budget",
1989+
"description": "思考预算",
19721990
"type": "int",
1973-
"hint": "Anthropic thinking.budget_tokens param. Must >= 1024. See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking",
1991+
"hint": "手动 budget_tokens,需 >= 1024。仅在 type 为空时生效。Opus 4.6 / Sonnet 4.6 上已弃用。参见: https://platform.claude.com/docs/en/build-with-claude/extended-thinking",
1992+
},
1993+
"effort": {
1994+
"description": "思考深度",
1995+
"type": "string",
1996+
"options": ["", "low", "medium", "high", "max"],
1997+
"hint": "type 为 'adaptive' 时控制思考深度。默认 'high'。'max' 仅限 Opus 4.6。参见: https://platform.claude.com/docs/en/build-with-claude/effort",
19741998
},
19751999
},
19762000
},

astrbot/core/provider/manager.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,10 @@ def dynamic_import_provider(self, type: str) -> None:
309309
from .sources.anthropic_source import (
310310
ProviderAnthropic as ProviderAnthropic,
311311
)
312+
case "anthropic_oauth":
313+
from .sources.anthropic_oauth_source import (
314+
ProviderAnthropicOAuth as ProviderAnthropicOAuth,
315+
)
312316
case "googlegenai_chat_completion":
313317
from .sources.gemini_source import (
314318
ProviderGoogleGenAI as ProviderGoogleGenAI,
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
from collections.abc import AsyncGenerator
2+
3+
from anthropic import AsyncAnthropic
4+
5+
from astrbot.core.provider.entities import LLMResponse
6+
7+
from ..register import register_provider_adapter
8+
from .anthropic_source import ProviderAnthropic
9+
10+
_OAUTH_DEFAULT_HEADERS = {
11+
"anthropic-beta": "claude-code-20250219,oauth-2025-04-20,context-1m-2025-08-07",
12+
"user-agent": "claude-cli/1.0.0 (external, cli)",
13+
"x-app": "cli",
14+
"anthropic-dangerous-direct-browser-access": "true",
15+
}
16+
17+
_CLAUDE_CODE_SYSTEM_PREFIX = (
18+
"You are Claude Code, Anthropic's official CLI for Claude.\n\n"
19+
)
20+
21+
# 支持 1M 上下文窗口的模型前缀(需配合 context-1m beta header)。
22+
# 新增 4.6+ 模型时需同步更新此列表。
23+
_1M_CONTEXT_MODEL_PREFIXES = (
24+
"claude-opus-4-6",
25+
"claude-sonnet-4-6",
26+
)
27+
28+
29+
@register_provider_adapter(
30+
"anthropic_oauth",
31+
"Anthropic Claude Code OAuth provider adapter",
32+
)
33+
class ProviderAnthropicOAuth(ProviderAnthropic):
34+
def __init__(
35+
self,
36+
provider_config: dict,
37+
provider_settings: dict,
38+
) -> None:
39+
# 禁用父类的 API key 客户端初始化,避免重复构造客户端
40+
super().__init__(provider_config, provider_settings, use_api_key=False)
41+
42+
# 手动解析 key 列表(父类跳过了 _init_api_key)
43+
self.api_keys: list = self.get_keys()
44+
self.chosen_api_key: str = self.api_keys[0] if self.api_keys else ""
45+
46+
# 使用 auth_token(OAuth Bearer 认证)构建客户端
47+
self.client = AsyncAnthropic(
48+
auth_token=self.chosen_api_key,
49+
timeout=self.timeout,
50+
base_url=self.base_url,
51+
default_headers=_OAUTH_DEFAULT_HEADERS,
52+
http_client=self._create_http_client(provider_config),
53+
)
54+
55+
def set_model(self, model_name: str) -> None:
56+
super().set_model(model_name)
57+
if any(model_name.startswith(p) for p in _1M_CONTEXT_MODEL_PREFIXES):
58+
if self.provider_config.get("max_context_tokens", 0) <= 0:
59+
self.provider_config["max_context_tokens"] = 1_000_000
60+
61+
def get_model_metadata_overrides(self, model_ids: list[str]) -> dict[str, dict]:
62+
overrides = {}
63+
for mid in model_ids:
64+
if any(mid.startswith(p) for p in _1M_CONTEXT_MODEL_PREFIXES):
65+
overrides[mid] = {"limit": {"context": 1_000_000}}
66+
return overrides
67+
68+
def set_key(self, key: str) -> None:
69+
self.chosen_api_key = key
70+
# 切换 key 时需要重建客户端以使用新的 auth_token
71+
self.client = AsyncAnthropic(
72+
auth_token=key,
73+
timeout=self.timeout,
74+
base_url=self.base_url,
75+
default_headers=_OAUTH_DEFAULT_HEADERS,
76+
http_client=self._create_http_client(self.provider_config),
77+
)
78+
79+
async def get_models(self) -> list[str]:
80+
return await super().get_models()
81+
82+
async def test(self, timeout: float = 45.0) -> None:
83+
await super().test(timeout)
84+
85+
async def text_chat(
86+
self,
87+
prompt=None,
88+
session_id=None,
89+
image_urls=None,
90+
func_tool=None,
91+
contexts=None,
92+
system_prompt=None,
93+
tool_calls_result=None,
94+
model=None,
95+
extra_user_content_parts=None,
96+
**kwargs,
97+
) -> LLMResponse:
98+
system_prompt = _CLAUDE_CODE_SYSTEM_PREFIX + (system_prompt or "")
99+
100+
return await super().text_chat(
101+
prompt=prompt,
102+
session_id=session_id,
103+
image_urls=image_urls,
104+
func_tool=func_tool,
105+
contexts=contexts,
106+
system_prompt=system_prompt,
107+
tool_calls_result=tool_calls_result,
108+
model=model,
109+
extra_user_content_parts=extra_user_content_parts,
110+
**kwargs,
111+
)
112+
113+
async def text_chat_stream(
114+
self,
115+
prompt=None,
116+
session_id=None,
117+
image_urls=None,
118+
func_tool=None,
119+
contexts=None,
120+
system_prompt=None,
121+
tool_calls_result=None,
122+
model=None,
123+
extra_user_content_parts=None,
124+
**kwargs,
125+
) -> AsyncGenerator[LLMResponse, None]:
126+
system_prompt = _CLAUDE_CODE_SYSTEM_PREFIX + (system_prompt or "")
127+
128+
async for llm_response in super().text_chat_stream(
129+
prompt=prompt,
130+
session_id=session_id,
131+
image_urls=image_urls,
132+
func_tool=func_tool,
133+
contexts=contexts,
134+
system_prompt=system_prompt,
135+
tool_calls_result=tool_calls_result,
136+
model=model,
137+
extra_user_content_parts=extra_user_content_parts,
138+
**kwargs,
139+
):
140+
yield llm_response

astrbot/core/provider/sources/anthropic_source.py

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,36 +33,57 @@ def __init__(
3333
self,
3434
provider_config,
3535
provider_settings,
36+
*,
37+
use_api_key: bool = True,
3638
) -> None:
3739
super().__init__(
3840
provider_config,
3941
provider_settings,
4042
)
4143

42-
self.chosen_api_key: str = ""
43-
self.api_keys: list = super().get_keys()
44-
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else ""
4544
self.base_url = provider_config.get("api_base", "https://api.anthropic.com")
4645
self.timeout = provider_config.get("timeout", 120)
4746
if isinstance(self.timeout, str):
4847
self.timeout = int(self.timeout)
48+
self.thinking_config = provider_config.get("anth_thinking_config", {})
49+
50+
if use_api_key:
51+
self._init_api_key(provider_config)
52+
53+
self.set_model(provider_config.get("model", "unknown"))
4954

55+
def _init_api_key(self, provider_config: dict) -> None:
56+
self.chosen_api_key: str = ""
57+
self.api_keys: list = super().get_keys()
58+
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else ""
5059
self.client = AsyncAnthropic(
5160
api_key=self.chosen_api_key,
5261
timeout=self.timeout,
5362
base_url=self.base_url,
5463
http_client=self._create_http_client(provider_config),
5564
)
5665

57-
self.thinking_config = provider_config.get("anth_thinking_config", {})
58-
59-
self.set_model(provider_config.get("model", "unknown"))
60-
6166
def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None:
6267
"""创建带代理的 HTTP 客户端"""
6368
proxy = provider_config.get("proxy", "")
6469
return create_proxy_client("Anthropic", proxy)
6570

71+
def _apply_thinking_config(self, payloads: dict) -> None:
72+
thinking_type = self.thinking_config.get("type", "")
73+
if thinking_type == "adaptive":
74+
payloads["thinking"] = {"type": "adaptive"}
75+
effort = self.thinking_config.get("effort", "")
76+
output_cfg = dict(payloads.get("output_config", {}))
77+
if effort:
78+
output_cfg["effort"] = effort
79+
if output_cfg:
80+
payloads["output_config"] = output_cfg
81+
elif not thinking_type and self.thinking_config.get("budget"):
82+
payloads["thinking"] = {
83+
"budget_tokens": self.thinking_config.get("budget"),
84+
"type": "enabled",
85+
}
86+
6687
def _prepare_payload(self, messages: list[dict]):
6788
"""准备 Anthropic API 的请求 payload
6889
@@ -213,11 +234,7 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
213234

214235
if "max_tokens" not in payloads:
215236
payloads["max_tokens"] = 1024
216-
if self.thinking_config.get("budget"):
217-
payloads["thinking"] = {
218-
"budget_tokens": self.thinking_config.get("budget"),
219-
"type": "enabled",
220-
}
237+
self._apply_thinking_config(payloads)
221238

222239
try:
223240
completion = await self.client.messages.create(
@@ -287,11 +304,7 @@ async def _query_stream(
287304

288305
if "max_tokens" not in payloads:
289306
payloads["max_tokens"] = 1024
290-
if self.thinking_config.get("budget"):
291-
payloads["thinking"] = {
292-
"budget_tokens": self.thinking_config.get("budget"),
293-
"type": "enabled",
294-
}
307+
self._apply_thinking_config(payloads)
295308

296309
async with self.client.messages.stream(
297310
**payloads, extra_body=extra_body

astrbot/dashboard/routes/config.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,23 @@
4040
MAX_FILE_BYTES = 500 * 1024 * 1024
4141

4242

43+
def _apply_provider_metadata_overrides(
44+
provider: Any, model_ids: list[str], metadata_map: dict
45+
) -> None:
46+
override_fn = getattr(provider, "get_model_metadata_overrides", None)
47+
if not callable(override_fn):
48+
return
49+
overrides_map = override_fn(model_ids) or {}
50+
for mid, overrides in overrides_map.items():
51+
merged = dict(metadata_map.get(mid, {}))
52+
for key, value in overrides.items():
53+
if isinstance(value, dict):
54+
merged[key] = {**merged.get(key, {}), **value}
55+
else:
56+
merged[key] = value
57+
metadata_map[mid] = merged
58+
59+
4360
def try_cast(value: Any, type_: str):
4461
if type_ == "int":
4562
try:
@@ -727,6 +744,8 @@ async def get_provider_model_list(self):
727744
if meta:
728745
metadata_map[model_id] = meta
729746

747+
_apply_provider_metadata_overrides(provider, models, metadata_map)
748+
730749
ret = {
731750
"models": models,
732751
"provider_id": provider_id,
@@ -872,6 +891,8 @@ async def get_provider_source_models(self):
872891
if meta:
873892
metadata_map[model_id] = meta
874893

894+
_apply_provider_metadata_overrides(inst, models, metadata_map)
895+
875896
# 销毁实例(如果有 terminate 方法)
876897
terminate_fn = getattr(inst, "terminate", None)
877898
if inspect.iscoroutinefunction(terminate_fn):

dashboard/src/composables/useProviderSources.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,9 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
241241
// 为 provider source 的 id 字段添加自定义 hint
242242
if (customSchema.provider?.items?.id) {
243243
customSchema.provider.items.id.hint = tm('providerSources.hints.id')
244-
customSchema.provider.items.key.hint = tm('providerSources.hints.key')
244+
customSchema.provider.items.key.hint = editableProviderSource.value?.type === 'anthropic_oauth'
245+
? tm('providerSources.hints.oauthToken')
246+
: tm('providerSources.hints.key')
245247
customSchema.provider.items.api_base.hint = tm('providerSources.hints.apiBase')
246248
}
247249
// 为 proxy 字段添加描述和提示

dashboard/src/i18n/locales/en-US/features/config-metadata.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1178,9 +1178,17 @@
11781178
},
11791179
"anth_thinking_config": {
11801180
"description": "Thinking Config",
1181+
"type": {
1182+
"description": "Thinking Type",
1183+
"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"
1184+
},
11811185
"budget": {
11821186
"description": "Thinking Budget",
1183-
"hint": "Anthropic thinking.budget_tokens param. Must >= 1024. See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking"
1187+
"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"
1188+
},
1189+
"effort": {
1190+
"description": "Effort Level",
1191+
"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"
11841192
}
11851193
},
11861194
"minimax-group-id": {

dashboard/src/i18n/locales/en-US/features/provider.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@
114114
"hints": {
115115
"id": "Provider source ID (not provider ID)",
116116
"key": "API key for authentication",
117+
"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.",
117118
"apiBase": "Custom API endpoint URL",
118119
"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."
119120
},

dashboard/src/i18n/locales/zh-CN/features/config-metadata.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1181,9 +1181,17 @@
11811181
},
11821182
"anth_thinking_config": {
11831183
"description": "思考配置",
1184+
"type": {
1185+
"description": "思考类型",
1186+
"hint": "设为 'adaptive' 以使用自适应思考(推荐 Opus 4.6+ / Sonnet 4.6+)。留空则使用手动预算模式。参见: https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking"
1187+
},
11841188
"budget": {
11851189
"description": "思考预算",
1186-
"hint": "Anthropic thinking.budget_tokens 参数。必须 >= 1024。参见: https://platform.claude.com/docs/en/build-with-claude/extended-thinking"
1190+
"hint": "Anthropic thinking.budget_tokens 参数。必须 >= 1024。仅在思考类型为空时生效。Opus 4.6 / Sonnet 4.6 已弃用。参见: https://platform.claude.com/docs/en/build-with-claude/extended-thinking"
1191+
},
1192+
"effort": {
1193+
"description": "思考深度",
1194+
"hint": "当思考类型为 'adaptive' 时控制思考深度。'high' 为默认值。'max' 仅限 Opus 4.6。参见: https://platform.claude.com/docs/en/build-with-claude/effort"
11871195
}
11881196
},
11891197
"minimax-group-id": {

dashboard/src/i18n/locales/zh-CN/features/provider.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@
115115
"hints": {
116116
"id": "提供商源唯一 ID(不是提供商 ID)",
117117
"key": "API 密钥",
118+
"oauthToken": "在终端运行 `claude setup-token` 获取长期有效的 OAuth Token,然后粘贴到此处。Token 有效期为 1 年。",
118119
"apiBase": "自定义 API 端点 URL",
119120
"proxy": "HTTP/HTTPS 代理地址,格式如 http://127.0.0.1:7890。仅对该提供商的 API 请求生效,不影响 Docker 内网通信。"
120121
},

0 commit comments

Comments
 (0)