|
|
@@ -2,7 +2,11 @@
|
|
|
OpenRouter Provider
|
|
|
|
|
|
使用 OpenRouter API 调用各种模型(包括 Claude Sonnet 4.5)
|
|
|
-支持 OpenAI 兼容的 API 格式
|
|
|
+
|
|
|
+路由策略:
|
|
|
+- Claude 模型:走 OpenRouter 的 Anthropic 原生端点(/api/v1/messages),
|
|
|
+ 使用自包含的格式转换逻辑,确保多模态工具结果(截图等)正确传递。
|
|
|
+- 其他模型:走 OpenAI 兼容端点(/api/v1/chat/completions)。
|
|
|
|
|
|
OpenRouter 转发多种模型,需要根据实际模型处理不同的 usage 格式:
|
|
|
- OpenAI 模型: prompt_tokens, completion_tokens, completion_tokens_details.reasoning_tokens
|
|
|
@@ -15,6 +19,7 @@ import json
|
|
|
import asyncio
|
|
|
import logging
|
|
|
import httpx
|
|
|
+from pathlib import Path
|
|
|
from typing import List, Dict, Any, Optional
|
|
|
|
|
|
from .usage import TokenUsage, create_usage_from_response
|
|
|
@@ -34,6 +39,289 @@ _RETRYABLE_EXCEPTIONS = (
|
|
|
)
|
|
|
|
|
|
|
|
|
+# ── OpenRouter Anthropic endpoint: model name mapping ──────────────────────
|
|
|
+# Local copy of yescode's model tables so this module is self-contained.
|
|
|
+_OR_MODEL_EXACT = {
|
|
|
+ "claude-sonnet-4-6": "claude-sonnet-4-6",
|
|
|
+ "claude-sonnet-4.6": "claude-sonnet-4-6",
|
|
|
+ "claude-sonnet-4-5-20250929": "claude-sonnet-4-5-20250929",
|
|
|
+ "claude-sonnet-4-5": "claude-sonnet-4-5-20250929",
|
|
|
+ "claude-sonnet-4.5": "claude-sonnet-4-5-20250929",
|
|
|
+ "claude-opus-4-6": "claude-opus-4-6",
|
|
|
+ "claude-opus-4-5-20251101": "claude-opus-4-5-20251101",
|
|
|
+ "claude-opus-4-5": "claude-opus-4-5-20251101",
|
|
|
+ "claude-opus-4-1-20250805": "claude-opus-4-1-20250805",
|
|
|
+ "claude-opus-4-1": "claude-opus-4-1-20250805",
|
|
|
+ "claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
|
|
|
+ "claude-haiku-4-5": "claude-haiku-4-5-20251001",
|
|
|
+}
|
|
|
+
|
|
|
+_OR_MODEL_FUZZY = [
|
|
|
+ ("sonnet-4-6", "claude-sonnet-4-6"),
|
|
|
+ ("sonnet-4.6", "claude-sonnet-4-6"),
|
|
|
+ ("sonnet-4-5", "claude-sonnet-4-5-20250929"),
|
|
|
+ ("sonnet-4.5", "claude-sonnet-4-5-20250929"),
|
|
|
+ ("opus-4-6", "claude-opus-4-6"),
|
|
|
+ ("opus-4.6", "claude-opus-4-6"),
|
|
|
+ ("opus-4-5", "claude-opus-4-5-20251101"),
|
|
|
+ ("opus-4.5", "claude-opus-4-5-20251101"),
|
|
|
+ ("opus-4-1", "claude-opus-4-1-20250805"),
|
|
|
+ ("opus-4.1", "claude-opus-4-1-20250805"),
|
|
|
+ ("haiku-4-5", "claude-haiku-4-5-20251001"),
|
|
|
+ ("haiku-4.5", "claude-haiku-4-5-20251001"),
|
|
|
+ ("sonnet", "claude-sonnet-4-6"),
|
|
|
+ ("opus", "claude-opus-4-6"),
|
|
|
+ ("haiku", "claude-haiku-4-5-20251001"),
|
|
|
+]
|
|
|
+
|
|
|
+
|
|
|
+def _resolve_openrouter_model(model: str) -> str:
|
|
|
+ """Normalize a model name for OpenRouter's Anthropic endpoint.
|
|
|
+
|
|
|
+ Strips ``anthropic/`` prefix, resolves aliases / dot-notation,
|
|
|
+ and re-prepends ``anthropic/`` for OpenRouter routing.
|
|
|
+ """
|
|
|
+ # 1. Strip provider prefix
|
|
|
+ bare = model.split("/", 1)[1] if "/" in model else model
|
|
|
+
|
|
|
+ # 2. Exact match
|
|
|
+ if bare in _OR_MODEL_EXACT:
|
|
|
+ return f"anthropic/{_OR_MODEL_EXACT[bare]}"
|
|
|
+
|
|
|
+ # 3. Fuzzy keyword match (case-insensitive)
|
|
|
+ bare_lower = bare.lower()
|
|
|
+ for keyword, target in _OR_MODEL_FUZZY:
|
|
|
+ if keyword in bare_lower:
|
|
|
+ logger.info("[OpenRouter] Model fuzzy match: %s → anthropic/%s", model, target)
|
|
|
+ return f"anthropic/{target}"
|
|
|
+
|
|
|
+ # 4. Fallback – return as-is (let API report the error)
|
|
|
+ logger.warning("[OpenRouter] Could not resolve model name: %s, passing as-is", model)
|
|
|
+ return model
|
|
|
+
|
|
|
+
|
|
|
+# ── OpenRouter Anthropic endpoint: format conversion helpers ───────────────
|
|
|
+
|
|
|
+def _to_anthropic_content(content: Any) -> Any:
|
|
|
+ """Convert OpenAI-style *content* (string or block list) to Anthropic format.
|
|
|
+
|
|
|
+ Handles ``image_url`` blocks → Anthropic ``image`` blocks (base64 or url).
|
|
|
+ Passes through ``text`` blocks and ``cache_control`` unchanged.
|
|
|
+ """
|
|
|
+ if not isinstance(content, list):
|
|
|
+ return content
|
|
|
+
|
|
|
+ result = []
|
|
|
+ for block in content:
|
|
|
+ if not isinstance(block, dict):
|
|
|
+ result.append(block)
|
|
|
+ continue
|
|
|
+
|
|
|
+ if block.get("type") == "image_url":
|
|
|
+ image_url_obj = block.get("image_url", {})
|
|
|
+ url = image_url_obj.get("url", "") if isinstance(image_url_obj, dict) else str(image_url_obj)
|
|
|
+ if url.startswith("data:"):
|
|
|
+ header, _, data = url.partition(",")
|
|
|
+ media_type = header.split(":")[1].split(";")[0] if ":" in header else "image/png"
|
|
|
+ result.append({
|
|
|
+ "type": "image",
|
|
|
+ "source": {
|
|
|
+ "type": "base64",
|
|
|
+ "media_type": media_type,
|
|
|
+ "data": data,
|
|
|
+ },
|
|
|
+ })
|
|
|
+ else:
|
|
|
+ # 检测本地文件路径,自动转 base64
|
|
|
+ local_path = Path(url)
|
|
|
+ if local_path.exists() and local_path.is_file():
|
|
|
+ import base64 as b64mod
|
|
|
+ import mimetypes
|
|
|
+ mime_type, _ = mimetypes.guess_type(str(local_path))
|
|
|
+ mime_type = mime_type or "image/png"
|
|
|
+ raw = local_path.read_bytes()
|
|
|
+ b64_data = b64mod.b64encode(raw).decode("ascii")
|
|
|
+ logger.info(f"[OpenRouter] 本地图片自动转 base64: {url} ({len(raw)} bytes)")
|
|
|
+ result.append({
|
|
|
+ "type": "image",
|
|
|
+ "source": {
|
|
|
+ "type": "base64",
|
|
|
+ "media_type": mime_type,
|
|
|
+ "data": b64_data,
|
|
|
+ },
|
|
|
+ })
|
|
|
+ else:
|
|
|
+ result.append({
|
|
|
+ "type": "image",
|
|
|
+ "source": {"type": "url", "url": url},
|
|
|
+ })
|
|
|
+ else:
|
|
|
+ result.append(block)
|
|
|
+ return result
|
|
|
+
|
|
|
+
|
|
|
+def _to_anthropic_messages(messages: List[Dict[str, Any]]) -> tuple:
|
|
|
+ """Convert an OpenAI-format message list to Anthropic Messages API format.
|
|
|
+
|
|
|
+ Returns ``(system_prompt, anthropic_messages)`` where *system_prompt* is
|
|
|
+ ``None`` or a string extracted from ``role=system`` messages, and
|
|
|
+ *anthropic_messages* is the converted list.
|
|
|
+ """
|
|
|
+ system_prompt = None
|
|
|
+ anthropic_messages: List[Dict[str, Any]] = []
|
|
|
+
|
|
|
+ for msg in messages:
|
|
|
+ role = msg.get("role", "")
|
|
|
+ content = msg.get("content", "")
|
|
|
+
|
|
|
+ if role == "system":
|
|
|
+ system_prompt = content
|
|
|
+
|
|
|
+ elif role == "user":
|
|
|
+ anthropic_messages.append({
|
|
|
+ "role": "user",
|
|
|
+ "content": _to_anthropic_content(content),
|
|
|
+ })
|
|
|
+
|
|
|
+ elif role == "assistant":
|
|
|
+ tool_calls = msg.get("tool_calls")
|
|
|
+ if tool_calls:
|
|
|
+ content_blocks: List[Dict[str, Any]] = []
|
|
|
+ if content:
|
|
|
+ converted = _to_anthropic_content(content)
|
|
|
+ if isinstance(converted, list):
|
|
|
+ content_blocks.extend(converted)
|
|
|
+ elif isinstance(converted, str) and converted.strip():
|
|
|
+ content_blocks.append({"type": "text", "text": converted})
|
|
|
+ for tc in tool_calls:
|
|
|
+ func = tc.get("function", {})
|
|
|
+ args_str = func.get("arguments", "{}")
|
|
|
+ try:
|
|
|
+ args = json.loads(args_str) if isinstance(args_str, str) else args_str
|
|
|
+ except json.JSONDecodeError:
|
|
|
+ args = {}
|
|
|
+ content_blocks.append({
|
|
|
+ "type": "tool_use",
|
|
|
+ "id": tc.get("id", ""),
|
|
|
+ "name": func.get("name", ""),
|
|
|
+ "input": args,
|
|
|
+ })
|
|
|
+ anthropic_messages.append({"role": "assistant", "content": content_blocks})
|
|
|
+ else:
|
|
|
+ anthropic_messages.append({"role": "assistant", "content": content})
|
|
|
+
|
|
|
+ elif role == "tool":
|
|
|
+ # Split tool result into text-only tool_result + sibling image blocks.
|
|
|
+ # Images nested inside tool_result.content are not reliably passed
|
|
|
+ # through by all proxies (e.g. OpenRouter). Placing them as sibling
|
|
|
+ # content blocks in the same user message is more compatible.
|
|
|
+ converted = _to_anthropic_content(content)
|
|
|
+ text_parts: List[Dict[str, Any]] = []
|
|
|
+ image_parts: List[Dict[str, Any]] = []
|
|
|
+ if isinstance(converted, list):
|
|
|
+ for block in converted:
|
|
|
+ if isinstance(block, dict) and block.get("type") == "image":
|
|
|
+ image_parts.append(block)
|
|
|
+ else:
|
|
|
+ text_parts.append(block)
|
|
|
+ elif isinstance(converted, str):
|
|
|
+ text_parts = [{"type": "text", "text": converted}] if converted else []
|
|
|
+
|
|
|
+ # tool_result keeps only text content
|
|
|
+ tool_result_block: Dict[str, Any] = {
|
|
|
+ "type": "tool_result",
|
|
|
+ "tool_use_id": msg.get("tool_call_id", ""),
|
|
|
+ }
|
|
|
+ if len(text_parts) == 1 and text_parts[0].get("type") == "text":
|
|
|
+ tool_result_block["content"] = text_parts[0]["text"]
|
|
|
+ elif text_parts:
|
|
|
+ tool_result_block["content"] = text_parts
|
|
|
+ # (omit content key entirely when empty – Anthropic accepts this)
|
|
|
+
|
|
|
+ # Build the blocks to append: tool_result first, then any images
|
|
|
+ new_blocks = [tool_result_block] + image_parts
|
|
|
+
|
|
|
+ # Merge consecutive tool results into one user message
|
|
|
+ if (anthropic_messages
|
|
|
+ and anthropic_messages[-1].get("role") == "user"
|
|
|
+ and isinstance(anthropic_messages[-1].get("content"), list)
|
|
|
+ and anthropic_messages[-1]["content"]
|
|
|
+ and anthropic_messages[-1]["content"][0].get("type") == "tool_result"):
|
|
|
+ anthropic_messages[-1]["content"].extend(new_blocks)
|
|
|
+ else:
|
|
|
+ anthropic_messages.append({
|
|
|
+ "role": "user",
|
|
|
+ "content": new_blocks,
|
|
|
+ })
|
|
|
+
|
|
|
+ return system_prompt, anthropic_messages
|
|
|
+
|
|
|
+
|
|
|
+def _to_anthropic_tools(tools: List[Dict]) -> List[Dict]:
|
|
|
+ """Convert OpenAI tool definitions to Anthropic format."""
|
|
|
+ anthropic_tools = []
|
|
|
+ for tool in tools:
|
|
|
+ if tool.get("type") == "function":
|
|
|
+ func = tool["function"]
|
|
|
+ anthropic_tools.append({
|
|
|
+ "name": func.get("name", ""),
|
|
|
+ "description": func.get("description", ""),
|
|
|
+ "input_schema": func.get("parameters", {"type": "object", "properties": {}}),
|
|
|
+ })
|
|
|
+ return anthropic_tools
|
|
|
+
|
|
|
+
|
|
|
+def _parse_anthropic_response(result: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
+ """Parse an Anthropic Messages API response into the unified format.
|
|
|
+
|
|
|
+ Returns a dict with keys: content, tool_calls, finish_reason, usage.
|
|
|
+ """
|
|
|
+ content_blocks = result.get("content", [])
|
|
|
+
|
|
|
+ text_parts = []
|
|
|
+ tool_calls = []
|
|
|
+ for block in content_blocks:
|
|
|
+ if block.get("type") == "text":
|
|
|
+ text_parts.append(block.get("text", ""))
|
|
|
+ elif block.get("type") == "tool_use":
|
|
|
+ tool_calls.append({
|
|
|
+ "id": block.get("id", ""),
|
|
|
+ "type": "function",
|
|
|
+ "function": {
|
|
|
+ "name": block.get("name", ""),
|
|
|
+ "arguments": json.dumps(block.get("input", {}), ensure_ascii=False),
|
|
|
+ },
|
|
|
+ })
|
|
|
+
|
|
|
+ content = "\n".join(text_parts)
|
|
|
+
|
|
|
+ stop_reason = result.get("stop_reason", "end_turn")
|
|
|
+ finish_reason_map = {
|
|
|
+ "end_turn": "stop",
|
|
|
+ "tool_use": "tool_calls",
|
|
|
+ "max_tokens": "length",
|
|
|
+ "stop_sequence": "stop",
|
|
|
+ }
|
|
|
+ finish_reason = finish_reason_map.get(stop_reason, stop_reason)
|
|
|
+
|
|
|
+ raw_usage = result.get("usage", {})
|
|
|
+ usage = TokenUsage(
|
|
|
+ input_tokens=raw_usage.get("input_tokens", 0),
|
|
|
+ output_tokens=raw_usage.get("output_tokens", 0),
|
|
|
+ cache_creation_tokens=raw_usage.get("cache_creation_input_tokens", 0),
|
|
|
+ cache_read_tokens=raw_usage.get("cache_read_input_tokens", 0),
|
|
|
+ )
|
|
|
+
|
|
|
+ return {
|
|
|
+ "content": content,
|
|
|
+ "tool_calls": tool_calls if tool_calls else None,
|
|
|
+ "finish_reason": finish_reason,
|
|
|
+ "usage": usage,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+# ── Provider detection / usage parsing ─────────────────────────────────────
|
|
|
+
|
|
|
def _detect_provider_from_model(model: str) -> str:
|
|
|
"""根据模型名称检测提供商"""
|
|
|
model_lower = model.lower()
|
|
|
@@ -139,6 +427,122 @@ def _normalize_tool_call_ids(messages: List[Dict[str, Any]], target_prefix: str)
|
|
|
return result
|
|
|
|
|
|
|
|
|
+async def _openrouter_anthropic_call(
|
|
|
+ messages: List[Dict[str, Any]],
|
|
|
+ model: str,
|
|
|
+ tools: Optional[List[Dict]],
|
|
|
+ api_key: str,
|
|
|
+ **kwargs,
|
|
|
+) -> Dict[str, Any]:
|
|
|
+ """
|
|
|
+ 通过 OpenRouter 的 Anthropic 原生端点调用 Claude 模型。
|
|
|
+
|
|
|
+ 使用 Anthropic Messages API 格式(/api/v1/messages),
|
|
|
+ 自包含的格式转换逻辑,确保多模态内容(截图等)正确传递。
|
|
|
+ """
|
|
|
+ endpoint = "https://openrouter.ai/api/v1/messages"
|
|
|
+
|
|
|
+ # Resolve model name for OpenRouter (e.g. "claude-sonnet-4.5" → "anthropic/claude-sonnet-4-5-20250929")
|
|
|
+ resolved_model = _resolve_openrouter_model(model)
|
|
|
+ logger.info("[OpenRouter/Anthropic] model: %s → %s", model, resolved_model)
|
|
|
+
|
|
|
+ # 跨 Provider 续跑时,重写不兼容的 tool_call_id 为 toolu_ 前缀
|
|
|
+ messages = _normalize_tool_call_ids(messages, "toolu")
|
|
|
+
|
|
|
+ # OpenAI 格式 → Anthropic 格式
|
|
|
+ system_prompt, anthropic_messages = _to_anthropic_messages(messages)
|
|
|
+
|
|
|
+ # Diagnostic: count image blocks in the payload
|
|
|
+ _img_count = 0
|
|
|
+ for _m in anthropic_messages:
|
|
|
+ if isinstance(_m.get("content"), list):
|
|
|
+ for _b in _m["content"]:
|
|
|
+ if isinstance(_b, dict) and _b.get("type") == "image":
|
|
|
+ _img_count += 1
|
|
|
+ if _img_count:
|
|
|
+ logger.info("[OpenRouter/Anthropic] payload contains %d image block(s)", _img_count)
|
|
|
+ print(f"[OpenRouter/Anthropic] payload contains {_img_count} image block(s)")
|
|
|
+
|
|
|
+ payload: Dict[str, Any] = {
|
|
|
+ "model": resolved_model,
|
|
|
+ "messages": anthropic_messages,
|
|
|
+ "max_tokens": kwargs.get("max_tokens", 16384),
|
|
|
+ }
|
|
|
+ if system_prompt is not None:
|
|
|
+ payload["system"] = system_prompt
|
|
|
+ if tools:
|
|
|
+ payload["tools"] = _to_anthropic_tools(tools)
|
|
|
+ if "temperature" in kwargs:
|
|
|
+ payload["temperature"] = kwargs["temperature"]
|
|
|
+
|
|
|
+ headers = {
|
|
|
+ "Authorization": f"Bearer {api_key}",
|
|
|
+ "anthropic-version": "2023-06-01",
|
|
|
+ "content-type": "application/json",
|
|
|
+ "HTTP-Referer": "https://github.com/your-repo",
|
|
|
+ "X-Title": "Agent Framework",
|
|
|
+ }
|
|
|
+
|
|
|
+ max_retries = 3
|
|
|
+ last_exception = None
|
|
|
+ for attempt in range(max_retries):
|
|
|
+ async with httpx.AsyncClient(timeout=300.0) as client:
|
|
|
+ try:
|
|
|
+ response = await client.post(endpoint, json=payload, headers=headers)
|
|
|
+ response.raise_for_status()
|
|
|
+ result = response.json()
|
|
|
+ break
|
|
|
+
|
|
|
+ except httpx.HTTPStatusError as e:
|
|
|
+ status = e.response.status_code
|
|
|
+ error_body = e.response.text
|
|
|
+ if status in (429, 500, 502, 503, 504) and attempt < max_retries - 1:
|
|
|
+ wait = 2 ** attempt * 2
|
|
|
+ logger.warning(
|
|
|
+ "[OpenRouter/Anthropic] HTTP %d (attempt %d/%d), retrying in %ds: %s",
|
|
|
+ status, attempt + 1, max_retries, wait, error_body[:200],
|
|
|
+ )
|
|
|
+ await asyncio.sleep(wait)
|
|
|
+ last_exception = e
|
|
|
+ continue
|
|
|
+ # Log AND print error body so it is visible in console output
|
|
|
+ logger.error("[OpenRouter/Anthropic] HTTP %d error body: %s", status, error_body)
|
|
|
+ print(f"[OpenRouter/Anthropic] API Error {status}: {error_body[:500]}")
|
|
|
+ raise
|
|
|
+
|
|
|
+ except _RETRYABLE_EXCEPTIONS as e:
|
|
|
+ last_exception = e
|
|
|
+ if attempt < max_retries - 1:
|
|
|
+ wait = 2 ** attempt * 2
|
|
|
+ logger.warning(
|
|
|
+ "[OpenRouter/Anthropic] %s (attempt %d/%d), retrying in %ds",
|
|
|
+ type(e).__name__, attempt + 1, max_retries, wait,
|
|
|
+ )
|
|
|
+ await asyncio.sleep(wait)
|
|
|
+ continue
|
|
|
+ raise
|
|
|
+ else:
|
|
|
+ raise last_exception # type: ignore[misc]
|
|
|
+
|
|
|
+ # 解析 Anthropic 响应 → 统一格式
|
|
|
+ parsed = _parse_anthropic_response(result)
|
|
|
+ usage = parsed["usage"]
|
|
|
+ cost = calculate_cost(model, usage)
|
|
|
+
|
|
|
+ return {
|
|
|
+ "content": parsed["content"],
|
|
|
+ "tool_calls": parsed["tool_calls"],
|
|
|
+ "prompt_tokens": usage.input_tokens,
|
|
|
+ "completion_tokens": usage.output_tokens,
|
|
|
+ "reasoning_tokens": usage.reasoning_tokens,
|
|
|
+ "cache_creation_tokens": usage.cache_creation_tokens,
|
|
|
+ "cache_read_tokens": usage.cache_read_tokens,
|
|
|
+ "finish_reason": parsed["finish_reason"],
|
|
|
+ "cost": cost,
|
|
|
+ "usage": usage,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
async def openrouter_llm_call(
|
|
|
messages: List[Dict[str, Any]],
|
|
|
model: str = "anthropic/claude-sonnet-4.5",
|
|
|
@@ -168,6 +572,12 @@ async def openrouter_llm_call(
|
|
|
if not api_key:
|
|
|
raise ValueError("OPEN_ROUTER_API_KEY environment variable not set")
|
|
|
|
|
|
+ # Claude 模型走 Anthropic 原生端点,其余走 OpenAI 兼容端点
|
|
|
+ provider = _detect_provider_from_model(model)
|
|
|
+ if provider == "anthropic":
|
|
|
+ logger.debug("[OpenRouter] Routing Claude model to Anthropic native endpoint")
|
|
|
+ return await _openrouter_anthropic_call(messages, model, tools, api_key, **kwargs)
|
|
|
+
|
|
|
base_url = "https://openrouter.ai/api/v1"
|
|
|
endpoint = f"{base_url}/chat/completions"
|
|
|
|
|
|
@@ -189,15 +599,6 @@ async def openrouter_llm_call(
|
|
|
if "max_tokens" in kwargs:
|
|
|
payload["max_tokens"] = kwargs["max_tokens"]
|
|
|
|
|
|
- # 对于 Anthropic 模型,锁定 provider 以确保缓存生效
|
|
|
- if "anthropic" in model.lower() or "claude" in model.lower():
|
|
|
- payload["provider"] = {
|
|
|
- "only": ["Anthropic"],
|
|
|
- "allow_fallbacks": False,
|
|
|
- "require_parameters": True
|
|
|
- }
|
|
|
- logger.debug("[OpenRouter] Locked provider to Anthropic for caching support")
|
|
|
-
|
|
|
# OpenRouter 特定参数
|
|
|
headers = {
|
|
|
"Authorization": f"Bearer {api_key}",
|