openrouter.py 27 KB

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