claude.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. """
  2. Native Anthropic Provider
  3. 直接使用 Anthropic 原生 API (或完全兼容原生协议的反代如 imds.ai) 调用 Claude 模型。
  4. 复用 openrouter.py 中的格式化拦截模块(无缝支持 AgentRunner Cache Control)。
  5. """
  6. import os
  7. import asyncio
  8. import logging
  9. import httpx
  10. from typing import List, Dict, Any, Optional
  11. from .pricing import calculate_cost
  12. # 直接复用底层已经在 openrouter 内部写好的格式转换/拦截器
  13. from .openrouter import (
  14. _normalize_tool_call_ids,
  15. _to_anthropic_messages,
  16. _to_anthropic_tools,
  17. _parse_anthropic_response,
  18. _RETRYABLE_EXCEPTIONS
  19. )
  20. logger = logging.getLogger(__name__)
  21. async def anthropic_native_llm_call(
  22. messages: List[Dict[str, Any]],
  23. model: str = "claude-3-5-sonnet-20241022",
  24. tools: Optional[List[Dict]] = None,
  25. **kwargs
  26. ) -> Dict[str, Any]:
  27. """
  28. 原生 Anthropic API 调用函数
  29. 支持 CLAUDE_CODE_KEY/CLAUDE_CODE_URL 或 ANTHROPIC_API_KEY/ANTHROPIC_BASE_URL
  30. """
  31. # 优先使用 CLAUDE_CODE_KEY,如果不存在则使用 ANTHROPIC_API_KEY
  32. claude_code_key = os.getenv("CLAUDE_CODE_KEY")
  33. anthropic_key = os.getenv("ANTHROPIC_API_KEY")
  34. if claude_code_key:
  35. api_key = claude_code_key
  36. key_source = "CLAUDE_CODE_KEY"
  37. elif anthropic_key:
  38. api_key = anthropic_key
  39. key_source = "ANTHROPIC_API_KEY"
  40. else:
  41. raise ValueError("CLAUDE_CODE_KEY or ANTHROPIC_API_KEY environment variable not set")
  42. # 优先使用 CLAUDE_CODE_URL,如果不存在则使用 ANTHROPIC_BASE_URL
  43. claude_code_url = os.getenv("CLAUDE_CODE_URL")
  44. anthropic_url = os.getenv("ANTHROPIC_BASE_URL")
  45. if claude_code_url:
  46. base_url = claude_code_url
  47. url_source = "CLAUDE_CODE_URL"
  48. elif anthropic_url:
  49. base_url = anthropic_url
  50. url_source = "ANTHROPIC_BASE_URL"
  51. else:
  52. base_url = "https://api.anthropic.com"
  53. url_source = "default"
  54. endpoint = f"{base_url.rstrip('/')}/v1/messages"
  55. # 记录使用的配置(只在第一次调用时输出)
  56. if not hasattr(anthropic_native_llm_call, '_logged_config'):
  57. logger.info(f"[Anthropic Native] Using {key_source}: {api_key[:20]}...")
  58. logger.info(f"[Anthropic Native] Using {url_source}: {base_url}")
  59. print(f"[Anthropic Native] Using {key_source}: {api_key[:20]}...")
  60. print(f"[Anthropic Native] Using {url_source}: {base_url}")
  61. anthropic_native_llm_call._logged_config = True
  62. anthropic_version = os.getenv("ANTHROPIC_VERSION", "2023-06-01")
  63. # 去掉 anthropic/ opneai/ 等命名空间前缀(如果传入的话)
  64. if "/" in model:
  65. model = model.split("/", 1)[1]
  66. # 工具前缀规范化为 toolu
  67. messages = _normalize_tool_call_ids(messages, "toolu")
  68. # 转换为 Anthropic 格式(这一步也会自动把 Cache Control 拦截写入 payload)
  69. system_prompt, anthropic_messages = _to_anthropic_messages(messages)
  70. # ── Anthropic 原生 API 严格校验:中间位置的 assistant 消息不能有空 content ──
  71. # OpenRouter 对此容忍,但原生 API 会直接 400。因此做净化处理:
  72. # 1. 非末尾的 assistant 消息若 content 为空字符串 → 补一个占位符
  73. # 2. content 为空列表 [] → 同样补位
  74. sanitized = []
  75. for i, m in enumerate(anthropic_messages):
  76. is_last = (i == len(anthropic_messages) - 1)
  77. if m.get("role") == "assistant" and not is_last:
  78. c = m.get("content", "")
  79. if c == "" or c == [] or c is None:
  80. # 跳过完全空的中间 assistant 消息(通常是历史 bug)
  81. logger.debug("[Anthropic Native] Dropped empty assistant message at index %d", i)
  82. continue
  83. sanitized.append(m)
  84. anthropic_messages = sanitized
  85. payload: Dict[str, Any] = {
  86. "model": model,
  87. "messages": anthropic_messages,
  88. "max_tokens": kwargs.get("max_tokens", 8192),
  89. }
  90. if system_prompt is not None:
  91. payload["system"] = system_prompt
  92. if tools:
  93. payload["tools"] = _to_anthropic_tools(tools)
  94. if "temperature" in kwargs:
  95. payload["temperature"] = kwargs["temperature"]
  96. # Debug: 检查 cache_control 是否存在
  97. if logger.isEnabledFor(logging.DEBUG):
  98. cache_control_count = 0
  99. if isinstance(system_prompt, list):
  100. for block in system_prompt:
  101. if isinstance(block, dict) and "cache_control" in block:
  102. cache_control_count += 1
  103. for msg in anthropic_messages:
  104. content = msg.get("content", "")
  105. if isinstance(content, list):
  106. for block in content:
  107. if isinstance(block, dict) and "cache_control" in block:
  108. cache_control_count += 1
  109. if cache_control_count > 0:
  110. logger.debug(f"[Anthropic Native] 发现 {cache_control_count} 个 cache_control 标记,将被发送到原生端点")
  111. headers = {
  112. "x-api-key": api_key,
  113. "anthropic-version": anthropic_version,
  114. "content-type": "application/json",
  115. }
  116. # 支持外部打标签如 anthropic_beta 字段
  117. if kwargs.get("anthropic_beta"):
  118. headers["anthropic-beta"] = kwargs["anthropic_beta"]
  119. max_retries = 3
  120. last_exception = None
  121. for attempt in range(max_retries):
  122. # 将默认 5 分钟超时延迟加长至 15 分钟,防止长文本输出(如 strategy.json 合成)时引发 ReadTimeout
  123. async with httpx.AsyncClient(timeout=900.0) as client:
  124. try:
  125. response = await client.post(endpoint, json=payload, headers=headers)
  126. response.raise_for_status()
  127. result = response.json()
  128. break
  129. except httpx.HTTPStatusError as e:
  130. status = e.response.status_code
  131. error_body = e.response.text
  132. if status in (429, 500, 502, 503, 504) and attempt < max_retries - 1:
  133. wait = 2 ** attempt * 2
  134. logger.warning("[Anthropic Native] HTTP %d (attempt %d/%d), retrying in %ds: %s", status, attempt + 1, max_retries, wait, error_body[:200])
  135. await asyncio.sleep(wait)
  136. last_exception = e
  137. continue
  138. logger.error("[Anthropic Native] HTTP %d error body: %s", status, error_body)
  139. print(f"[Anthropic Native] API Error {status}: {error_body[:500]}")
  140. raise
  141. except _RETRYABLE_EXCEPTIONS as e:
  142. last_exception = e
  143. if attempt < max_retries - 1:
  144. wait = 2 ** attempt * 2
  145. logger.warning("[Anthropic Native] %s (attempt %d/%d), retrying in %ds", type(e).__name__, attempt + 1, max_retries, wait)
  146. await asyncio.sleep(wait)
  147. continue
  148. raise
  149. else:
  150. raise last_exception # type: ignore[misc]
  151. # 解析响应并抽离 Usage
  152. parsed = _parse_anthropic_response(result)
  153. usage = parsed["usage"]
  154. cost = calculate_cost(model, usage)
  155. return {
  156. "content": parsed["content"],
  157. "tool_calls": parsed["tool_calls"],
  158. "prompt_tokens": usage.input_tokens,
  159. "completion_tokens": usage.output_tokens,
  160. "reasoning_tokens": usage.reasoning_tokens,
  161. "cache_creation_tokens": usage.cache_creation_tokens,
  162. "cache_read_tokens": usage.cache_read_tokens,
  163. "finish_reason": parsed["finish_reason"],
  164. "cost": cost,
  165. "usage": usage,
  166. }
  167. def create_claude_llm_call(model: str = "claude-3-5-sonnet-20241022"):
  168. """
  169. 创建 Anthropic 原生 LLM 调用函数
  170. Args:
  171. model: 模型名称如 "claude-3-5-sonnet-20241022"
  172. (可带前缀,将会自动截取)
  173. Returns:
  174. 异步 LLM 调用函数提供给 AgentRunner
  175. """
  176. async def llm_call(messages: List[Dict[str, Any]], model: str = model, tools: Optional[List[Dict]] = None, **kwargs) -> Dict[str, Any]:
  177. return await anthropic_native_llm_call(messages, model, tools, **kwargs)
  178. return llm_call