openrouter.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. """
  2. OpenRouter Provider
  3. 使用 OpenRouter API 调用各种模型(包括 Claude Sonnet 4.5)
  4. 支持 OpenAI 兼容的 API 格式
  5. OpenRouter 转发多种模型,需要根据实际模型处理不同的 usage 格式:
  6. - OpenAI 模型: prompt_tokens, completion_tokens, completion_tokens_details.reasoning_tokens
  7. - Claude 模型: input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens
  8. - DeepSeek 模型: prompt_tokens, completion_tokens, reasoning_tokens
  9. """
  10. import os
  11. import json
  12. import asyncio
  13. import logging
  14. import httpx
  15. from typing import List, Dict, Any, Optional
  16. from .usage import TokenUsage, create_usage_from_response
  17. from .pricing import calculate_cost
  18. logger = logging.getLogger(__name__)
  19. # 可重试的异常类型
  20. _RETRYABLE_EXCEPTIONS = (
  21. httpx.RemoteProtocolError, # Server disconnected without sending a response
  22. httpx.ConnectError,
  23. httpx.ReadTimeout,
  24. httpx.WriteTimeout,
  25. httpx.ConnectTimeout,
  26. httpx.PoolTimeout,
  27. ConnectionError,
  28. )
  29. def _detect_provider_from_model(model: str) -> str:
  30. """根据模型名称检测提供商"""
  31. model_lower = model.lower()
  32. if model_lower.startswith("anthropic/") or "claude" in model_lower:
  33. return "anthropic"
  34. elif model_lower.startswith("openai/") or model_lower.startswith("gpt") or model_lower.startswith("o1") or model_lower.startswith("o3"):
  35. return "openai"
  36. elif model_lower.startswith("deepseek/") or "deepseek" in model_lower:
  37. return "deepseek"
  38. elif model_lower.startswith("google/") or "gemini" in model_lower:
  39. return "gemini"
  40. else:
  41. return "openai" # 默认使用 OpenAI 格式
  42. def _parse_openrouter_usage(usage: Dict[str, Any], model: str) -> TokenUsage:
  43. """
  44. 解析 OpenRouter 返回的 usage
  45. OpenRouter 会根据底层模型返回不同格式的 usage
  46. """
  47. provider = _detect_provider_from_model(model)
  48. # OpenRouter 通常返回 OpenAI 格式,但可能包含额外字段
  49. if provider == "anthropic":
  50. # Claude 模型可能有缓存字段
  51. # OpenRouter 使用 prompt_tokens_details 嵌套结构
  52. prompt_details = usage.get("prompt_tokens_details", {})
  53. # 调试:打印原始 usage
  54. if logger.isEnabledFor(logging.DEBUG):
  55. logger.debug(f"[OpenRouter] Raw usage: {usage}")
  56. logger.debug(f"[OpenRouter] prompt_tokens_details: {prompt_details}")
  57. return TokenUsage(
  58. input_tokens=usage.get("prompt_tokens") or usage.get("input_tokens", 0),
  59. output_tokens=usage.get("completion_tokens") or usage.get("output_tokens", 0),
  60. # OpenRouter 格式:prompt_tokens_details.cached_tokens / cache_write_tokens
  61. cache_read_tokens=prompt_details.get("cached_tokens", 0),
  62. cache_creation_tokens=prompt_details.get("cache_write_tokens", 0),
  63. )
  64. elif provider == "deepseek":
  65. # DeepSeek 可能有 reasoning_tokens
  66. return TokenUsage(
  67. input_tokens=usage.get("prompt_tokens", 0),
  68. output_tokens=usage.get("completion_tokens", 0),
  69. reasoning_tokens=usage.get("reasoning_tokens", 0),
  70. )
  71. else:
  72. # OpenAI 格式(包括 o1/o3 的 reasoning_tokens)
  73. reasoning = 0
  74. if details := usage.get("completion_tokens_details"):
  75. reasoning = details.get("reasoning_tokens", 0)
  76. return TokenUsage(
  77. input_tokens=usage.get("prompt_tokens", 0),
  78. output_tokens=usage.get("completion_tokens", 0),
  79. reasoning_tokens=reasoning,
  80. )
  81. def _normalize_tool_call_ids(messages: List[Dict[str, Any]], target_prefix: str) -> List[Dict[str, Any]]:
  82. """
  83. 将消息历史中的 tool_call_id 统一重写为目标 Provider 的格式。
  84. 跨 Provider 续跑时,历史中的 tool_call_id 可能不兼容目标 API
  85. (如 Anthropic 的 toolu_xxx 发给 OpenAI,或 OpenAI 的 call_xxx 发给 Anthropic)。
  86. 仅在检测到异格式 ID 时才重写,同格式直接跳过。
  87. """
  88. # 第一遍:收集需要重写的 ID
  89. id_map: Dict[str, str] = {}
  90. counter = 0
  91. for msg in messages:
  92. if msg.get("role") == "assistant" and msg.get("tool_calls"):
  93. for tc in msg["tool_calls"]:
  94. old_id = tc.get("id", "")
  95. if old_id and not old_id.startswith(target_prefix + "_"):
  96. if old_id not in id_map:
  97. id_map[old_id] = f"{target_prefix}_{counter:06x}"
  98. counter += 1
  99. if not id_map:
  100. return messages # 无需重写
  101. logger.info("重写 %d 个 tool_call_id (target_prefix=%s)", len(id_map), target_prefix)
  102. # 第二遍:重写(浅拷贝避免修改原始数据)
  103. result = []
  104. for msg in messages:
  105. if msg.get("role") == "assistant" and msg.get("tool_calls"):
  106. new_tcs = []
  107. for tc in msg["tool_calls"]:
  108. old_id = tc.get("id", "")
  109. if old_id in id_map:
  110. new_tcs.append({**tc, "id": id_map[old_id]})
  111. else:
  112. new_tcs.append(tc)
  113. result.append({**msg, "tool_calls": new_tcs})
  114. elif msg.get("role") == "tool" and msg.get("tool_call_id") in id_map:
  115. result.append({**msg, "tool_call_id": id_map[msg["tool_call_id"]]})
  116. else:
  117. result.append(msg)
  118. return result
  119. async def openrouter_llm_call(
  120. messages: List[Dict[str, Any]],
  121. model: str = "anthropic/claude-sonnet-4.5",
  122. tools: Optional[List[Dict]] = None,
  123. **kwargs
  124. ) -> Dict[str, Any]:
  125. """
  126. OpenRouter LLM 调用函数
  127. Args:
  128. messages: OpenAI 格式消息列表
  129. model: 模型名称(如 "anthropic/claude-sonnet-4.5")
  130. tools: OpenAI 格式工具定义
  131. **kwargs: 其他参数(temperature, max_tokens 等)
  132. Returns:
  133. {
  134. "content": str,
  135. "tool_calls": List[Dict] | None,
  136. "prompt_tokens": int,
  137. "completion_tokens": int,
  138. "finish_reason": str,
  139. "cost": float
  140. }
  141. """
  142. api_key = os.getenv("OPEN_ROUTER_API_KEY")
  143. if not api_key:
  144. raise ValueError("OPEN_ROUTER_API_KEY environment variable not set")
  145. base_url = "https://openrouter.ai/api/v1"
  146. endpoint = f"{base_url}/chat/completions"
  147. # 跨 Provider 续跑时,重写不兼容的 tool_call_id
  148. messages = _normalize_tool_call_ids(messages, "call")
  149. # 构建请求
  150. payload = {
  151. "model": model,
  152. "messages": messages,
  153. }
  154. # 添加可选参数
  155. if tools:
  156. payload["tools"] = tools
  157. if "temperature" in kwargs:
  158. payload["temperature"] = kwargs["temperature"]
  159. if "max_tokens" in kwargs:
  160. payload["max_tokens"] = kwargs["max_tokens"]
  161. # 对于 Anthropic 模型,锁定 provider 以确保缓存生效
  162. if "anthropic" in model.lower() or "claude" in model.lower():
  163. payload["provider"] = {
  164. "only": ["Anthropic"],
  165. "allow_fallbacks": False,
  166. "require_parameters": True
  167. }
  168. logger.debug("[OpenRouter] Locked provider to Anthropic for caching support")
  169. # OpenRouter 特定参数
  170. headers = {
  171. "Authorization": f"Bearer {api_key}",
  172. "HTTP-Referer": "https://github.com/your-repo", # 可选,用于统计
  173. "X-Title": "Agent Framework", # 可选,显示在 OpenRouter dashboard
  174. }
  175. # 调用 API(带重试)
  176. max_retries = 3
  177. last_exception = None
  178. for attempt in range(max_retries):
  179. async with httpx.AsyncClient(timeout=300.0) as client:
  180. try:
  181. response = await client.post(endpoint, json=payload, headers=headers)
  182. response.raise_for_status()
  183. result = response.json()
  184. break # 成功,跳出重试循环
  185. except httpx.HTTPStatusError as e:
  186. error_body = e.response.text
  187. status = e.response.status_code
  188. # 429 (rate limit) 和 5xx 可重试
  189. if status in (429, 500, 502, 503, 504) and attempt < max_retries - 1:
  190. wait = 2 ** attempt * 2 # 2s, 4s, 8s
  191. logger.warning(
  192. "[OpenRouter] HTTP %d (attempt %d/%d), retrying in %ds: %s",
  193. status, attempt + 1, max_retries, wait, error_body[:200],
  194. )
  195. await asyncio.sleep(wait)
  196. last_exception = e
  197. continue
  198. logger.error("[OpenRouter] Error %d: %s", status, error_body)
  199. raise
  200. except _RETRYABLE_EXCEPTIONS as e:
  201. last_exception = e
  202. if attempt < max_retries - 1:
  203. wait = 2 ** attempt * 2
  204. logger.warning(
  205. "[OpenRouter] %s (attempt %d/%d), retrying in %ds",
  206. type(e).__name__, attempt + 1, max_retries, wait,
  207. )
  208. await asyncio.sleep(wait)
  209. continue
  210. logger.error("[OpenRouter] Request failed after %d attempts: %s", max_retries, e)
  211. raise
  212. except Exception as e:
  213. logger.error("[OpenRouter] Request failed: %s", e)
  214. raise
  215. else:
  216. # 所有重试都用完
  217. raise last_exception # type: ignore[misc]
  218. # 解析响应(OpenAI 格式)
  219. choice = result["choices"][0] if result.get("choices") else {}
  220. message = choice.get("message", {})
  221. content = message.get("content", "")
  222. tool_calls = message.get("tool_calls")
  223. finish_reason = choice.get("finish_reason") # stop, length, tool_calls, content_filter 等
  224. # 提取 usage(完整版,根据模型类型解析)
  225. raw_usage = result.get("usage", {})
  226. usage = _parse_openrouter_usage(raw_usage, model)
  227. # 计算费用
  228. cost = calculate_cost(model, usage)
  229. return {
  230. "content": content,
  231. "tool_calls": tool_calls,
  232. "prompt_tokens": usage.input_tokens,
  233. "completion_tokens": usage.output_tokens,
  234. "reasoning_tokens": usage.reasoning_tokens,
  235. "cache_creation_tokens": usage.cache_creation_tokens,
  236. "cache_read_tokens": usage.cache_read_tokens,
  237. "finish_reason": finish_reason,
  238. "cost": cost,
  239. "usage": usage, # 完整的 TokenUsage 对象
  240. }
  241. def create_openrouter_llm_call(
  242. model: str = "anthropic/claude-sonnet-4.5"
  243. ):
  244. """
  245. 创建 OpenRouter LLM 调用函数
  246. Args:
  247. model: 模型名称
  248. - "anthropic/claude-sonnet-4.5"
  249. - "anthropic/claude-opus-4.5"
  250. - "openai/gpt-4o"
  251. 等等
  252. Returns:
  253. 异步 LLM 调用函数
  254. """
  255. async def llm_call(
  256. messages: List[Dict[str, Any]],
  257. model: str = model,
  258. tools: Optional[List[Dict]] = None,
  259. **kwargs
  260. ) -> Dict[str, Any]:
  261. return await openrouter_llm_call(messages, model, tools, **kwargs)
  262. return llm_call