openrouter.py 29 KB

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