openrouter.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822
  1. """
  2. OpenRouter Provider
  3. 使用 OpenRouter API 调用各种模型(包括 Claude Sonnet 4.5)
  4. 路由策略:
  5. - Claude 模型:走 OpenRouter 的 Anthropic 原生端点(/api/v1/messages),
  6. 使用自包含的格式转换逻辑,确保多模态工具结果(截图等)正确传递。
  7. - 其他模型:走 OpenAI 兼容端点(/api/v1/chat/completions)。
  8. OpenRouter 转发多种模型,需要根据实际模型处理不同的 usage 格式:
  9. - OpenAI 模型: prompt_tokens, completion_tokens, completion_tokens_details.reasoning_tokens
  10. - Claude 模型: input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens
  11. - DeepSeek 模型: prompt_tokens, completion_tokens, reasoning_tokens
  12. """
  13. import os
  14. import json
  15. import asyncio
  16. import logging
  17. import httpx
  18. from pathlib import Path
  19. from typing import List, Dict, Any, Optional
  20. from .usage import TokenUsage, create_usage_from_response
  21. from .pricing import calculate_cost
  22. logger = logging.getLogger(__name__)
  23. # 可重试的异常类型
  24. _RETRYABLE_EXCEPTIONS = (
  25. httpx.RemoteProtocolError, # Server disconnected without sending a response
  26. httpx.ConnectError,
  27. httpx.ReadTimeout,
  28. httpx.WriteTimeout,
  29. httpx.ConnectTimeout,
  30. httpx.PoolTimeout,
  31. ConnectionError,
  32. )
  33. def _select_http_proxy_url() -> Optional[str]:
  34. """
  35. 从环境变量读取代理地址。
  36. - 环境变量存在且非空:使用该代理
  37. - 环境变量不存在:不传 proxy(让 httpx 按直连/系统配置处理)
  38. """
  39. if os.getenv("no_proxy") == "*" or os.getenv("NO_PROXY") == "*":
  40. return None
  41. # 优先级:专用 OPENROUTER_PROXY > 通用 HTTPS/HTTP/ALL 代理变量
  42. for key in (
  43. "OPENROUTER_PROXY",
  44. "HTTPS_PROXY",
  45. "https_proxy",
  46. "HTTP_PROXY",
  47. "http_proxy",
  48. "ALL_PROXY",
  49. "all_proxy",
  50. ):
  51. v = os.getenv(key)
  52. if v:
  53. return v
  54. return None
  55. def _get_openai_compatible_api_key() -> str:
  56. ark_api_key = os.getenv("ARK_API_KEY")
  57. if ark_api_key and (os.getenv("ARK_BASE_URL") or os.getenv("ARK_MODEL")):
  58. return ark_api_key
  59. api_key = (
  60. os.getenv("OPEN_ROUTER_API_KEY")
  61. or os.getenv("OPENROUTER_API_KEY")
  62. or ark_api_key
  63. )
  64. if not api_key:
  65. raise ValueError("OPEN_ROUTER_API_KEY or ARK_API_KEY environment variable not set")
  66. return api_key
  67. def _get_openai_compatible_base_url() -> str:
  68. base_url = (
  69. os.getenv("ARK_BASE_URL")
  70. or os.getenv("OPEN_ROUTER_BASE_URL")
  71. or os.getenv("OPENROUTER_BASE_URL")
  72. or "https://openrouter.ai/api/v1"
  73. )
  74. return base_url.rstrip("/")
  75. def _is_openrouter_base_url(base_url: str) -> bool:
  76. return "openrouter.ai" in base_url.lower()
  77. # ── OpenRouter Anthropic endpoint: model name mapping ──────────────────────
  78. # Local copy of yescode's model tables so this module is self-contained.
  79. _OR_MODEL_EXACT = {
  80. "claude-sonnet-4-6": "claude-sonnet-4-6",
  81. "claude-sonnet-4.6": "claude-sonnet-4-6",
  82. "claude-sonnet-4-5-20250929": "claude-sonnet-4-5-20250929",
  83. "claude-sonnet-4-5": "claude-sonnet-4-5-20250929",
  84. "claude-sonnet-4.5": "claude-sonnet-4-5-20250929",
  85. "claude-opus-4-6": "claude-opus-4-6",
  86. "claude-opus-4-5-20251101": "claude-opus-4-5-20251101",
  87. "claude-opus-4-5": "claude-opus-4-5-20251101",
  88. "claude-opus-4-1-20250805": "claude-opus-4-1-20250805",
  89. "claude-opus-4-1": "claude-opus-4-1-20250805",
  90. "claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
  91. "claude-haiku-4-5": "claude-haiku-4-5-20251001",
  92. }
  93. _OR_MODEL_FUZZY = [
  94. ("sonnet-4-6", "claude-sonnet-4-6"),
  95. ("sonnet-4.6", "claude-sonnet-4-6"),
  96. ("sonnet-4-5", "claude-sonnet-4-5-20250929"),
  97. ("sonnet-4.5", "claude-sonnet-4-5-20250929"),
  98. ("opus-4-6", "claude-opus-4-6"),
  99. ("opus-4.6", "claude-opus-4-6"),
  100. ("opus-4-5", "claude-opus-4-5-20251101"),
  101. ("opus-4.5", "claude-opus-4-5-20251101"),
  102. ("opus-4-1", "claude-opus-4-1-20250805"),
  103. ("opus-4.1", "claude-opus-4-1-20250805"),
  104. ("haiku-4-5", "claude-haiku-4-5-20251001"),
  105. ("haiku-4.5", "claude-haiku-4-5-20251001"),
  106. ("sonnet", "claude-sonnet-4-6"),
  107. ("opus", "claude-opus-4-6"),
  108. ("haiku", "claude-haiku-4-5-20251001"),
  109. ]
  110. def _resolve_openrouter_model(model: str) -> str:
  111. """Normalize a model name for OpenRouter's Anthropic endpoint.
  112. Strips ``anthropic/`` prefix, resolves aliases / dot-notation,
  113. and re-prepends ``anthropic/`` for OpenRouter routing.
  114. """
  115. # 1. Strip provider prefix
  116. bare = model.split("/", 1)[1] if "/" in model else model
  117. # 2. Exact match
  118. if bare in _OR_MODEL_EXACT:
  119. return f"anthropic/{_OR_MODEL_EXACT[bare]}"
  120. # 3. Fuzzy keyword match (case-insensitive)
  121. bare_lower = bare.lower()
  122. for keyword, target in _OR_MODEL_FUZZY:
  123. if keyword in bare_lower:
  124. logger.info("[OpenRouter] Model fuzzy match: %s → anthropic/%s", model, target)
  125. return f"anthropic/{target}"
  126. # 4. Fallback – return as-is (let API report the error)
  127. logger.warning("[OpenRouter] Could not resolve model name: %s, passing as-is", model)
  128. return model
  129. # ── OpenRouter Anthropic endpoint: format conversion helpers ───────────────
  130. def _get_image_dimensions(data: bytes) -> Optional[tuple]:
  131. """从图片二进制数据的文件头解析宽高,支持 PNG/JPEG。不依赖 PIL。"""
  132. try:
  133. # PNG: 前 8 字节签名,IHDR chunk 在 16-24 字节存宽高 (big-endian uint32)
  134. if data[:8] == b'\x89PNG\r\n\x1a\n' and len(data) >= 24:
  135. import struct
  136. w, h = struct.unpack('>II', data[16:24])
  137. return (w, h)
  138. # JPEG: 扫描 SOF0/SOF2 marker (0xFFC0/0xFFC2)
  139. if data[:2] == b'\xff\xd8':
  140. import struct
  141. i = 2
  142. while i < len(data) - 9:
  143. if data[i] != 0xFF:
  144. break
  145. marker = data[i + 1]
  146. if marker in (0xC0, 0xC2):
  147. h, w = struct.unpack('>HH', data[i + 5:i + 9])
  148. return (w, h)
  149. length = struct.unpack('>H', data[i + 2:i + 4])[0]
  150. i += 2 + length
  151. except Exception:
  152. pass
  153. return None
  154. def _to_anthropic_content(content: Any) -> Any:
  155. """Convert OpenAI-style *content* (string or block list) to Anthropic format.
  156. Handles ``image_url`` blocks → Anthropic ``image`` blocks (base64 or url).
  157. Passes through ``text`` blocks and ``cache_control`` unchanged.
  158. """
  159. if not isinstance(content, list):
  160. return content
  161. result = []
  162. for block in content:
  163. if not isinstance(block, dict):
  164. result.append(block)
  165. continue
  166. if block.get("type") == "image_url":
  167. image_url_obj = block.get("image_url", {})
  168. url = image_url_obj.get("url", "") if isinstance(image_url_obj, dict) else str(image_url_obj)
  169. if url.startswith("data:"):
  170. header, _, data = url.partition(",")
  171. media_type = header.split(":")[1].split(";")[0] if ":" in header else "image/png"
  172. import base64 as b64mod
  173. raw = b64mod.b64decode(data)
  174. dims = _get_image_dimensions(raw)
  175. img_block = {
  176. "type": "image",
  177. "source": {
  178. "type": "base64",
  179. "media_type": media_type,
  180. "data": data,
  181. },
  182. }
  183. if dims:
  184. img_block["_image_meta"] = {"width": dims[0], "height": dims[1]}
  185. result.append(img_block)
  186. else:
  187. # 检测本地文件路径,自动转 base64
  188. local_path = Path(url)
  189. if local_path.exists() and local_path.is_file():
  190. import base64 as b64mod
  191. import mimetypes
  192. mime_type, _ = mimetypes.guess_type(str(local_path))
  193. mime_type = mime_type or "image/png"
  194. raw = local_path.read_bytes()
  195. dims = _get_image_dimensions(raw)
  196. b64_data = b64mod.b64encode(raw).decode("ascii")
  197. logger.info(f"[OpenRouter] 本地图片自动转 base64: {url} ({len(raw)} bytes)")
  198. img_block = {
  199. "type": "image",
  200. "source": {
  201. "type": "base64",
  202. "media_type": mime_type,
  203. "data": b64_data,
  204. },
  205. }
  206. if dims:
  207. img_block["_image_meta"] = {"width": dims[0], "height": dims[1]}
  208. result.append(img_block)
  209. else:
  210. result.append({
  211. "type": "image",
  212. "source": {"type": "url", "url": url},
  213. })
  214. else:
  215. result.append(block)
  216. return result
  217. def _to_anthropic_messages(messages: List[Dict[str, Any]]) -> tuple:
  218. """Convert an OpenAI-format message list to Anthropic Messages API format.
  219. Returns ``(system_prompt, anthropic_messages)`` where *system_prompt* is
  220. ``None`` or a string extracted from ``role=system`` messages, and
  221. *anthropic_messages* is the converted list.
  222. """
  223. system_prompt = None
  224. anthropic_messages: List[Dict[str, Any]] = []
  225. for msg in messages:
  226. role = msg.get("role", "")
  227. content = msg.get("content", "")
  228. if role == "system":
  229. system_prompt = content
  230. elif role == "user":
  231. anthropic_messages.append({
  232. "role": "user",
  233. "content": _to_anthropic_content(content),
  234. })
  235. elif role == "assistant":
  236. tool_calls = msg.get("tool_calls")
  237. if tool_calls:
  238. content_blocks: List[Dict[str, Any]] = []
  239. if content:
  240. converted = _to_anthropic_content(content)
  241. if isinstance(converted, list):
  242. content_blocks.extend(converted)
  243. elif isinstance(converted, str) and converted.strip():
  244. content_blocks.append({"type": "text", "text": converted})
  245. for tc in tool_calls:
  246. func = tc.get("function", {})
  247. args_str = func.get("arguments", "{}")
  248. try:
  249. args = json.loads(args_str) if isinstance(args_str, str) else args_str
  250. except json.JSONDecodeError:
  251. args = {}
  252. content_blocks.append({
  253. "type": "tool_use",
  254. "id": tc.get("id", ""),
  255. "name": func.get("name", ""),
  256. "input": args,
  257. })
  258. anthropic_messages.append({"role": "assistant", "content": content_blocks})
  259. else:
  260. anthropic_messages.append({"role": "assistant", "content": content})
  261. elif role == "tool":
  262. # Split tool result into text-only tool_result + sibling image blocks.
  263. # Images nested inside tool_result.content are not reliably passed
  264. # through by all proxies (e.g. OpenRouter). Placing them as sibling
  265. # content blocks in the same user message is more compatible.
  266. converted = _to_anthropic_content(content)
  267. text_parts: List[Dict[str, Any]] = []
  268. image_parts: List[Dict[str, Any]] = []
  269. if isinstance(converted, list):
  270. for block in converted:
  271. if isinstance(block, dict) and block.get("type") == "image":
  272. image_parts.append(block)
  273. else:
  274. text_parts.append(block)
  275. elif isinstance(converted, str):
  276. text_parts = [{"type": "text", "text": converted}] if converted else []
  277. # tool_result keeps only text content
  278. tool_result_block: Dict[str, Any] = {
  279. "type": "tool_result",
  280. "tool_use_id": msg.get("tool_call_id", ""),
  281. }
  282. if len(text_parts) == 1 and text_parts[0].get("type") == "text":
  283. tool_result_block["content"] = text_parts[0]["text"]
  284. elif text_parts:
  285. tool_result_block["content"] = text_parts
  286. # (omit content key entirely when empty – Anthropic accepts this)
  287. # Build the blocks to append: tool_result first, then any images
  288. new_blocks = [tool_result_block] + image_parts
  289. # Merge consecutive tool results into one user message
  290. if (anthropic_messages
  291. and anthropic_messages[-1].get("role") == "user"
  292. and isinstance(anthropic_messages[-1].get("content"), list)
  293. and anthropic_messages[-1]["content"]
  294. and anthropic_messages[-1]["content"][0].get("type") == "tool_result"):
  295. anthropic_messages[-1]["content"].extend(new_blocks)
  296. else:
  297. anthropic_messages.append({
  298. "role": "user",
  299. "content": new_blocks,
  300. })
  301. return system_prompt, anthropic_messages
  302. def _to_anthropic_tools(tools: List[Dict]) -> List[Dict]:
  303. """Convert OpenAI tool definitions to Anthropic format."""
  304. anthropic_tools = []
  305. for tool in tools:
  306. if tool.get("type") == "function":
  307. func = tool["function"]
  308. anthropic_tools.append({
  309. "name": func.get("name", ""),
  310. "description": func.get("description", ""),
  311. "input_schema": func.get("parameters", {"type": "object", "properties": {}}),
  312. })
  313. return anthropic_tools
  314. def _parse_anthropic_response(result: Dict[str, Any]) -> Dict[str, Any]:
  315. """Parse an Anthropic Messages API response into the unified format.
  316. Returns a dict with keys: content, tool_calls, finish_reason, usage.
  317. """
  318. content_blocks = result.get("content", [])
  319. text_parts = []
  320. tool_calls = []
  321. for block in content_blocks:
  322. if block.get("type") == "text":
  323. text_parts.append(block.get("text", ""))
  324. elif block.get("type") == "tool_use":
  325. tool_calls.append({
  326. "id": block.get("id", ""),
  327. "type": "function",
  328. "function": {
  329. "name": block.get("name", ""),
  330. "arguments": json.dumps(block.get("input", {}), ensure_ascii=False),
  331. },
  332. })
  333. content = "\n".join(text_parts)
  334. stop_reason = result.get("stop_reason", "end_turn")
  335. finish_reason_map = {
  336. "end_turn": "stop",
  337. "tool_use": "tool_calls",
  338. "max_tokens": "length",
  339. "stop_sequence": "stop",
  340. }
  341. finish_reason = finish_reason_map.get(stop_reason, stop_reason)
  342. raw_usage = result.get("usage", {})
  343. usage = TokenUsage(
  344. input_tokens=raw_usage.get("input_tokens", 0),
  345. output_tokens=raw_usage.get("output_tokens", 0),
  346. cache_creation_tokens=raw_usage.get("cache_creation_input_tokens", 0),
  347. cache_read_tokens=raw_usage.get("cache_read_input_tokens", 0),
  348. )
  349. return {
  350. "content": content,
  351. "tool_calls": tool_calls if tool_calls else None,
  352. "finish_reason": finish_reason,
  353. "usage": usage,
  354. }
  355. # ── Provider detection / usage parsing ─────────────────────────────────────
  356. def _detect_provider_from_model(model: str) -> str:
  357. """根据模型名称检测提供商"""
  358. model_lower = model.lower()
  359. if model_lower.startswith("anthropic/") or "claude" in model_lower:
  360. return "anthropic"
  361. elif model_lower.startswith("openai/") or model_lower.startswith("gpt") or model_lower.startswith("o1") or model_lower.startswith("o3"):
  362. return "openai"
  363. elif model_lower.startswith("deepseek/") or "deepseek" in model_lower:
  364. return "deepseek"
  365. elif model_lower.startswith("google/") or "gemini" in model_lower:
  366. return "gemini"
  367. else:
  368. return "openai" # 默认使用 OpenAI 格式
  369. def _parse_openrouter_usage(usage: Dict[str, Any], model: str) -> TokenUsage:
  370. """
  371. 解析 OpenRouter 返回的 usage
  372. OpenRouter 会根据底层模型返回不同格式的 usage
  373. """
  374. provider = _detect_provider_from_model(model)
  375. # OpenRouter 通常返回 OpenAI 格式,但可能包含额外字段
  376. if provider == "anthropic":
  377. # Claude 模型可能有缓存字段
  378. # OpenRouter 使用 prompt_tokens_details 嵌套结构
  379. prompt_details = usage.get("prompt_tokens_details", {})
  380. # 调试:打印原始 usage
  381. if logger.isEnabledFor(logging.DEBUG):
  382. logger.debug(f"[OpenRouter] Raw usage: {usage}")
  383. logger.debug(f"[OpenRouter] prompt_tokens_details: {prompt_details}")
  384. return TokenUsage(
  385. input_tokens=usage.get("prompt_tokens") or usage.get("input_tokens", 0),
  386. output_tokens=usage.get("completion_tokens") or usage.get("output_tokens", 0),
  387. # OpenRouter 格式:prompt_tokens_details.cached_tokens / cache_write_tokens
  388. cache_read_tokens=prompt_details.get("cached_tokens", 0),
  389. cache_creation_tokens=prompt_details.get("cache_write_tokens", 0),
  390. )
  391. elif provider == "deepseek":
  392. # DeepSeek 可能有 reasoning_tokens
  393. return TokenUsage(
  394. input_tokens=usage.get("prompt_tokens", 0),
  395. output_tokens=usage.get("completion_tokens", 0),
  396. reasoning_tokens=usage.get("reasoning_tokens", 0),
  397. )
  398. else:
  399. # OpenAI 格式(包括 o1/o3 的 reasoning_tokens)
  400. reasoning = 0
  401. if details := usage.get("completion_tokens_details"):
  402. reasoning = details.get("reasoning_tokens", 0)
  403. return TokenUsage(
  404. input_tokens=usage.get("prompt_tokens", 0),
  405. output_tokens=usage.get("completion_tokens", 0),
  406. reasoning_tokens=reasoning,
  407. )
  408. def _normalize_tool_call_ids(messages: List[Dict[str, Any]], target_prefix: str) -> List[Dict[str, Any]]:
  409. """
  410. 将消息历史中的 tool_call_id 统一重写为目标 Provider 的格式。
  411. 跨 Provider 续跑时,历史中的 tool_call_id 可能不兼容目标 API
  412. (如 Anthropic 的 toolu_xxx 发给 OpenAI,或 OpenAI 的 call_xxx 发给 Anthropic)。
  413. 仅在检测到异格式 ID 时才重写,同格式直接跳过。
  414. """
  415. # 第一遍:收集需要重写的 ID
  416. id_map: Dict[str, str] = {}
  417. counter = 0
  418. for msg in messages:
  419. if msg.get("role") == "assistant" and msg.get("tool_calls"):
  420. for tc in msg["tool_calls"]:
  421. old_id = tc.get("id", "")
  422. if old_id and not old_id.startswith(target_prefix + "_"):
  423. if old_id not in id_map:
  424. id_map[old_id] = f"{target_prefix}_{counter:06x}"
  425. counter += 1
  426. if not id_map:
  427. return messages # 无需重写
  428. logger.info("重写 %d 个 tool_call_id (target_prefix=%s)", len(id_map), target_prefix)
  429. # 第二遍:重写(浅拷贝避免修改原始数据)
  430. result = []
  431. for msg in messages:
  432. if msg.get("role") == "assistant" and msg.get("tool_calls"):
  433. new_tcs = []
  434. for tc in msg["tool_calls"]:
  435. old_id = tc.get("id", "")
  436. if old_id in id_map:
  437. new_tcs.append({**tc, "id": id_map[old_id]})
  438. else:
  439. new_tcs.append(tc)
  440. result.append({**msg, "tool_calls": new_tcs})
  441. elif msg.get("role") == "tool" and msg.get("tool_call_id") in id_map:
  442. result.append({**msg, "tool_call_id": id_map[msg["tool_call_id"]]})
  443. else:
  444. result.append(msg)
  445. return result
  446. async def _openrouter_anthropic_call(
  447. messages: List[Dict[str, Any]],
  448. model: str,
  449. tools: Optional[List[Dict]],
  450. api_key: str,
  451. **kwargs,
  452. ) -> Dict[str, Any]:
  453. """
  454. 通过 OpenRouter 的 Anthropic 原生端点调用 Claude 模型。
  455. 使用 Anthropic Messages API 格式(/api/v1/messages),
  456. 自包含的格式转换逻辑,确保多模态内容(截图等)正确传递。
  457. """
  458. endpoint = "https://openrouter.ai/api/v1/messages"
  459. # Resolve model name for OpenRouter (e.g. "claude-sonnet-4.5" → "anthropic/claude-sonnet-4-5-20250929")
  460. resolved_model = _resolve_openrouter_model(model)
  461. logger.debug("[OpenRouter/Anthropic] model: %s → %s", model, resolved_model)
  462. # 跨 Provider 续跑时,重写不兼容的 tool_call_id 为 toolu_ 前缀
  463. messages = _normalize_tool_call_ids(messages, "toolu")
  464. # OpenAI 格式 → Anthropic 格式
  465. system_prompt, anthropic_messages = _to_anthropic_messages(messages)
  466. # Diagnostic: count image blocks in the payload
  467. _img_count = 0
  468. for _m in anthropic_messages:
  469. if isinstance(_m.get("content"), list):
  470. for _b in _m["content"]:
  471. if isinstance(_b, dict) and _b.get("type") == "image":
  472. _img_count += 1
  473. if _img_count:
  474. logger.info("[OpenRouter/Anthropic] payload contains %d image block(s)", _img_count)
  475. print(f"[OpenRouter/Anthropic] payload contains {_img_count} image block(s)")
  476. payload: Dict[str, Any] = {
  477. "model": resolved_model,
  478. "messages": anthropic_messages,
  479. "max_tokens": kwargs.get("max_tokens", 16384),
  480. }
  481. if system_prompt is not None:
  482. payload["system"] = system_prompt
  483. if tools:
  484. payload["tools"] = _to_anthropic_tools(tools)
  485. if "temperature" in kwargs:
  486. payload["temperature"] = kwargs["temperature"]
  487. # Debug: 检查 cache_control 是否存在
  488. if logger.isEnabledFor(logging.DEBUG):
  489. cache_control_count = 0
  490. if isinstance(system_prompt, list):
  491. for block in system_prompt:
  492. if isinstance(block, dict) and "cache_control" in block:
  493. cache_control_count += 1
  494. for msg in anthropic_messages:
  495. content = msg.get("content", "")
  496. if isinstance(content, list):
  497. for block in content:
  498. if isinstance(block, dict) and "cache_control" in block:
  499. cache_control_count += 1
  500. if cache_control_count > 0:
  501. logger.debug(f"[OpenRouter/Anthropic] 发现 {cache_control_count} 个 cache_control 标记")
  502. headers = {
  503. "Authorization": f"Bearer {api_key}",
  504. "anthropic-version": "2023-06-01",
  505. "content-type": "application/json",
  506. "HTTP-Referer": "https://github.com/your-repo",
  507. "X-Title": "Agent Framework",
  508. }
  509. max_retries = 3
  510. last_exception = None
  511. proxy_url = _select_http_proxy_url()
  512. for attempt in range(max_retries):
  513. client_kwargs = {"timeout": 300.0}
  514. if proxy_url:
  515. client_kwargs["proxy"] = proxy_url
  516. async with httpx.AsyncClient(**client_kwargs) as client:
  517. try:
  518. response = await client.post(endpoint, json=payload, headers=headers)
  519. response.raise_for_status()
  520. result = response.json()
  521. break
  522. except httpx.HTTPStatusError as e:
  523. status = e.response.status_code
  524. error_body = e.response.text
  525. if status in (429, 500, 502, 503, 504) and attempt < max_retries - 1:
  526. wait = 2 ** attempt * 2
  527. logger.warning(
  528. "[OpenRouter/Anthropic] HTTP %d (attempt %d/%d), retrying in %ds: %s",
  529. status, attempt + 1, max_retries, wait, error_body[:200],
  530. )
  531. await asyncio.sleep(wait)
  532. last_exception = e
  533. continue
  534. # Log AND print error body so it is visible in console output
  535. logger.error("[OpenRouter/Anthropic] HTTP %d error body: %s", status, error_body)
  536. print(f"[OpenRouter/Anthropic] API Error {status}: {error_body[:500]}")
  537. raise
  538. except _RETRYABLE_EXCEPTIONS as e:
  539. last_exception = e
  540. if attempt < max_retries - 1:
  541. wait = 2 ** attempt * 2
  542. logger.warning(
  543. "[OpenRouter/Anthropic] %s (attempt %d/%d), retrying in %ds",
  544. type(e).__name__, attempt + 1, max_retries, wait,
  545. )
  546. await asyncio.sleep(wait)
  547. continue
  548. raise
  549. else:
  550. raise last_exception # type: ignore[misc]
  551. # 解析 Anthropic 响应 → 统一格式
  552. parsed = _parse_anthropic_response(result)
  553. usage = parsed["usage"]
  554. cost = calculate_cost(model, usage)
  555. return {
  556. "content": parsed["content"],
  557. "tool_calls": parsed["tool_calls"],
  558. "prompt_tokens": usage.input_tokens,
  559. "completion_tokens": usage.output_tokens,
  560. "reasoning_tokens": usage.reasoning_tokens,
  561. "cache_creation_tokens": usage.cache_creation_tokens,
  562. "cache_read_tokens": usage.cache_read_tokens,
  563. "finish_reason": parsed["finish_reason"],
  564. "cost": cost,
  565. "usage": usage,
  566. }
  567. async def openrouter_llm_call(
  568. messages: List[Dict[str, Any]],
  569. model: str = "anthropic/claude-sonnet-4.5",
  570. tools: Optional[List[Dict]] = None,
  571. **kwargs
  572. ) -> Dict[str, Any]:
  573. """
  574. OpenRouter LLM 调用函数
  575. Args:
  576. messages: OpenAI 格式消息列表
  577. model: 模型名称(如 "anthropic/claude-sonnet-4.5")
  578. tools: OpenAI 格式工具定义
  579. **kwargs: 其他参数(temperature, max_tokens 等)
  580. Returns:
  581. {
  582. "content": str,
  583. "tool_calls": List[Dict] | None,
  584. "prompt_tokens": int,
  585. "completion_tokens": int,
  586. "finish_reason": str,
  587. "cost": float
  588. }
  589. """
  590. api_key = _get_openai_compatible_api_key()
  591. base_url = _get_openai_compatible_base_url()
  592. # Claude 模型在 OpenRouter 域名下走 Anthropic 原生端点;
  593. # Ark 等 OpenAI-compatible endpoint 始终走 /chat/completions。
  594. provider = _detect_provider_from_model(model)
  595. if provider == "anthropic" and _is_openrouter_base_url(base_url):
  596. logger.debug("[OpenRouter] Routing Claude model to Anthropic native endpoint")
  597. return await _openrouter_anthropic_call(messages, model, tools, api_key, **kwargs)
  598. endpoint = f"{base_url}/chat/completions"
  599. # 跨 Provider 续跑时,重写不兼容的 tool_call_id
  600. messages = _normalize_tool_call_ids(messages, "call")
  601. # 构建请求
  602. payload = {
  603. "model": model,
  604. "messages": messages,
  605. }
  606. # 添加可选参数
  607. if tools:
  608. payload["tools"] = tools
  609. if "temperature" in kwargs:
  610. payload["temperature"] = kwargs["temperature"]
  611. if "max_tokens" in kwargs:
  612. payload["max_tokens"] = kwargs["max_tokens"]
  613. # OpenRouter 特定参数
  614. headers = {
  615. "Authorization": f"Bearer {api_key}",
  616. "HTTP-Referer": "https://github.com/your-repo", # 可选,用于统计
  617. "X-Title": "Agent Framework", # 可选,显示在 OpenRouter dashboard
  618. }
  619. # 调用 API(带重试)
  620. max_retries = 3
  621. last_exception = None
  622. proxy_url = _select_http_proxy_url()
  623. for attempt in range(max_retries):
  624. client_kwargs = {"timeout": 300.0}
  625. if proxy_url:
  626. client_kwargs["proxy"] = proxy_url
  627. async with httpx.AsyncClient(**client_kwargs) as client:
  628. try:
  629. response = await client.post(endpoint, json=payload, headers=headers)
  630. response.raise_for_status()
  631. result = response.json()
  632. break # 成功,跳出重试循环
  633. except httpx.HTTPStatusError as e:
  634. error_body = e.response.text
  635. status = e.response.status_code
  636. # 429 (rate limit) 和 5xx 可重试
  637. if status in (429, 500, 502, 503, 504) and attempt < max_retries - 1:
  638. wait = 2 ** attempt * 2 # 2s, 4s, 8s
  639. logger.warning(
  640. "[OpenRouter] HTTP %d (attempt %d/%d), retrying in %ds: %s",
  641. status, attempt + 1, max_retries, wait, error_body[:200],
  642. )
  643. await asyncio.sleep(wait)
  644. last_exception = e
  645. continue
  646. logger.error("[OpenRouter] Error %d: %s", status, error_body)
  647. raise
  648. except _RETRYABLE_EXCEPTIONS as e:
  649. last_exception = e
  650. if attempt < max_retries - 1:
  651. wait = 2 ** attempt * 2
  652. logger.warning(
  653. "[OpenRouter] %s (attempt %d/%d), retrying in %ds",
  654. type(e).__name__, attempt + 1, max_retries, wait,
  655. )
  656. await asyncio.sleep(wait)
  657. continue
  658. logger.error("[OpenRouter] Request failed after %d attempts: %s", max_retries, e)
  659. raise
  660. except Exception as e:
  661. logger.error("[OpenRouter] Request failed: %s", e)
  662. raise
  663. else:
  664. # 所有重试都用完
  665. raise last_exception # type: ignore[misc]
  666. # 解析响应(OpenAI 格式)
  667. choice = result["choices"][0] if result.get("choices") else {}
  668. message = choice.get("message", {})
  669. content = message.get("content", "")
  670. tool_calls = message.get("tool_calls")
  671. finish_reason = choice.get("finish_reason") # stop, length, tool_calls, content_filter 等
  672. # 提取 usage(完整版,根据模型类型解析)
  673. raw_usage = result.get("usage", {})
  674. usage = _parse_openrouter_usage(raw_usage, model)
  675. # 计算费用
  676. cost = calculate_cost(model, usage)
  677. return {
  678. "content": content,
  679. "tool_calls": tool_calls,
  680. "prompt_tokens": usage.input_tokens,
  681. "completion_tokens": usage.output_tokens,
  682. "reasoning_tokens": usage.reasoning_tokens,
  683. "cache_creation_tokens": usage.cache_creation_tokens,
  684. "cache_read_tokens": usage.cache_read_tokens,
  685. "finish_reason": finish_reason,
  686. "cost": cost,
  687. "usage": usage, # 完整的 TokenUsage 对象
  688. }
  689. def create_openrouter_llm_call(
  690. model: str = "anthropic/claude-sonnet-4.5"
  691. ):
  692. """
  693. 创建 OpenRouter LLM 调用函数
  694. Args:
  695. model: 模型名称
  696. - "anthropic/claude-sonnet-4.5"
  697. - "anthropic/claude-opus-4.5"
  698. - "openai/gpt-4o"
  699. 等等
  700. Returns:
  701. 异步 LLM 调用函数
  702. """
  703. async def llm_call(
  704. messages: List[Dict[str, Any]],
  705. model: str = model,
  706. tools: Optional[List[Dict]] = None,
  707. **kwargs
  708. ) -> Dict[str, Any]:
  709. return await openrouter_llm_call(messages, model, tools, **kwargs)
  710. return llm_call