|
|
@@ -15,64 +15,37 @@ OpenRouter 转发多种模型,需要根据实际模型处理不同的 usage
|
|
|
"""
|
|
|
|
|
|
import os
|
|
|
-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
|
|
|
+from .anthropic_protocol import (
|
|
|
+ ANTHROPIC_MODEL_EXACT,
|
|
|
+ ANTHROPIC_MODEL_FUZZY,
|
|
|
+ ANTHROPIC_RETRYABLE_EXCEPTIONS,
|
|
|
+ build_anthropic_result,
|
|
|
+ count_cache_controls,
|
|
|
+ normalize_tool_call_ids as _normalize_tool_call_ids,
|
|
|
+ parse_anthropic_response as _parse_anthropic_response,
|
|
|
+ resolve_anthropic_model,
|
|
|
+ to_anthropic_messages,
|
|
|
+ to_anthropic_tools as _to_anthropic_tools,
|
|
|
+)
|
|
|
+from .usage import TokenUsage
|
|
|
from .pricing import calculate_cost
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# 可重试的异常类型
|
|
|
-_RETRYABLE_EXCEPTIONS = (
|
|
|
- httpx.RemoteProtocolError, # Server disconnected without sending a response
|
|
|
- httpx.ConnectError,
|
|
|
- httpx.ReadTimeout,
|
|
|
- httpx.WriteTimeout,
|
|
|
- httpx.ConnectTimeout,
|
|
|
- httpx.PoolTimeout,
|
|
|
- ConnectionError,
|
|
|
-)
|
|
|
+_RETRYABLE_EXCEPTIONS = ANTHROPIC_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"),
|
|
|
-]
|
|
|
+_OR_MODEL_EXACT = ANTHROPIC_MODEL_EXACT
|
|
|
+_OR_MODEL_FUZZY = ANTHROPIC_MODEL_FUZZY
|
|
|
|
|
|
|
|
|
def _resolve_openrouter_model(model: str) -> str:
|
|
|
@@ -81,51 +54,12 @@ def _resolve_openrouter_model(model: str) -> str:
|
|
|
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 _get_image_dimensions(data: bytes) -> Optional[tuple]:
|
|
|
- """从图片二进制数据的文件头解析宽高,支持 PNG/JPEG。不依赖 PIL。"""
|
|
|
- try:
|
|
|
- # PNG: 前 8 字节签名,IHDR chunk 在 16-24 字节存宽高 (big-endian uint32)
|
|
|
- if data[:8] == b'\x89PNG\r\n\x1a\n' and len(data) >= 24:
|
|
|
- import struct
|
|
|
- w, h = struct.unpack('>II', data[16:24])
|
|
|
- return (w, h)
|
|
|
- # JPEG: 扫描 SOF0/SOF2 marker (0xFFC0/0xFFC2)
|
|
|
- if data[:2] == b'\xff\xd8':
|
|
|
- import struct
|
|
|
- i = 2
|
|
|
- while i < len(data) - 9:
|
|
|
- if data[i] != 0xFF:
|
|
|
- break
|
|
|
- marker = data[i + 1]
|
|
|
- if marker in (0xC0, 0xC2):
|
|
|
- h, w = struct.unpack('>HH', data[i + 5:i + 9])
|
|
|
- return (w, h)
|
|
|
- length = struct.unpack('>H', data[i + 2:i + 4])[0]
|
|
|
- i += 2 + length
|
|
|
- except Exception:
|
|
|
- pass
|
|
|
- return None
|
|
|
+ return resolve_anthropic_model(
|
|
|
+ model,
|
|
|
+ provider_prefix="anthropic/",
|
|
|
+ preserve_unknown_prefix=True,
|
|
|
+ logger=logger,
|
|
|
+ )
|
|
|
|
|
|
|
|
|
def _sanitize_schema_name(title: str) -> str:
|
|
|
@@ -212,237 +146,14 @@ def _ensure_strict_schema(schema: Dict) -> Dict:
|
|
|
result.pop("$schema", None)
|
|
|
|
|
|
return result
|
|
|
- _process(result)
|
|
|
-
|
|
|
- return result
|
|
|
-
|
|
|
-
|
|
|
-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"
|
|
|
- import base64 as b64mod
|
|
|
- raw = b64mod.b64decode(data)
|
|
|
- dims = _get_image_dimensions(raw)
|
|
|
- img_block = {
|
|
|
- "type": "image",
|
|
|
- "source": {
|
|
|
- "type": "base64",
|
|
|
- "media_type": media_type,
|
|
|
- "data": data,
|
|
|
- },
|
|
|
- }
|
|
|
- if dims:
|
|
|
- img_block["_image_meta"] = {"width": dims[0], "height": dims[1]}
|
|
|
- result.append(img_block)
|
|
|
- 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()
|
|
|
- dims = _get_image_dimensions(raw)
|
|
|
- b64_data = b64mod.b64encode(raw).decode("ascii")
|
|
|
- logger.info(f"[OpenRouter] 本地图片自动转 base64: {url} ({len(raw)} bytes)")
|
|
|
- img_block = {
|
|
|
- "type": "image",
|
|
|
- "source": {
|
|
|
- "type": "base64",
|
|
|
- "media_type": mime_type,
|
|
|
- "data": b64_data,
|
|
|
- },
|
|
|
- }
|
|
|
- if dims:
|
|
|
- img_block["_image_meta"] = {"width": dims[0], "height": dims[1]}
|
|
|
- result.append(img_block)
|
|
|
- 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),
|
|
|
- )
|
|
|
+def _to_anthropic_messages(
|
|
|
+ messages: List[Dict[str, Any]],
|
|
|
+) -> tuple[Any, List[Dict[str, Any]]]:
|
|
|
+ """Use the shared protocol with OpenRouter's local-image behavior."""
|
|
|
|
|
|
- return {
|
|
|
- "content": content,
|
|
|
- "tool_calls": tool_calls if tool_calls else None,
|
|
|
- "finish_reason": finish_reason,
|
|
|
- "usage": usage,
|
|
|
- }
|
|
|
+ return to_anthropic_messages(messages, logger=logger)
|
|
|
|
|
|
|
|
|
# ── Provider detection / usage parsing ─────────────────────────────────────
|
|
|
@@ -508,50 +219,6 @@ def _parse_openrouter_usage(usage: Dict[str, Any], model: str) -> TokenUsage:
|
|
|
)
|
|
|
|
|
|
|
|
|
-def _normalize_tool_call_ids(messages: List[Dict[str, Any]], target_prefix: str) -> List[Dict[str, Any]]:
|
|
|
- """
|
|
|
- 将消息历史中的 tool_call_id 统一重写为目标 Provider 的格式。
|
|
|
- 跨 Provider 续跑时,历史中的 tool_call_id 可能不兼容目标 API
|
|
|
- (如 Anthropic 的 toolu_xxx 发给 OpenAI,或 OpenAI 的 call_xxx 发给 Anthropic)。
|
|
|
- 仅在检测到异格式 ID 时才重写,同格式直接跳过。
|
|
|
- """
|
|
|
- # 第一遍:收集需要重写的 ID
|
|
|
- id_map: Dict[str, str] = {}
|
|
|
- counter = 0
|
|
|
- for msg in messages:
|
|
|
- if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
|
|
- for tc in msg["tool_calls"]:
|
|
|
- old_id = tc.get("id", "")
|
|
|
- if old_id and not old_id.startswith(target_prefix + "_"):
|
|
|
- if old_id not in id_map:
|
|
|
- id_map[old_id] = f"{target_prefix}_{counter:06x}"
|
|
|
- counter += 1
|
|
|
-
|
|
|
- if not id_map:
|
|
|
- return messages # 无需重写
|
|
|
-
|
|
|
- logger.info("重写 %d 个 tool_call_id (target_prefix=%s)", len(id_map), target_prefix)
|
|
|
-
|
|
|
- # 第二遍:重写(浅拷贝避免修改原始数据)
|
|
|
- result = []
|
|
|
- for msg in messages:
|
|
|
- if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
|
|
- new_tcs = []
|
|
|
- for tc in msg["tool_calls"]:
|
|
|
- old_id = tc.get("id", "")
|
|
|
- if old_id in id_map:
|
|
|
- new_tcs.append({**tc, "id": id_map[old_id]})
|
|
|
- else:
|
|
|
- new_tcs.append(tc)
|
|
|
- result.append({**msg, "tool_calls": new_tcs})
|
|
|
- elif msg.get("role") == "tool" and msg.get("tool_call_id") in id_map:
|
|
|
- result.append({**msg, "tool_call_id": id_map[msg["tool_call_id"]]})
|
|
|
- else:
|
|
|
- result.append(msg)
|
|
|
-
|
|
|
- return result
|
|
|
-
|
|
|
-
|
|
|
async def _openrouter_anthropic_call(
|
|
|
messages: List[Dict[str, Any]],
|
|
|
model: str,
|
|
|
@@ -586,7 +253,6 @@ async def _openrouter_anthropic_call(
|
|
|
_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,
|
|
|
@@ -616,17 +282,7 @@ async def _openrouter_anthropic_call(
|
|
|
|
|
|
# Debug: 检查 cache_control 是否存在
|
|
|
if logger.isEnabledFor(logging.DEBUG):
|
|
|
- cache_control_count = 0
|
|
|
- if isinstance(system_prompt, list):
|
|
|
- for block in system_prompt:
|
|
|
- if isinstance(block, dict) and "cache_control" in block:
|
|
|
- cache_control_count += 1
|
|
|
- for msg in anthropic_messages:
|
|
|
- content = msg.get("content", "")
|
|
|
- if isinstance(content, list):
|
|
|
- for block in content:
|
|
|
- if isinstance(block, dict) and "cache_control" in block:
|
|
|
- cache_control_count += 1
|
|
|
+ cache_control_count = count_cache_controls(system_prompt, anthropic_messages)
|
|
|
if cache_control_count > 0:
|
|
|
logger.debug(f"[OpenRouter/Anthropic] 发现 {cache_control_count} 个 cache_control 标记")
|
|
|
|
|
|
@@ -660,9 +316,7 @@ async def _openrouter_anthropic_call(
|
|
|
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:
|
|
|
@@ -679,23 +333,7 @@ async def _openrouter_anthropic_call(
|
|
|
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,
|
|
|
- }
|
|
|
+ return build_anthropic_result(_parse_anthropic_response(result), model)
|
|
|
|
|
|
|
|
|
def _resolve_local_images_in_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|