Bläddra i källkod

重构(模型): 统一 Anthropic 协议与模型映射

抽出 Anthropic Messages 转换、Tool Call ID 归一化、模型别名、Usage 和响应封装;Yescode、OpenRouter、Claude 保留各自鉴权、端点、重试和特有校验,并补充跨 Provider 兼容测试。
SamLee 1 dag sedan
förälder
incheckning
c048d72c3b

+ 416 - 0
agent/agent/llm/anthropic_protocol.py

@@ -0,0 +1,416 @@
+"""Provider-neutral helpers for Anthropic Messages API adapters."""
+
+from __future__ import annotations
+
+import base64
+import json
+import logging
+import mimetypes
+import struct
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+
+import httpx
+
+from .pricing import calculate_cost
+from .usage import TokenUsage
+
+
+ANTHROPIC_RETRYABLE_EXCEPTIONS = (
+    httpx.RemoteProtocolError,
+    httpx.ConnectError,
+    httpx.ReadTimeout,
+    httpx.WriteTimeout,
+    httpx.ConnectTimeout,
+    httpx.PoolTimeout,
+    ConnectionError,
+)
+
+ANTHROPIC_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",
+}
+
+ANTHROPIC_MODEL_FUZZY: List[Tuple[str, str]] = [
+    ("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_anthropic_model(
+    model: str,
+    *,
+    provider_prefix: str = "",
+    preserve_unknown_prefix: bool = False,
+    logger: Optional[logging.Logger] = None,
+) -> str:
+    """Resolve framework aliases while preserving each Provider's fallback."""
+
+    original = model
+    bare = model.split("/", 1)[1] if "/" in model else model
+    target = ANTHROPIC_MODEL_EXACT.get(bare)
+    if target is None:
+        bare_lower = bare.lower()
+        target = next(
+            (
+                candidate
+                for keyword, candidate in ANTHROPIC_MODEL_FUZZY
+                if keyword in bare_lower
+            ),
+            None,
+        )
+    if target is not None:
+        resolved = f"{provider_prefix}{target}"
+        if logger and bare != target:
+            logger.info("Anthropic model alias resolved: %s -> %s", model, resolved)
+        return resolved
+
+    fallback = original if preserve_unknown_prefix else bare
+    if logger:
+        logger.warning("Could not resolve Anthropic model alias: %s", model)
+    return fallback
+
+
+def normalize_tool_call_ids(
+    messages: List[Dict[str, Any]], target_prefix: str
+) -> List[Dict[str, Any]]:
+    """Rewrite cross-Provider tool call IDs without mutating input messages."""
+
+    id_map: Dict[str, str] = {}
+    for message in messages:
+        if message.get("role") != "assistant":
+            continue
+        for tool_call in message.get("tool_calls") or ():
+            old_id = tool_call.get("id", "")
+            if old_id and not old_id.startswith(f"{target_prefix}_"):
+                id_map.setdefault(old_id, f"{target_prefix}_{len(id_map):06x}")
+
+    if not id_map:
+        return messages
+
+    normalized = []
+    for message in messages:
+        if message.get("role") == "assistant" and message.get("tool_calls"):
+            tool_calls = [
+                {**tool_call, "id": id_map[tool_call["id"]]}
+                if tool_call.get("id") in id_map
+                else tool_call
+                for tool_call in message["tool_calls"]
+            ]
+            normalized.append({**message, "tool_calls": tool_calls})
+        elif message.get("role") == "tool" and message.get("tool_call_id") in id_map:
+            normalized.append(
+                {**message, "tool_call_id": id_map[message["tool_call_id"]]}
+            )
+        else:
+            normalized.append(message)
+    return normalized
+
+
+def _image_dimensions(data: bytes) -> Optional[Tuple[int, int]]:
+    """Read PNG/JPEG dimensions from headers without a Pillow dependency."""
+
+    try:
+        if data[:8] == b"\x89PNG\r\n\x1a\n" and len(data) >= 24:
+            return struct.unpack(">II", data[16:24])
+        if data[:2] == b"\xff\xd8":
+            offset = 2
+            while offset < len(data) - 9:
+                if data[offset] != 0xFF:
+                    break
+                marker = data[offset + 1]
+                if marker in (0xC0, 0xC2):
+                    height, width = struct.unpack(">HH", data[offset + 5 : offset + 9])
+                    return width, height
+                length = struct.unpack(">H", data[offset + 2 : offset + 4])[0]
+                offset += 2 + length
+    except (IndexError, struct.error):
+        return None
+    return None
+
+
+def to_anthropic_content(
+    content: Any,
+    *,
+    resolve_local_files: bool = True,
+    include_image_metadata: bool = True,
+    logger: Optional[logging.Logger] = None,
+) -> Any:
+    """Convert OpenAI content blocks to Anthropic content blocks."""
+
+    if not isinstance(content, list):
+        return content
+
+    converted = []
+    for block in content:
+        if not isinstance(block, dict) or block.get("type") != "image_url":
+            converted.append(block)
+            continue
+
+        image_url = block.get("image_url", {})
+        url = image_url.get("url", "") if isinstance(image_url, dict) else str(image_url)
+        image_data: Optional[bytes] = None
+        if url.startswith("data:"):
+            header, _, encoded = url.partition(",")
+            media_type = (
+                header.split(":", 1)[1].split(";", 1)[0]
+                if ":" in header
+                else "image/png"
+            )
+            source = {"type": "base64", "media_type": media_type, "data": encoded}
+            if include_image_metadata:
+                image_data = base64.b64decode(encoded)
+        elif resolve_local_files and Path(url).is_file():
+            path = Path(url)
+            image_data = path.read_bytes()
+            media_type = mimetypes.guess_type(str(path))[0] or "image/png"
+            source = {
+                "type": "base64",
+                "media_type": media_type,
+                "data": base64.b64encode(image_data).decode("ascii"),
+            }
+            if logger:
+                logger.info("Converted local image to base64: %s (%d bytes)", url, len(image_data))
+        else:
+            source = {"type": "url", "url": url}
+
+        image_block: Dict[str, Any] = {"type": "image", "source": source}
+        if include_image_metadata and image_data:
+            dimensions = _image_dimensions(image_data)
+            if dimensions:
+                image_block["_image_meta"] = {
+                    "width": dimensions[0],
+                    "height": dimensions[1],
+                }
+        converted.append(image_block)
+    return converted
+
+
+def to_anthropic_messages(
+    messages: List[Dict[str, Any]],
+    *,
+    resolve_local_files: bool = True,
+    include_image_metadata: bool = True,
+    split_tool_result_images: bool = True,
+    logger: Optional[logging.Logger] = None,
+) -> Tuple[Any, List[Dict[str, Any]]]:
+    """Convert OpenAI message history to the Anthropic Messages format."""
+
+    def convert(content: Any) -> Any:
+        return to_anthropic_content(
+            content,
+            resolve_local_files=resolve_local_files,
+            include_image_metadata=include_image_metadata,
+            logger=logger,
+        )
+
+    system_prompt = None
+    anthropic_messages: List[Dict[str, Any]] = []
+    for message in messages:
+        role = message.get("role", "")
+        content = message.get("content", "")
+        if role == "system":
+            system_prompt = content
+        elif role == "user":
+            anthropic_messages.append({"role": "user", "content": convert(content)})
+        elif role == "assistant":
+            tool_calls = message.get("tool_calls")
+            if not tool_calls:
+                anthropic_messages.append({"role": "assistant", "content": content})
+                continue
+            content_blocks: List[Dict[str, Any]] = []
+            if content:
+                assistant_content = convert(content)
+                if isinstance(assistant_content, list):
+                    content_blocks.extend(assistant_content)
+                elif isinstance(assistant_content, str) and assistant_content.strip():
+                    content_blocks.append({"type": "text", "text": assistant_content})
+            for tool_call in tool_calls:
+                function = tool_call.get("function", {})
+                arguments = function.get("arguments", "{}")
+                try:
+                    parsed_arguments = (
+                        json.loads(arguments) if isinstance(arguments, str) else arguments
+                    )
+                except json.JSONDecodeError:
+                    parsed_arguments = {}
+                content_blocks.append(
+                    {
+                        "type": "tool_use",
+                        "id": tool_call.get("id", ""),
+                        "name": function.get("name", ""),
+                        "input": parsed_arguments,
+                    }
+                )
+            anthropic_messages.append(
+                {"role": "assistant", "content": content_blocks}
+            )
+        elif role == "tool":
+            tool_content = convert(content)
+            if not split_tool_result_images:
+                blocks = [
+                    {
+                        "type": "tool_result",
+                        "tool_use_id": message.get("tool_call_id", ""),
+                        "content": tool_content,
+                    }
+                ]
+            else:
+                text_parts: List[Dict[str, Any]] = []
+                image_parts: List[Dict[str, Any]] = []
+                if isinstance(tool_content, list):
+                    for block in tool_content:
+                        if isinstance(block, dict) and block.get("type") == "image":
+                            image_parts.append(block)
+                        else:
+                            text_parts.append(block)
+                elif isinstance(tool_content, str) and tool_content:
+                    text_parts.append({"type": "text", "text": tool_content})
+                tool_result: Dict[str, Any] = {
+                    "type": "tool_result",
+                    "tool_use_id": message.get("tool_call_id", ""),
+                }
+                if len(text_parts) == 1 and text_parts[0].get("type") == "text":
+                    tool_result["content"] = text_parts[0]["text"]
+                elif text_parts:
+                    tool_result["content"] = text_parts
+                blocks = [tool_result, *image_parts]
+
+            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(blocks)
+            else:
+                anthropic_messages.append({"role": "user", "content": blocks})
+    return system_prompt, anthropic_messages
+
+
+def to_anthropic_tools(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+    """Convert OpenAI function definitions to Anthropic tool definitions."""
+
+    return [
+        {
+            "name": function.get("name", ""),
+            "description": function.get("description", ""),
+            "input_schema": function.get(
+                "parameters", {"type": "object", "properties": {}}
+            ),
+        }
+        for tool in tools
+        if tool.get("type") == "function"
+        for function in (tool["function"],)
+    ]
+
+
+def parse_anthropic_response(result: Dict[str, Any]) -> Dict[str, Any]:
+    """Convert an Anthropic response to the framework's unified shape."""
+
+    text_parts = []
+    tool_calls = []
+    for block in result.get("content", []):
+        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
+                        ),
+                    },
+                }
+            )
+    stop_reason = result.get("stop_reason", "end_turn")
+    finish_reason = {
+        "end_turn": "stop",
+        "tool_use": "tool_calls",
+        "max_tokens": "length",
+        "stop_sequence": "stop",
+    }.get(stop_reason, stop_reason)
+    raw_usage = result.get("usage", {})
+    return {
+        "content": "\n".join(text_parts),
+        "tool_calls": tool_calls or None,
+        "finish_reason": finish_reason,
+        "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 count_cache_controls(system_prompt: Any, messages: List[Dict[str, Any]]) -> int:
+    """Count cache-control blocks for optional Provider diagnostics."""
+
+    blocks = list(system_prompt) if isinstance(system_prompt, list) else []
+    for message in messages:
+        if isinstance(message.get("content"), list):
+            blocks.extend(message["content"])
+    return sum(
+        1 for block in blocks if isinstance(block, dict) and "cache_control" in block
+    )
+
+
+def build_anthropic_result(parsed: Dict[str, Any], model: str) -> Dict[str, Any]:
+    """Attach cost and token compatibility fields to a parsed response."""
+
+    usage = parsed["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": calculate_cost(model, usage),
+        "usage": usage,
+    }
+
+
+__all__ = [
+    "ANTHROPIC_MODEL_EXACT",
+    "ANTHROPIC_MODEL_FUZZY",
+    "ANTHROPIC_RETRYABLE_EXCEPTIONS",
+    "build_anthropic_result",
+    "count_cache_controls",
+    "normalize_tool_call_ids",
+    "parse_anthropic_response",
+    "resolve_anthropic_model",
+    "to_anthropic_content",
+    "to_anthropic_messages",
+    "to_anthropic_tools",
+]

+ 12 - 42
agent/agent/llm/claude.py

@@ -11,15 +11,14 @@ import logging
 import httpx
 import httpx
 from typing import List, Dict, Any, Optional
 from typing import List, Dict, Any, Optional
 
 
-from .pricing import calculate_cost
-
-# 直接复用底层已经在 openrouter 内部写好的格式转换/拦截器
-from .openrouter import (
-    _normalize_tool_call_ids,
-    _to_anthropic_messages,
-    _to_anthropic_tools,
-    _parse_anthropic_response,
-    _RETRYABLE_EXCEPTIONS
+from .anthropic_protocol import (
+    ANTHROPIC_RETRYABLE_EXCEPTIONS as _RETRYABLE_EXCEPTIONS,
+    build_anthropic_result,
+    count_cache_controls,
+    parse_anthropic_response as _parse_anthropic_response,
+    normalize_tool_call_ids as _normalize_tool_call_ids,
+    to_anthropic_messages as _to_anthropic_messages,
+    to_anthropic_tools as _to_anthropic_tools,
 )
 )
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -65,10 +64,8 @@ async def anthropic_native_llm_call(
 
 
     # 记录使用的配置(只在第一次调用时输出)
     # 记录使用的配置(只在第一次调用时输出)
     if not hasattr(anthropic_native_llm_call, '_logged_config'):
     if not hasattr(anthropic_native_llm_call, '_logged_config'):
-        logger.info(f"[Anthropic Native] Using {key_source}: {api_key[:20]}...")
-        logger.info(f"[Anthropic Native] Using {url_source}: {base_url}")
-        print(f"[Anthropic Native] Using {key_source}: {api_key[:20]}...")
-        print(f"[Anthropic Native] Using {url_source}: {base_url}")
+        logger.info("[Anthropic Native] Using credentials from %s", key_source)
+        logger.info("[Anthropic Native] Using %s endpoint: %s", url_source, base_url)
         anthropic_native_llm_call._logged_config = True
         anthropic_native_llm_call._logged_config = True
 
 
     anthropic_version = os.getenv("ANTHROPIC_VERSION", "2023-06-01")
     anthropic_version = os.getenv("ANTHROPIC_VERSION", "2023-06-01")
@@ -114,17 +111,7 @@ async def anthropic_native_llm_call(
 
 
     # Debug: 检查 cache_control 是否存在
     # Debug: 检查 cache_control 是否存在
     if logger.isEnabledFor(logging.DEBUG):
     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:
         if cache_control_count > 0:
             logger.debug(f"[Anthropic Native] 发现 {cache_control_count} 个 cache_control 标记,将被发送到原生端点")
             logger.debug(f"[Anthropic Native] 发现 {cache_control_count} 个 cache_control 标记,将被发送到原生端点")
 
 
@@ -160,7 +147,6 @@ async def anthropic_native_llm_call(
                     last_exception = e
                     last_exception = e
                     continue
                     continue
                 logger.error("[Anthropic Native] HTTP %d error body: %s", status, error_body)
                 logger.error("[Anthropic Native] HTTP %d error body: %s", status, error_body)
-                print(f"[Anthropic Native] API Error {status}: {error_body[:500]}")
                 raise
                 raise
                 
                 
             except _RETRYABLE_EXCEPTIONS as e:
             except _RETRYABLE_EXCEPTIONS as e:
@@ -174,23 +160,7 @@ async def anthropic_native_llm_call(
     else:
     else:
         raise last_exception  # type: ignore[misc]
         raise last_exception  # type: ignore[misc]
 
 
-    # 解析响应并抽离 Usage
-    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 create_claude_llm_call(model: str = "claude-3-5-sonnet-20241022"):
 def create_claude_llm_call(model: str = "claude-3-5-sonnet-20241022"):

+ 29 - 16
agent/agent/llm/gemini.py

@@ -8,7 +8,7 @@ Gemini Provider (HTTP API)
 
 
 import os
 import os
 import json
 import json
-import sys
+import logging
 import httpx
 import httpx
 from typing import List, Dict, Any, Optional
 from typing import List, Dict, Any, Optional
 
 
@@ -16,6 +16,9 @@ from .usage import TokenUsage
 from .pricing import calculate_cost
 from .pricing import calculate_cost
 
 
 
 
+logger = logging.getLogger(__name__)
+
+
 def _dump_llm_request(endpoint: str, payload: Dict[str, Any], model: str):
 def _dump_llm_request(endpoint: str, payload: Dict[str, Any], model: str):
     """
     """
     Dump完整的LLM请求用于调试(需要设置 AGENT_DEBUG=1)
     Dump完整的LLM请求用于调试(需要设置 AGENT_DEBUG=1)
@@ -57,12 +60,10 @@ def _dump_llm_request(endpoint: str, payload: Dict[str, Any], model: str):
         "payload": truncate_images(payload)
         "payload": truncate_images(payload)
     }
     }
 
 
-    # 输出到stderr
-    print("\n" + "="*80, file=sys.stderr)
-    print("[AGENT_DEBUG] LLM Request Dump", file=sys.stderr)
-    print("="*80, file=sys.stderr)
-    print(json.dumps(debug_info, indent=2, ensure_ascii=False), file=sys.stderr)
-    print("="*80 + "\n", file=sys.stderr)
+    logger.debug(
+        "[AGENT_DEBUG] Gemini request dump:\n%s",
+        json.dumps(debug_info, indent=2, ensure_ascii=False),
+    )
 
 
 
 
 def _convert_messages_to_gemini(messages: List[Dict]) -> tuple[List[Dict], Optional[str]]:
 def _convert_messages_to_gemini(messages: List[Dict]) -> tuple[List[Dict], Optional[str]]:
@@ -99,7 +100,7 @@ def _convert_messages_to_gemini(messages: List[Dict]) -> tuple[List[Dict], Optio
             content_text = msg.get("content", "")
             content_text = msg.get("content", "")
 
 
             if not tool_name:
             if not tool_name:
-                print(f"[WARNING] Tool message missing 'name' field, skipping")
+                logger.warning("Gemini tool message missing name; skipping")
                 continue
                 continue
 
 
             # 尝试解析为 JSON
             # 尝试解析为 JSON
@@ -197,7 +198,7 @@ def _convert_messages_to_gemini(messages: List[Dict]) -> tuple[List[Dict], Optio
                                 }
                                 }
                             })
                             })
                         except Exception as e:
                         except Exception as e:
-                            print(f"[WARNING] Failed to parse image data URL: {e}")
+                            logger.warning("Failed to parse Gemini image data URL: %s", e)
 
 
             if parts:
             if parts:
                 gemini_role = "model" if role == "assistant" else "user"
                 gemini_role = "model" if role == "assistant" else "user"
@@ -345,7 +346,11 @@ def create_gemini_llm_call(
         # 转换消息
         # 转换消息
         contents, system_instruction = _convert_messages_to_gemini(messages)
         contents, system_instruction = _convert_messages_to_gemini(messages)
 
 
-        print(f"\n[Gemini HTTP] Converted {len(contents)} messages: {[c['role'] for c in contents]}")
+        logger.debug(
+            "[Gemini HTTP] Converted %d messages: %s",
+            len(contents),
+            [content["role"] for content in contents],
+        )
 
 
         # 构建请求
         # 构建请求
         endpoint = f"{base_url}/models/{model}:generateContent"
         endpoint = f"{base_url}/models/{model}:generateContent"
@@ -372,17 +377,22 @@ def create_gemini_llm_call(
 
 
         except httpx.HTTPStatusError as e:
         except httpx.HTTPStatusError as e:
             error_body = e.response.text
             error_body = e.response.text
-            print(f"[Gemini HTTP] Error {e.response.status_code}: {error_body}")
+            logger.error(
+                "[Gemini HTTP] Error %d: %s",
+                e.response.status_code,
+                error_body,
+            )
             raise
             raise
         except Exception as e:
         except Exception as e:
-            print(f"[Gemini HTTP] Request failed: {e}")
+            logger.error("[Gemini HTTP] Request failed: %s", e)
             raise
             raise
 
 
         # Debug: 输出原始响应(如果启用)
         # Debug: 输出原始响应(如果启用)
         if os.getenv("AGENT_DEBUG"):
         if os.getenv("AGENT_DEBUG"):
-            print("\n[AGENT_DEBUG] Gemini Response:", file=sys.stderr)
-            print(json.dumps(gemini_resp, ensure_ascii=False, indent=2)[:2000], file=sys.stderr)
-            print("\n", file=sys.stderr)
+            logger.debug(
+                "[AGENT_DEBUG] Gemini response:\n%s",
+                json.dumps(gemini_resp, ensure_ascii=False, indent=2)[:2000],
+            )
 
 
         # 解析响应
         # 解析响应
         content = ""
         content = ""
@@ -411,7 +421,10 @@ def create_gemini_llm_call(
                 # Gemini 返回了格式错误的函数调用
                 # Gemini 返回了格式错误的函数调用
                 # 提取 finishMessage 中的内容作为 content
                 # 提取 finishMessage 中的内容作为 content
                 finish_message = candidate.get("finishMessage", "")
                 finish_message = candidate.get("finishMessage", "")
-                print(f"[Gemini HTTP] Warning: MALFORMED_FUNCTION_CALL\n{finish_message}")
+                logger.warning(
+                    "[Gemini HTTP] MALFORMED_FUNCTION_CALL: %s",
+                    finish_message,
+                )
                 content = f"[模型尝试调用工具但格式错误]\n\n{finish_message}"
                 content = f"[模型尝试调用工具但格式错误]\n\n{finish_message}"
             else:
             else:
                 # 正常解析
                 # 正常解析

+ 29 - 391
agent/agent/llm/openrouter.py

@@ -15,64 +15,37 @@ OpenRouter 转发多种模型,需要根据实际模型处理不同的 usage 
 """
 """
 
 
 import os
 import os
-import json
 import asyncio
 import asyncio
 import logging
 import logging
 import httpx
 import httpx
 from pathlib import Path
 from pathlib import Path
 from typing import List, Dict, Any, Optional
 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
 from .pricing import calculate_cost
 
 
 logger = logging.getLogger(__name__)
 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 ──────────────────────
 # ── OpenRouter Anthropic endpoint: model name mapping ──────────────────────
 # Local copy of yescode's model tables so this module is self-contained.
 # 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:
 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,
     Strips ``anthropic/`` prefix, resolves aliases / dot-notation,
     and re-prepends ``anthropic/`` for OpenRouter routing.
     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:
 def _sanitize_schema_name(title: str) -> str:
@@ -212,237 +146,14 @@ def _ensure_strict_schema(schema: Dict) -> Dict:
     result.pop("$schema", None)
     result.pop("$schema", None)
 
 
     return result
     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 ─────────────────────────────────────
 # ── 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(
 async def _openrouter_anthropic_call(
     messages: List[Dict[str, Any]],
     messages: List[Dict[str, Any]],
     model: str,
     model: str,
@@ -586,7 +253,6 @@ async def _openrouter_anthropic_call(
                     _img_count += 1
                     _img_count += 1
     if _img_count:
     if _img_count:
         logger.info("[OpenRouter/Anthropic] payload contains %d image block(s)", _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] = {
     payload: Dict[str, Any] = {
         "model": resolved_model,
         "model": resolved_model,
@@ -616,17 +282,7 @@ async def _openrouter_anthropic_call(
 
 
     # Debug: 检查 cache_control 是否存在
     # Debug: 检查 cache_control 是否存在
     if logger.isEnabledFor(logging.DEBUG):
     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:
         if cache_control_count > 0:
             logger.debug(f"[OpenRouter/Anthropic] 发现 {cache_control_count} 个 cache_control 标记")
             logger.debug(f"[OpenRouter/Anthropic] 发现 {cache_control_count} 个 cache_control 标记")
 
 
@@ -660,9 +316,7 @@ async def _openrouter_anthropic_call(
                     await asyncio.sleep(wait)
                     await asyncio.sleep(wait)
                     last_exception = e
                     last_exception = e
                     continue
                     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)
                 logger.error("[OpenRouter/Anthropic] HTTP %d error body: %s", status, error_body)
-                print(f"[OpenRouter/Anthropic] API Error {status}: {error_body[:500]}")
                 raise
                 raise
 
 
             except _RETRYABLE_EXCEPTIONS as e:
             except _RETRYABLE_EXCEPTIONS as e:
@@ -679,23 +333,7 @@ async def _openrouter_anthropic_call(
     else:
     else:
         raise last_exception  # type: ignore[misc]
         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]]:
 def _resolve_local_images_in_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:

+ 8 - 3
agent/agent/llm/pricing.py

@@ -12,7 +12,8 @@ LLM 定价计算器
 
 
 import os
 import os
 import re
 import re
-from dataclasses import dataclass, field
+import logging
+from dataclasses import dataclass
 from pathlib import Path
 from pathlib import Path
 from typing import Dict, Any, Optional, List
 from typing import Dict, Any, Optional, List
 import yaml
 import yaml
@@ -20,6 +21,9 @@ import yaml
 from .usage import TokenUsage
 from .usage import TokenUsage
 
 
 
 
+logger = logging.getLogger(__name__)
+
+
 @dataclass
 @dataclass
 class ModelPricing:
 class ModelPricing:
     """
     """
@@ -170,7 +174,7 @@ class PricingCalculator:
 
 
         for path in search_paths:
         for path in search_paths:
             if path.exists():
             if path.exists():
-                print(f"[Pricing] Loaded config from: {path}")
+                logger.info("Pricing config loaded from %s", path)
                 return str(path)
                 return str(path)
 
 
         return None
         return None
@@ -201,7 +205,8 @@ class PricingCalculator:
         """
         """
         内置默认定价表
         内置默认定价表
 
 
-        价格来源:各提供商官网(2024-12 更新)
+        这些值仅是无外部配置时的兼容兜底;生产环境应通过
+        ``AGENT_PRICING_CONFIG`` 提供经过校验的实时价格。
         单位:美元 / 1M tokens
         单位:美元 / 1M tokens
         """
         """
         return [
         return [

+ 1 - 1
agent/agent/llm/prompts/wrapper.py

@@ -6,7 +6,7 @@ Prompt Wrapper - 为 .prompt 文件提供 Prompt 实现
 
 
 import base64
 import base64
 from pathlib import Path
 from pathlib import Path
-from typing import List, Dict, Any, Union, Optional
+from typing import List, Dict, Any, Union
 from agent.llm.prompts.loader import load_prompt, get_message
 from agent.llm.prompts.loader import load_prompt, get_message
 
 
 
 

+ 2 - 5
agent/agent/llm/usage.py

@@ -13,9 +13,8 @@ Token Usage 数据模型和费用计算
 - PricingCalculator: 策略模式,根据定价表计算费用
 - PricingCalculator: 策略模式,根据定价表计算费用
 """
 """
 
 
-from dataclasses import dataclass, field
-from typing import Dict, Any, Optional
-import copy
+from dataclasses import dataclass
+from typing import Dict, Any
 
 
 
 
 @dataclass(frozen=True)
 @dataclass(frozen=True)
@@ -82,8 +81,6 @@ class TokenUsage:
 
 
         这里返回等效的全价 tokens 数
         这里返回等效的全价 tokens 数
         """
         """
-        # 普通输入 = 总输入 - 缓存读取
-        regular_input = self.input_tokens - self.cache_read_tokens
         # 等效计费 = 普通输入 + 缓存读取*0.1 + 缓存创建*1.25
         # 等效计费 = 普通输入 + 缓存读取*0.1 + 缓存创建*1.25
         # 简化:返回原始值,让 PricingCalculator 处理
         # 简化:返回原始值,让 PricingCalculator 处理
         return self.input_tokens
         return self.input_tokens

+ 73 - 393
agent/agent/llm/yescode.py

@@ -1,392 +1,91 @@
-"""
-Yescode Provider
+"""Yescode Provider using the Anthropic Messages API."""
 
 
-使用 Yescode 代理 API 调用 Claude 等模型
-使用 Anthropic Messages API 格式(/v1/messages)
-
-环境变量:
-- YESCODE_BASE_URL: API 基础地址(如 https://co.yes.vg)
-- YESCODE_API_KEY: API 密钥
-
-注意:
-- Yescode 代理要求 User-Agent 包含 "claude-code"
-- 使用 Anthropic 原生 Messages API 格式
-- 响应格式转换为框架统一的 OpenAI 兼容格式
-"""
-
-import os
-import json
 import asyncio
 import asyncio
 import logging
 import logging
-import httpx
-from typing import List, Dict, Any, Optional
-
-from .usage import TokenUsage
-from .pricing import calculate_cost
+import os
+from typing import Any, Dict, List, Optional
 
 
-logger = logging.getLogger(__name__)
+import httpx
 
 
-# 可重试的异常类型
-_RETRYABLE_EXCEPTIONS = (
-    httpx.RemoteProtocolError,
-    httpx.ConnectError,
-    httpx.ReadTimeout,
-    httpx.WriteTimeout,
-    httpx.ConnectTimeout,
-    httpx.PoolTimeout,
-    ConnectionError,
+from .anthropic_protocol import (
+    ANTHROPIC_MODEL_EXACT,
+    ANTHROPIC_MODEL_FUZZY,
+    ANTHROPIC_RETRYABLE_EXCEPTIONS,
+    build_anthropic_result,
+    normalize_tool_call_ids as _normalize_tool_call_ids,
+    parse_anthropic_response as _parse_anthropic_response,
+    resolve_anthropic_model,
+    to_anthropic_content,
+    to_anthropic_messages,
+    to_anthropic_tools as _convert_tools_to_anthropic,
 )
 )
 
 
-# 模糊匹配规则:(关键词, 目标模型名),从精确到宽泛排序
-# 精确匹配走 MODEL_EXACT,不命中则按顺序尝试关键词匹配
-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",
-}
+logger = logging.getLogger(__name__)
 
 
-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"),
-]
+# Keep existing module-level names available to callers that imported them.
+_RETRYABLE_EXCEPTIONS = ANTHROPIC_RETRYABLE_EXCEPTIONS
+MODEL_EXACT = ANTHROPIC_MODEL_EXACT
+MODEL_FUZZY = ANTHROPIC_MODEL_FUZZY
 
 
 
 
 def _resolve_model(model: str) -> str:
 def _resolve_model(model: str) -> str:
-    """将任意格式的模型名映射为 Yescode API 接受的模型名。
-    支持:OpenRouter 前缀(anthropic/xxx)、带点号(4.5)、纯家族名(sonnet)等。
-    """
-    # 1. 剥离 provider 前缀
-    if "/" in model:
-        model = model.split("/", 1)[1]
-
-    # 2. 精确匹配
-    if model in MODEL_EXACT:
-        return MODEL_EXACT[model]
-
-    # 3. 模糊匹配(大小写不敏感)
-    model_lower = model.lower()
-    for keyword, target in MODEL_FUZZY:
-        if keyword in model_lower:
-            logger.info("模型名模糊匹配: %s → %s", model, target)
-            return target
-
-    # 4. 兜底:原样返回,让 API 报错
-    logger.warning("未能匹配模型名: %s, 原样传递", model)
-    return model
-
+    """Resolve a framework model alias for Yescode."""
 
 
-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
+    return resolve_anthropic_model(model, logger=logger)
 
 
 
 
 def _convert_content_to_anthropic(content: Any) -> Any:
 def _convert_content_to_anthropic(content: Any) -> Any:
-    """
-    将 OpenAI 格式的 content(字符串或列表)转换为 Anthropic 格式。
-    主要处理 image_url 类型块 → Anthropic image 块。
-    """
-    if not isinstance(content, list):
-        return content
-
-    result = []
-    for block in content:
-        if not isinstance(block, dict):
-            result.append(block)
-            continue
-
-        block_type = block.get("type", "")
-        if block_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:"):
-                # base64 编码图片:data:<media_type>;base64,<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:
-                result.append({
-                    "type": "image",
-                    "source": {
-                        "type": "url",
-                        "url": url,
-                    },
-                })
-        else:
-            result.append(block)
-    return result
-
-
-def _convert_messages_to_anthropic(messages: List[Dict[str, Any]]) -> tuple:
-    """
-    将 OpenAI 格式消息转换为 Anthropic Messages API 格式
-
-    Returns:
-        (system_prompt, anthropic_messages)
-    """
-    system_prompt = None
-    anthropic_messages = []
-
-    for msg in messages:
-        role = msg.get("role", "")
-        content = msg.get("content", "")
-
-        if role == "system":
-            # Anthropic 把 system 消息放在顶层参数中
-            system_prompt = content
-        elif role == "user":
-            anthropic_messages.append({"role": "user", "content": _convert_content_to_anthropic(content)})
-        elif role == "assistant":
-            assistant_msg = {"role": "assistant"}
-            # 处理 tool_calls(assistant 发起工具调用)
-            tool_calls = msg.get("tool_calls")
-            if tool_calls:
-                content_blocks = []
-                if content:
-                    # content 可能已被 _add_cache_control 转成 list(含 cache_control),
-                    # 也可能是普通字符串。两者都需要正确处理,避免产生 {"type":"text","text":[...]}
-                    converted = _convert_content_to_anthropic(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,
-                    })
-                assistant_msg["content"] = content_blocks
-            else:
-                assistant_msg["content"] = content
-            anthropic_messages.append(assistant_msg)
-        elif role == "tool":
-            # OpenAI tool 结果 -> Anthropic tool_result
-            # Anthropic 要求同一个 assistant 的所有 tool_results 合并到一个 user message 中
-            tool_result_block = {
-                "type": "tool_result",
-                "tool_use_id": msg.get("tool_call_id", ""),
-                "content": _convert_content_to_anthropic(content),
-            }
-            # 如果上一条已经是 tool_result 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"].append(tool_result_block)
-            else:
-                anthropic_messages.append({
-                    "role": "user",
-                    "content": [tool_result_block],
-                })
-
-    return system_prompt, anthropic_messages
-
+    """Preserve Yescode's original image conversion behavior."""
 
 
-def _convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
-    """将 OpenAI 工具定义转换为 Anthropic 格式"""
-    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]:
-    """
-    将 Anthropic Messages API 响应转换为框架统一格式
-
-    Anthropic 响应格式:
-    {
-        "id": "msg_...",
-        "type": "message",
-        "role": "assistant",
-        "content": [{"type": "text", "text": "..."}, {"type": "tool_use", ...}],
-        "usage": {"input_tokens": ..., "output_tokens": ...},
-        "stop_reason": "end_turn" | "tool_use" | "max_tokens"
-    }
-    """
-    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":
-            # 转换为 OpenAI tool_calls 格式
-            tool_calls.append({
-                "id": block.get("id", ""),
-                "type": "function",
-                "function": {
-                    "name": block.get("name", ""),
-                    "arguments": json.dumps(block.get("input", {}), ensure_ascii=False),
-                },
-            })
+    return to_anthropic_content(
+        content,
+        resolve_local_files=False,
+        include_image_metadata=False,
+    )
 
 
-    content = "\n".join(text_parts)
 
 
-    # 映射 stop_reason
-    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)
-
-    # 提取 usage(Anthropic 原生格式)
-    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 _convert_messages_to_anthropic(
+    messages: List[Dict[str, Any]],
+) -> tuple[Any, List[Dict[str, Any]]]:
+    """Preserve Yescode's nested tool-result image representation."""
+
+    return to_anthropic_messages(
+        messages,
+        resolve_local_files=False,
+        include_image_metadata=False,
+        split_tool_result_images=False,
     )
     )
 
 
-    return {
-        "content": content,
-        "tool_calls": tool_calls if tool_calls else None,
-        "finish_reason": finish_reason,
-        "usage": usage,
-    }
-
 
 
 async def yescode_llm_call(
 async def yescode_llm_call(
     messages: List[Dict[str, Any]],
     messages: List[Dict[str, Any]],
     model: str = "claude-sonnet-4.5",
     model: str = "claude-sonnet-4.5",
     tools: Optional[List[Dict]] = None,
     tools: Optional[List[Dict]] = None,
-    **kwargs
+    **kwargs,
 ) -> Dict[str, Any]:
 ) -> Dict[str, Any]:
-    """
-    Yescode LLM 调用函数
-
-    Args:
-        messages: OpenAI 格式消息列表
-        model: 模型名称(如 "claude-sonnet-4.5")
-        tools: OpenAI 格式工具定义
-        **kwargs: 其他参数(temperature, max_tokens 等)
+    """Call a Yescode Anthropic-compatible endpoint."""
 
 
-    Returns:
-        统一格式的响应字典
-    """
     base_url = os.getenv("YESCODE_BASE_URL")
     base_url = os.getenv("YESCODE_BASE_URL")
     api_key = os.getenv("YESCODE_API_KEY")
     api_key = os.getenv("YESCODE_API_KEY")
-
     if not base_url:
     if not base_url:
         raise ValueError("YESCODE_BASE_URL environment variable not set")
         raise ValueError("YESCODE_BASE_URL environment variable not set")
     if not api_key:
     if not api_key:
         raise ValueError("YESCODE_API_KEY environment variable not set")
         raise ValueError("YESCODE_API_KEY environment variable not set")
 
 
-    base_url = base_url.rstrip("/")
-    endpoint = f"{base_url}/v1/messages"
-
-    # 解析模型名
+    endpoint = f"{base_url.rstrip('/')}/v1/messages"
     api_model = _resolve_model(model)
     api_model = _resolve_model(model)
-
-    # 跨 Provider 续跑时,重写不兼容的 tool_call_id
     messages = _normalize_tool_call_ids(messages, "toolu")
     messages = _normalize_tool_call_ids(messages, "toolu")
-
-    # 转换消息格式
     system_prompt, anthropic_messages = _convert_messages_to_anthropic(messages)
     system_prompt, anthropic_messages = _convert_messages_to_anthropic(messages)
 
 
-    # 构建 Anthropic 格式请求
     payload = {
     payload = {
         "model": api_model,
         "model": api_model,
         "messages": anthropic_messages,
         "messages": anthropic_messages,
         "max_tokens": kwargs.get("max_tokens", 16384),
         "max_tokens": kwargs.get("max_tokens", 16384),
     }
     }
-
     if system_prompt:
     if system_prompt:
         payload["system"] = system_prompt
         payload["system"] = system_prompt
-
     if tools:
     if tools:
         payload["tools"] = _convert_tools_to_anthropic(tools)
         payload["tools"] = _convert_tools_to_anthropic(tools)
-
     if "temperature" in kwargs:
     if "temperature" in kwargs:
         payload["temperature"] = kwargs["temperature"]
         payload["temperature"] = kwargs["temperature"]
 
 
@@ -397,7 +96,6 @@ async def yescode_llm_call(
         "user-agent": "claude-code/1.0.0",
         "user-agent": "claude-code/1.0.0",
     }
     }
 
 
-    # 调用 API(带重试)
     max_retries = 5
     max_retries = 5
     last_exception = None
     last_exception = None
     for attempt in range(max_retries):
     for attempt in range(max_retries):
@@ -407,81 +105,63 @@ async def yescode_llm_call(
                 response.raise_for_status()
                 response.raise_for_status()
                 result = response.json()
                 result = response.json()
                 break
                 break
-
-            except httpx.HTTPStatusError as e:
-                error_body = e.response.text
-                status = e.response.status_code
-                if status in (429, 500, 502, 503, 504, 524, 529) and attempt < max_retries - 1:
-                    wait = 2 ** attempt * 2
+            except httpx.HTTPStatusError as exc:
+                error_body = exc.response.text
+                status = exc.response.status_code
+                if (
+                    status in (429, 500, 502, 503, 504, 524, 529)
+                    and attempt < max_retries - 1
+                ):
+                    wait = 2**attempt * 2
                     logger.warning(
                     logger.warning(
                         "[Yescode] HTTP %d (attempt %d/%d), retrying in %ds: %s",
                         "[Yescode] HTTP %d (attempt %d/%d), retrying in %ds: %s",
-                        status, attempt + 1, max_retries, wait, error_body[:200],
+                        status,
+                        attempt + 1,
+                        max_retries,
+                        wait,
+                        error_body[:200],
                     )
                     )
                     await asyncio.sleep(wait)
                     await asyncio.sleep(wait)
-                    last_exception = e
+                    last_exception = exc
                     continue
                     continue
                 logger.error("[Yescode] Error %d: %s", status, error_body)
                 logger.error("[Yescode] Error %d: %s", status, error_body)
-                print(f"[Yescode] API Error {status}: {error_body[:500]}")
                 raise
                 raise
-
-            except _RETRYABLE_EXCEPTIONS as e:
-                last_exception = e
+            except _RETRYABLE_EXCEPTIONS as exc:
+                last_exception = exc
                 if attempt < max_retries - 1:
                 if attempt < max_retries - 1:
-                    wait = 2 ** attempt * 2
+                    wait = 2**attempt * 2
                     logger.warning(
                     logger.warning(
                         "[Yescode] %s (attempt %d/%d), retrying in %ds",
                         "[Yescode] %s (attempt %d/%d), retrying in %ds",
-                        type(e).__name__, attempt + 1, max_retries, wait,
+                        type(exc).__name__,
+                        attempt + 1,
+                        max_retries,
+                        wait,
                     )
                     )
                     await asyncio.sleep(wait)
                     await asyncio.sleep(wait)
                     continue
                     continue
-                logger.error("[Yescode] Request failed after %d attempts: %s", max_retries, e)
+                logger.error(
+                    "[Yescode] Request failed after %d attempts: %s",
+                    max_retries,
+                    exc,
+                )
                 raise
                 raise
-
-            except Exception as e:
-                logger.error("[Yescode] Request failed: %s", e)
+            except Exception as exc:
+                logger.error("[Yescode] Request failed: %s", exc)
                 raise
                 raise
     else:
     else:
         raise last_exception  # type: ignore[misc]
         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 create_yescode_llm_call(
-    model: str = "claude-sonnet-4.5"
-):
-    """
-    创建 Yescode LLM 调用函数
 
 
-    Args:
-        model: 模型名称
-            - "claude-sonnet-4.5"
+def create_yescode_llm_call(model: str = "claude-sonnet-4.5"):
+    """Create a Yescode LLM callable bound to a default model."""
 
 
-    Returns:
-        异步 LLM 调用函数
-    """
     async def llm_call(
     async def llm_call(
         messages: List[Dict[str, Any]],
         messages: List[Dict[str, Any]],
         model: str = model,
         model: str = model,
         tools: Optional[List[Dict]] = None,
         tools: Optional[List[Dict]] = None,
-        **kwargs
+        **kwargs,
     ) -> Dict[str, Any]:
     ) -> Dict[str, Any]:
         return await yescode_llm_call(messages, model, tools, **kwargs)
         return await yescode_llm_call(messages, model, tools, **kwargs)
 
 

+ 93 - 0
agent/tests/test_anthropic_protocol.py

@@ -0,0 +1,93 @@
+import base64
+
+from agent.llm import openrouter, yescode
+from agent.llm.anthropic_protocol import (
+    ANTHROPIC_MODEL_EXACT,
+    ANTHROPIC_MODEL_FUZZY,
+    build_anthropic_result,
+    normalize_tool_call_ids,
+    parse_anthropic_response,
+    to_anthropic_messages,
+)
+
+
+def test_provider_model_tables_share_one_source_of_truth():
+    assert yescode.MODEL_EXACT is ANTHROPIC_MODEL_EXACT
+    assert yescode.MODEL_FUZZY is ANTHROPIC_MODEL_FUZZY
+    assert openrouter._OR_MODEL_EXACT is ANTHROPIC_MODEL_EXACT
+    assert openrouter._OR_MODEL_FUZZY is ANTHROPIC_MODEL_FUZZY
+    assert yescode._resolve_model("anthropic/claude-sonnet-4.5") == (
+        "claude-sonnet-4-5-20250929"
+    )
+    assert openrouter._resolve_openrouter_model("claude-sonnet-4.5") == (
+        "anthropic/claude-sonnet-4-5-20250929"
+    )
+
+
+def test_unknown_model_fallback_remains_provider_specific():
+    assert yescode._resolve_model("vendor/new-model") == "new-model"
+    assert openrouter._resolve_openrouter_model("vendor/new-model") == (
+        "vendor/new-model"
+    )
+
+
+def test_tool_call_id_normalization_links_assistant_and_tool_without_mutation():
+    messages = [
+        {
+            "role": "assistant",
+            "tool_calls": [{"id": "call_old", "function": {"name": "read"}}],
+        },
+        {"role": "tool", "tool_call_id": "call_old", "content": "ok"},
+    ]
+
+    normalized = normalize_tool_call_ids(messages, "toolu")
+
+    assert normalized[0]["tool_calls"][0]["id"] == "toolu_000000"
+    assert normalized[1]["tool_call_id"] == "toolu_000000"
+    assert messages[0]["tool_calls"][0]["id"] == "call_old"
+    assert normalize_tool_call_ids(normalized, "toolu") is normalized
+
+
+def test_yescode_and_openrouter_keep_their_image_nesting_contracts():
+    png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 8 + (2).to_bytes(4, "big") + (
+        3
+    ).to_bytes(4, "big")
+    uri = "data:image/png;base64," + base64.b64encode(png).decode()
+    messages = [
+        {
+            "role": "tool",
+            "tool_call_id": "toolu_1",
+            "content": [{"type": "image_url", "image_url": {"url": uri}}],
+        }
+    ]
+
+    _, yescode_messages = yescode._convert_messages_to_anthropic(messages)
+    _, shared_messages = to_anthropic_messages(messages)
+
+    nested = yescode_messages[0]["content"][0]["content"][0]
+    assert nested["type"] == "image"
+    assert "_image_meta" not in nested
+    sibling = shared_messages[0]["content"][1]
+    assert sibling["type"] == "image"
+    assert sibling["_image_meta"] == {"width": 2, "height": 3}
+
+
+def test_anthropic_response_and_result_shape_remain_compatible():
+    parsed = parse_anthropic_response(
+        {
+            "content": [
+                {"type": "text", "text": "done"},
+                {"type": "tool_use", "id": "1", "name": "read", "input": {}},
+            ],
+            "stop_reason": "tool_use",
+            "usage": {"input_tokens": 4, "output_tokens": 2},
+        }
+    )
+    result = build_anthropic_result(parsed, "claude-sonnet-4-6")
+
+    assert result["content"] == "done"
+    assert result["tool_calls"][0]["function"]["name"] == "read"
+    assert result["finish_reason"] == "tool_calls"
+    assert result["prompt_tokens"] == 4
+    assert result["completion_tokens"] == 2
+    assert "cost" in result