Переглянути джерело

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

抽出 Anthropic Messages 转换、Tool Call ID 归一化、模型别名、Usage 和响应封装;Yescode、OpenRouter、Claude 保留各自鉴权、端点、重试和特有校验,并补充跨 Provider 兼容测试。
SamLee 1 день тому
батько
коміт
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
 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__)
@@ -65,10 +64,8 @@ async def anthropic_native_llm_call(
 
     # 记录使用的配置(只在第一次调用时输出)
     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_version = os.getenv("ANTHROPIC_VERSION", "2023-06-01")
@@ -114,17 +111,7 @@ async def anthropic_native_llm_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"[Anthropic Native] 发现 {cache_control_count} 个 cache_control 标记,将被发送到原生端点")
 
@@ -160,7 +147,6 @@ async def anthropic_native_llm_call(
                     last_exception = e
                     continue
                 logger.error("[Anthropic Native] HTTP %d error body: %s", status, error_body)
-                print(f"[Anthropic Native] API Error {status}: {error_body[:500]}")
                 raise
                 
             except _RETRYABLE_EXCEPTIONS as e:
@@ -174,23 +160,7 @@ async def anthropic_native_llm_call(
     else:
         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"):

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

@@ -8,7 +8,7 @@ Gemini Provider (HTTP API)
 
 import os
 import json
-import sys
+import logging
 import httpx
 from typing import List, Dict, Any, Optional
 
@@ -16,6 +16,9 @@ from .usage import TokenUsage
 from .pricing import calculate_cost
 
 
+logger = logging.getLogger(__name__)
+
+
 def _dump_llm_request(endpoint: str, payload: Dict[str, Any], model: str):
     """
     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)
     }
 
-    # 输出到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]]:
@@ -99,7 +100,7 @@ def _convert_messages_to_gemini(messages: List[Dict]) -> tuple[List[Dict], Optio
             content_text = msg.get("content", "")
 
             if not tool_name:
-                print(f"[WARNING] Tool message missing 'name' field, skipping")
+                logger.warning("Gemini tool message missing name; skipping")
                 continue
 
             # 尝试解析为 JSON
@@ -197,7 +198,7 @@ def _convert_messages_to_gemini(messages: List[Dict]) -> tuple[List[Dict], Optio
                                 }
                             })
                         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:
                 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)
 
-        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"
@@ -372,17 +377,22 @@ def create_gemini_llm_call(
 
         except httpx.HTTPStatusError as e:
             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
         except Exception as e:
-            print(f"[Gemini HTTP] Request failed: {e}")
+            logger.error("[Gemini HTTP] Request failed: %s", e)
             raise
 
         # 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 = ""
@@ -411,7 +421,10 @@ def create_gemini_llm_call(
                 # Gemini 返回了格式错误的函数调用
                 # 提取 finishMessage 中的内容作为 content
                 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}"
             else:
                 # 正常解析

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

@@ -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]]:

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

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

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

@@ -6,7 +6,7 @@ Prompt Wrapper - 为 .prompt 文件提供 Prompt 实现
 
 import base64
 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
 
 

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

@@ -13,9 +13,8 @@ Token Usage 数据模型和费用计算
 - 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)
@@ -82,8 +81,6 @@ class TokenUsage:
 
         这里返回等效的全价 tokens 数
         """
-        # 普通输入 = 总输入 - 缓存读取
-        regular_input = self.input_tokens - self.cache_read_tokens
         # 等效计费 = 普通输入 + 缓存读取*0.1 + 缓存创建*1.25
         # 简化:返回原始值,让 PricingCalculator 处理
         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 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:
-    """将任意格式的模型名映射为 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:
-    """
-    将 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(
     messages: List[Dict[str, Any]],
     model: str = "claude-sonnet-4.5",
     tools: Optional[List[Dict]] = None,
-    **kwargs
+    **kwargs,
 ) -> 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")
     api_key = os.getenv("YESCODE_API_KEY")
-
     if not base_url:
         raise ValueError("YESCODE_BASE_URL environment variable not set")
     if not api_key:
         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)
-
-    # 跨 Provider 续跑时,重写不兼容的 tool_call_id
     messages = _normalize_tool_call_ids(messages, "toolu")
-
-    # 转换消息格式
     system_prompt, anthropic_messages = _convert_messages_to_anthropic(messages)
 
-    # 构建 Anthropic 格式请求
     payload = {
         "model": api_model,
         "messages": anthropic_messages,
         "max_tokens": kwargs.get("max_tokens", 16384),
     }
-
     if system_prompt:
         payload["system"] = system_prompt
-
     if tools:
         payload["tools"] = _convert_tools_to_anthropic(tools)
-
     if "temperature" in kwargs:
         payload["temperature"] = kwargs["temperature"]
 
@@ -397,7 +96,6 @@ async def yescode_llm_call(
         "user-agent": "claude-code/1.0.0",
     }
 
-    # 调用 API(带重试)
     max_retries = 5
     last_exception = None
     for attempt in range(max_retries):
@@ -407,81 +105,63 @@ async def yescode_llm_call(
                 response.raise_for_status()
                 result = response.json()
                 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(
                         "[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)
-                    last_exception = e
+                    last_exception = exc
                     continue
                 logger.error("[Yescode] Error %d: %s", status, error_body)
-                print(f"[Yescode] API Error {status}: {error_body[:500]}")
                 raise
-
-            except _RETRYABLE_EXCEPTIONS as e:
-                last_exception = e
+            except _RETRYABLE_EXCEPTIONS as exc:
+                last_exception = exc
                 if attempt < max_retries - 1:
-                    wait = 2 ** attempt * 2
+                    wait = 2**attempt * 2
                     logger.warning(
                         "[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)
                     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
-
-            except Exception as e:
-                logger.error("[Yescode] Request failed: %s", e)
+            except Exception as exc:
+                logger.error("[Yescode] Request failed: %s", exc)
                 raise
     else:
         raise last_exception  # type: ignore[misc]
 
-    # 解析 Anthropic 响应并转换为统一格式
-    parsed = _parse_anthropic_response(result)
-    usage = parsed["usage"]
-
-    # 计算费用
-    cost = calculate_cost(model, usage)
-
-    return {
-        "content": parsed["content"],
-        "tool_calls": parsed["tool_calls"],
-        "prompt_tokens": usage.input_tokens,
-        "completion_tokens": usage.output_tokens,
-        "reasoning_tokens": usage.reasoning_tokens,
-        "cache_creation_tokens": usage.cache_creation_tokens,
-        "cache_read_tokens": usage.cache_read_tokens,
-        "finish_reason": parsed["finish_reason"],
-        "cost": cost,
-        "usage": usage,
-    }
-
+    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(
         messages: List[Dict[str, Any]],
         model: str = model,
         tools: Optional[List[Dict]] = None,
-        **kwargs
+        **kwargs,
     ) -> Dict[str, Any]:
         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