claude_code.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. """
  2. Claude Code Provider (Anthropic Direct)
  3. 使用 Anthropic 官方 API 调用 Claude 模型
  4. 使用 Anthropic Messages API 格式(/v1/messages)
  5. 环境变量:
  6. - ANTHROPIC_BASE_URL: API 基础地址(如 https://api.anthropic.com)
  7. - ANTHROPIC_AUTH_TOKEN: API 密钥
  8. 注意:
  9. - 使用 Anthropic 原生 Messages API 格式
  10. - 响应格式转换为框架统一的 OpenAI 兼容格式
  11. """
  12. import os
  13. import json
  14. import asyncio
  15. import logging
  16. import httpx
  17. from typing import List, Dict, Any, Optional
  18. from .usage import TokenUsage
  19. from .pricing import calculate_cost
  20. logger = logging.getLogger(__name__)
  21. # 可重试的异常类型
  22. _RETRYABLE_EXCEPTIONS = (
  23. httpx.RemoteProtocolError,
  24. httpx.ConnectError,
  25. httpx.ReadTimeout,
  26. httpx.WriteTimeout,
  27. httpx.ConnectTimeout,
  28. httpx.PoolTimeout,
  29. ConnectionError,
  30. )
  31. # 模糊匹配规则:(关键词, 目标模型名),从精确到宽泛排序
  32. # 精确匹配走 MODEL_EXACT,不命中则按顺序尝试关键词匹配
  33. MODEL_EXACT = {
  34. "claude-sonnet-4-6": "claude-sonnet-4-6",
  35. "claude-sonnet-4.6": "claude-sonnet-4-6",
  36. "claude-sonnet-4-5-20250929": "claude-sonnet-4-5-20250929",
  37. "claude-sonnet-4-5": "claude-sonnet-4-5-20250929",
  38. "claude-sonnet-4.5": "claude-sonnet-4-5-20250929",
  39. "claude-opus-4-6": "claude-opus-4-6",
  40. "claude-opus-4-5-20251101": "claude-opus-4-5-20251101",
  41. "claude-opus-4-5": "claude-opus-4-5-20251101",
  42. "claude-opus-4-1-20250805": "claude-opus-4-1-20250805",
  43. "claude-opus-4-1": "claude-opus-4-1-20250805",
  44. "claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
  45. "claude-haiku-4-5": "claude-haiku-4-5-20251001",
  46. }
  47. MODEL_FUZZY = [
  48. # 版本+家族(精确)
  49. ("sonnet-4-6", "claude-sonnet-4-6"),
  50. ("sonnet-4.6", "claude-sonnet-4-6"),
  51. ("sonnet-4-5", "claude-sonnet-4-5-20250929"),
  52. ("sonnet-4.5", "claude-sonnet-4-5-20250929"),
  53. ("opus-4-6", "claude-opus-4-6"),
  54. ("opus-4.6", "claude-opus-4-6"),
  55. ("opus-4-5", "claude-opus-4-5-20251101"),
  56. ("opus-4.5", "claude-opus-4-5-20251101"),
  57. ("opus-4-1", "claude-opus-4-1-20250805"),
  58. ("opus-4.1", "claude-opus-4-1-20250805"),
  59. ("haiku-4-5", "claude-haiku-4-5-20251001"),
  60. ("haiku-4.5", "claude-haiku-4-5-20251001"),
  61. # 仅家族名 → 最新版本
  62. ("sonnet", "claude-sonnet-4-6"),
  63. ("opus", "claude-opus-4-6"),
  64. ("haiku", "claude-haiku-4-5-20251001"),
  65. ]
  66. def _resolve_model(model: str) -> str:
  67. """将任意格式的模型名映射为 Anthropic API 接受的模型名。
  68. 支持:OpenRouter 前缀(anthropic/xxx)、带点号(4.5)、纯家族名(sonnet)等。
  69. """
  70. # 1. 剥离 provider 前缀
  71. if "/" in model:
  72. model = model.split("/", 1)[1]
  73. # 2. 精确匹配
  74. if model in MODEL_EXACT:
  75. return MODEL_EXACT[model]
  76. # 3. 模糊匹配(大小写不敏感)
  77. model_lower = model.lower()
  78. for keyword, target in MODEL_FUZZY:
  79. if keyword in model_lower:
  80. logger.info("模型名模糊匹配: %s → %s", model, target)
  81. return target
  82. # 4. 兜底:原样返回,让 API 报错
  83. logger.warning("未能匹配模型名: %s, 原样传递", model)
  84. return model
  85. def _normalize_tool_call_ids(messages: List[Dict[str, Any]], target_prefix: str) -> List[Dict[str, Any]]:
  86. """
  87. 将消息历史中的 tool_call_id 统一重写为目标 Provider 的格式。
  88. 跨 Provider 续跑时,历史中的 tool_call_id 可能不兼容目标 API
  89. (如 Anthropic 的 toolu_xxx 发给 OpenAI,或 OpenAI 的 call_xxx 发给 Anthropic)。
  90. 仅在检测到异格式 ID 时才重写,同格式直接跳过。
  91. """
  92. # 第一遍:收集需要重写的 ID
  93. id_map: Dict[str, str] = {}
  94. counter = 0
  95. for msg in messages:
  96. if msg.get("role") == "assistant" and msg.get("tool_calls"):
  97. for tc in msg["tool_calls"]:
  98. old_id = tc.get("id", "")
  99. if old_id and not old_id.startswith(target_prefix + "_"):
  100. if old_id not in id_map:
  101. id_map[old_id] = f"{target_prefix}_{counter:06x}"
  102. counter += 1
  103. if not id_map:
  104. return messages # 无需重写
  105. logger.info("重写 %d 个 tool_call_id (target_prefix=%s)", len(id_map), target_prefix)
  106. # 第二遍:重写(浅拷贝避免修改原始数据)
  107. result = []
  108. for msg in messages:
  109. if msg.get("role") == "assistant" and msg.get("tool_calls"):
  110. new_tcs = []
  111. for tc in msg["tool_calls"]:
  112. old_id = tc.get("id", "")
  113. if old_id in id_map:
  114. new_tcs.append({**tc, "id": id_map[old_id]})
  115. else:
  116. new_tcs.append(tc)
  117. result.append({**msg, "tool_calls": new_tcs})
  118. elif msg.get("role") == "tool" and msg.get("tool_call_id") in id_map:
  119. result.append({**msg, "tool_call_id": id_map[msg["tool_call_id"]]})
  120. else:
  121. result.append(msg)
  122. return result
  123. def _convert_content_to_anthropic(content: Any) -> Any:
  124. """
  125. 将 OpenAI 格式的 content(字符串或列表)转换为 Anthropic 格式。
  126. 主要处理 image_url 类型块 → Anthropic image 块。
  127. """
  128. if not isinstance(content, list):
  129. return content
  130. result = []
  131. for block in content:
  132. if not isinstance(block, dict):
  133. result.append(block)
  134. continue
  135. block_type = block.get("type", "")
  136. if block_type == "image_url":
  137. image_url_obj = block.get("image_url", {})
  138. url = image_url_obj.get("url", "") if isinstance(image_url_obj, dict) else str(image_url_obj)
  139. if url.startswith("data:"):
  140. # base64 编码图片:data:<media_type>;base64,<data>
  141. header, _, data = url.partition(",")
  142. media_type = header.split(":")[1].split(";")[0] if ":" in header else "image/png"
  143. result.append({
  144. "type": "image",
  145. "source": {
  146. "type": "base64",
  147. "media_type": media_type,
  148. "data": data,
  149. },
  150. })
  151. else:
  152. result.append({
  153. "type": "image",
  154. "source": {
  155. "type": "url",
  156. "url": url,
  157. },
  158. })
  159. else:
  160. result.append(block)
  161. return result
  162. def _convert_messages_to_anthropic(messages: List[Dict[str, Any]]) -> tuple:
  163. """
  164. 将 OpenAI 格式消息转换为 Anthropic Messages API 格式
  165. Returns:
  166. (system_prompt, anthropic_messages)
  167. """
  168. system_prompt = None
  169. anthropic_messages = []
  170. for msg in messages:
  171. role = msg.get("role", "")
  172. content = msg.get("content", "")
  173. if role == "system":
  174. # Anthropic 把 system 消息放在顶层参数中
  175. system_prompt = content
  176. elif role == "user":
  177. anthropic_messages.append({"role": "user", "content": _convert_content_to_anthropic(content)})
  178. elif role == "assistant":
  179. assistant_msg = {"role": "assistant"}
  180. # 处理 tool_calls(assistant 发起工具调用)
  181. tool_calls = msg.get("tool_calls")
  182. if tool_calls:
  183. content_blocks = []
  184. if content:
  185. # content 可能已被 _add_cache_control 转成 list(含 cache_control),
  186. # 也可能是普通字符串。两者都需要正确处理,避免产生 {"type":"text","text":[...]}
  187. converted = _convert_content_to_anthropic(content)
  188. if isinstance(converted, list):
  189. content_blocks.extend(converted)
  190. elif isinstance(converted, str) and converted.strip():
  191. content_blocks.append({"type": "text", "text": converted})
  192. for tc in tool_calls:
  193. func = tc.get("function", {})
  194. args_str = func.get("arguments", "{}")
  195. try:
  196. args = json.loads(args_str) if isinstance(args_str, str) else args_str
  197. except json.JSONDecodeError:
  198. args = {}
  199. content_blocks.append({
  200. "type": "tool_use",
  201. "id": tc.get("id", ""),
  202. "name": func.get("name", ""),
  203. "input": args,
  204. })
  205. assistant_msg["content"] = content_blocks
  206. else:
  207. assistant_msg["content"] = content
  208. anthropic_messages.append(assistant_msg)
  209. elif role == "tool":
  210. # OpenAI tool 结果 -> Anthropic tool_result
  211. # Anthropic 要求同一个 assistant 的所有 tool_results 合并到一个 user message 中
  212. tool_result_block = {
  213. "type": "tool_result",
  214. "tool_use_id": msg.get("tool_call_id", ""),
  215. "content": _convert_content_to_anthropic(content),
  216. }
  217. # 如果上一条已经是 tool_result user message,合并进去
  218. if (anthropic_messages
  219. and anthropic_messages[-1].get("role") == "user"
  220. and isinstance(anthropic_messages[-1].get("content"), list)
  221. and anthropic_messages[-1]["content"]
  222. and anthropic_messages[-1]["content"][0].get("type") == "tool_result"):
  223. anthropic_messages[-1]["content"].append(tool_result_block)
  224. else:
  225. anthropic_messages.append({
  226. "role": "user",
  227. "content": [tool_result_block],
  228. })
  229. return system_prompt, anthropic_messages
  230. def _convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
  231. """将 OpenAI 工具定义转换为 Anthropic 格式"""
  232. anthropic_tools = []
  233. for tool in tools:
  234. if tool.get("type") == "function":
  235. func = tool["function"]
  236. anthropic_tools.append({
  237. "name": func.get("name", ""),
  238. "description": func.get("description", ""),
  239. "input_schema": func.get("parameters", {"type": "object", "properties": {}}),
  240. })
  241. return anthropic_tools
  242. def _parse_anthropic_response(result: Dict[str, Any]) -> Dict[str, Any]:
  243. """
  244. 将 Anthropic Messages API 响应转换为框架统一格式
  245. Anthropic 响应格式:
  246. {
  247. "id": "msg_...",
  248. "type": "message",
  249. "role": "assistant",
  250. "content": [{"type": "text", "text": "..."}, {"type": "tool_use", ...}],
  251. "usage": {"input_tokens": ..., "output_tokens": ...},
  252. "stop_reason": "end_turn" | "tool_use" | "max_tokens"
  253. }
  254. """
  255. content_blocks = result.get("content", [])
  256. # 提取文本内容
  257. text_parts = []
  258. tool_calls = []
  259. for block in content_blocks:
  260. if block.get("type") == "text":
  261. text_parts.append(block.get("text", ""))
  262. elif block.get("type") == "tool_use":
  263. # 转换为 OpenAI tool_calls 格式
  264. tool_calls.append({
  265. "id": block.get("id", ""),
  266. "type": "function",
  267. "function": {
  268. "name": block.get("name", ""),
  269. "arguments": json.dumps(block.get("input", {}), ensure_ascii=False),
  270. },
  271. })
  272. content = "\n".join(text_parts)
  273. # 映射 stop_reason
  274. stop_reason = result.get("stop_reason", "end_turn")
  275. finish_reason_map = {
  276. "end_turn": "stop",
  277. "tool_use": "tool_calls",
  278. "max_tokens": "length",
  279. "stop_sequence": "stop",
  280. }
  281. finish_reason = finish_reason_map.get(stop_reason, stop_reason)
  282. # 提取 usage(Anthropic 原生格式)
  283. raw_usage = result.get("usage", {})
  284. usage = TokenUsage(
  285. input_tokens=raw_usage.get("input_tokens", 0),
  286. output_tokens=raw_usage.get("output_tokens", 0),
  287. cache_creation_tokens=raw_usage.get("cache_creation_input_tokens", 0),
  288. cache_read_tokens=raw_usage.get("cache_read_input_tokens", 0),
  289. )
  290. return {
  291. "content": content,
  292. "tool_calls": tool_calls if tool_calls else None,
  293. "finish_reason": finish_reason,
  294. "usage": usage,
  295. }
  296. async def claude_code_llm_call(
  297. messages: List[Dict[str, Any]],
  298. model: str = "claude-sonnet-4.5",
  299. tools: Optional[List[Dict]] = None,
  300. **kwargs
  301. ) -> Dict[str, Any]:
  302. """
  303. Claude Code (Anthropic) LLM 调用函数
  304. Args:
  305. messages: OpenAI 格式消息列表
  306. model: 模型名称(如 "claude-sonnet-4.5")
  307. tools: OpenAI 格式工具定义
  308. **kwargs: 其他参数(temperature, max_tokens 等)
  309. Returns:
  310. 统一格式的响应字典
  311. """
  312. # base_url = os.getenv("YESCODE_BASE_URL")
  313. # api_key = os.getenv("YESCODE_API_KEY")
  314. base_url = os.getenv("ANTHROPIC_BASE_URL")
  315. api_key = os.getenv("ANTHROPIC_AUTH_TOKEN")
  316. if not base_url:
  317. raise ValueError("ANTHROPIC_BASE_URL environment variable not set")
  318. if not api_key:
  319. raise ValueError("ANTHROPIC_AUTH_TOKEN environment variable not set")
  320. base_url = base_url.rstrip("/")
  321. endpoint = f"{base_url}/v1/messages"
  322. # 解析模型名
  323. api_model = _resolve_model(model)
  324. # 跨 Provider 续跑时,重写不兼容的 tool_call_id
  325. messages = _normalize_tool_call_ids(messages, "toolu")
  326. # 转换消息格式
  327. system_prompt, anthropic_messages = _convert_messages_to_anthropic(messages)
  328. # 构建 Anthropic 格式请求
  329. payload = {
  330. "model": api_model,
  331. "messages": anthropic_messages,
  332. "max_tokens": kwargs.get("max_tokens", 16384),
  333. }
  334. if system_prompt:
  335. payload["system"] = system_prompt
  336. if tools:
  337. payload["tools"] = _convert_tools_to_anthropic(tools)
  338. if "temperature" in kwargs:
  339. payload["temperature"] = kwargs["temperature"]
  340. headers = {
  341. "x-api-key": api_key,
  342. "content-type": "application/json",
  343. "anthropic-version": "2023-06-01",
  344. "user-agent": "claude-code/1.0.0",
  345. }
  346. # 调用 API(带重试)
  347. max_retries = 5
  348. last_exception = None
  349. for attempt in range(max_retries):
  350. async with httpx.AsyncClient(timeout=300.0) as client:
  351. try:
  352. response = await client.post(endpoint, json=payload, headers=headers)
  353. response.raise_for_status()
  354. result = response.json()
  355. break
  356. except httpx.HTTPStatusError as e:
  357. error_body = e.response.text
  358. status = e.response.status_code
  359. if status in (429, 500, 502, 503, 504, 524, 529) and attempt < max_retries - 1:
  360. wait = 2 ** attempt * 2
  361. logger.warning(
  362. "[Claude Code] HTTP %d (attempt %d/%d), retrying in %ds: %s",
  363. status, attempt + 1, max_retries, wait, error_body[:200],
  364. )
  365. await asyncio.sleep(wait)
  366. last_exception = e
  367. continue
  368. logger.error("[Claude Code] Error %d: %s", status, error_body)
  369. print(f"[Claude Code] API Error {status}: {error_body[:500]}")
  370. raise
  371. except _RETRYABLE_EXCEPTIONS as e:
  372. last_exception = e
  373. if attempt < max_retries - 1:
  374. wait = 2 ** attempt * 2
  375. logger.warning(
  376. "[Claude Code] %s (attempt %d/%d), retrying in %ds",
  377. type(e).__name__, attempt + 1, max_retries, wait,
  378. )
  379. await asyncio.sleep(wait)
  380. continue
  381. logger.error("[Claude Code] Request failed after %d attempts: %s", max_retries, e)
  382. raise
  383. except Exception as e:
  384. logger.error("[Claude Code] Request failed: %s", e)
  385. raise
  386. else:
  387. raise last_exception # type: ignore[misc]
  388. # 解析 Anthropic 响应并转换为统一格式
  389. parsed = _parse_anthropic_response(result)
  390. usage = parsed["usage"]
  391. # 计算费用
  392. cost = calculate_cost(model, usage)
  393. return {
  394. "content": parsed["content"],
  395. "tool_calls": parsed["tool_calls"],
  396. "prompt_tokens": usage.input_tokens,
  397. "completion_tokens": usage.output_tokens,
  398. "reasoning_tokens": usage.reasoning_tokens,
  399. "cache_creation_tokens": usage.cache_creation_tokens,
  400. "cache_read_tokens": usage.cache_read_tokens,
  401. "finish_reason": parsed["finish_reason"],
  402. "cost": cost,
  403. "usage": usage,
  404. }
  405. def create_claude_code_llm_call(
  406. model: str = "claude-sonnet-4.5"
  407. ):
  408. """
  409. 创建 Claude Code (Anthropic) LLM 调用函数
  410. Args:
  411. model: 模型名称
  412. - "claude-sonnet-4.5"
  413. Returns:
  414. 异步 LLM 调用函数
  415. """
  416. async def llm_call(
  417. messages: List[Dict[str, Any]],
  418. model: str = model,
  419. tools: Optional[List[Dict]] = None,
  420. **kwargs
  421. ) -> Dict[str, Any]:
  422. return await claude_code_llm_call(messages, model, tools, **kwargs)
  423. return llm_call