Files

1 line
19 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "catmaniii/vibecraft", "files": {"src/vibecraft/llm/anthropic_provider.py": {"48b0114ebe6d1684898451de00ced963d284f6f6": {"t": 1785745988.5361695, "n": 7803, "h": "309551c89424f080", "c": "\"\"\"AnthropicProvider —— 通过 anthropic SDK 调用 Claude 或 Anthropic 兼容端点。\n\n仅在 production 路径用,单测全部 mock(不调真实 API)。\n本文件 import anthropic SDK,但 LLMProvider Protocol 已在 provider.py\n独立定义,import 失败不影响其他模块。\n\n模型默认 `claude-sonnet-4-6`(设计文档 §7.4),可通过 LLMConfig 切换。\n`base_url` 指向 DeepSeek 的 Anthropic 兼容端点(`https://api.deepseek.com/anthropic`)\n时,同一份代码即可调 DeepSeek V4(见 ADR 0005)。\n\n错误处理策略(设计文档 §7.6):\n- `anthropic.APITimeoutError` / `asyncio.TimeoutError` → 由上层 `asyncio.wait_for` 或\n SDK 内部超时触发,统一被 `IntentParser` 包裹成 `ParseError(kind=TIMEOUT)`。\n- `anthropic.RateLimitError` / 其他 `anthropic.APIError` → 抛出,\n 由 `IntentParser` 的 `except Exception` 包裹成 `ParseError(kind=PROVIDER_ERROR)`。\n- `LLMError`(tool_use 缺失)→ 同上,走 `PROVIDER_ERROR`。\n\n**关键不变量**:AnthropicProvider 本身不吞掉任何异常。所有错误都向上抛,\n由 IntentParser 统一转成 ParseError。\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport time\nfrom typing import TYPE_CHECKING, Any\n\nfrom vibecraft.llm.errors import LLMError\nfrom vibecraft.llm.provider import ProviderResponse\n\nif TYPE_CHECKING:\n from anthropic import AsyncAnthropic\n from anthropic.types import Message\n\n\nclass AnthropicProvider:\n \"\"\"Anthropic Claude 异步 provider,实现 `LLMProvider` Protocol。\n\n 构造方式:\n - 直接构造:``AnthropicProvider(api_key=\"sk-...\")``\n - 从配置工厂:``LLMConfig.from_yaml(path).build_provider()``\n\n API key 优先级:\n 1. 显式传入 ``api_key`` 参数\n 2. SDK 自动读 ``ANTHROPIC_API_KEY`` 环境变量(不传 key 时)\n\n secret **不进 git** —— yaml / 代码里不要写 key 字面值。\n \"\"\"\n\n name: str\n\n def __init__(\n self,\n api_key: str | None = None,\n model: str = \"claude-sonnet-4-6\",\n max_tokens: int = 1024,\n use_prompt_cache: bool = True,\n base_url: str | None = None,\n provider_name: str = \"anthropic\",\n disable_thinking: bool = False,\n ) -> None:\n \"\"\"\n Args:\n api_key: API key;为 None 时 SDK 自己读环境变量。\n model: 模型 id。\n max_tokens: 单次调用最大输出 token 数。\n use_prompt_cache: 是否给静态段(system / few-shot)打 cache_control。\n base_url: API 端点;None 走 anthropic SDK 默认(官方 Anthropic)。\n 指向 DeepSeek 的 Anthropic 兼容端点时传\n `https://api.deepseek.com/anthropic`。\n provider_name: provider 标识,写进 ProviderResponse.provider + 日志。\n 走 DeepSeek 兼容端点时传 \"deepseek\"。\n disable_thinking: 是否显式禁用思考模式。DeepSeek v4 在 Anthropic\n 兼容端点默认走思考模式(= deepseek-reasoner),思考模式不兼容\n `tool_choice` 强制指定 tool。IntentParser 是结构化输出任务、\n 不需要推理,DeepSeek 走该端点时应置 True(见 ADR 0005)。\n \"\"\"\n try:\n from anthropic import AsyncAnthropic\n except ImportError as e:\n raise LLMError(\"未安装 anthropic SDK:`uv add anthropic`\") from e\n\n # base_url=None 时不传给 SDK,走官方默认端点;\n # api_key=None 时 AsyncAnthropic 自己读环境变量\n client_kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n client_kwargs[\"base_url\"] = base_url\n self.client: AsyncAnthropic = AsyncAnthropic(**client_kwargs)\n self.model = model\n self.max_tokens = max_tokens\n self.use_prompt_cache = use_prompt_cache\n self.base_url = base_url\n self.name = provider_name\n self.disable_thinking = disable_thinking\n\n async def parse(\n self,\n *,\n system: str,\n few_shot: str,\n dynamic_context: str,\n user_text: str,\n tool_schema: dict[str, Any],\n timeout_s: float,\n ) -> ProviderResponse:\n \"\"\"调用 Claude API,强制走 tool_use 输出(§7.2 / §7.3)。\n\n Prompt 缓存策略(§7.3):\n - system 段(含 strategy catalog):打 cache_control=ephemeral\n - few_shot 段:打 cache_control=ephemeral\n - dynamic_context + user_text:不缓存(每次新)\n\n 三段静态内容总约 4-5K tokens;Anthropic 的 ephemeral 缓存有效期 5 分钟。\n 实际游戏节奏下两次 parse 间隔 << 5 分钟,缓存命中率极高。\n\n Args:\n system: 第 1-2 段合并(system prompt + strategy catalog),静态缓存。\n few_shot: 第 3 段 few-shot 示例,静态缓存。\n dynamic_context: 第 4 段动态 context(游戏时间 / 资源 / 战况等)。\n user_text: 玩家本条话语。\n tool_schema: `emit_directives` tool 的 JSON Schema。\n timeout_s: 给 SDK 的 HTTP 超时(同时外层有 `asyncio.wait_for`)。\n\n Returns:\n `ProviderResponse`,`raw` 已经是 `dict`(tool_use.input)。\n\n Raises:\n LLMError: Claude 未返回 tool_use block(不应发生,保险起见)。\n anthropic.APITimeoutError: SDK 层超时(上层 `asyncio.wait_for` 也会触发 `asyncio.TimeoutError`)。\n anthropic.RateLimitError: 429 限频。\n anthropic.APIError: 其他 4xx/5xx。\n \"\"\"\n # ---- 构造 system blocks(前 3 段拼成 system;few_shot 进 messages) ----\n system_block: dict[str, Any] = {\"type\": \"text\", \"text\": system}\n if self.use_prompt_cache:\n system_block[\"cache_control\"] = {\"type\": \"ephemeral\"}\n\n few_shot_block: dict[str, Any] = {\"type\": \"text\", \"text\": few_shot}\n if self.use_prompt_cache:\n few_shot_block[\"cache_control\"] = {\"type\": \"ephemeral\"}\n\n # messages:few_shot / dynamic_context / user_text 三块放 user turn\n messages: list[dict[str, Any]] = [\n {\n \"role\": \"user\",\n \"content\": [\n few_shot_block,\n {\"type\": \"text\", \"text\": dynamic_context},\n {\"type\": \"text\", \"text\": f\"\\n玩家话语:{user_text}\\n\"},\n ],\n }\n ]\n\n t0 = time.monotonic()\n\n # 用 dict 收集 kwargs:system/messages/tools 用 dict 字面值(与 SDK 的\n # TypedDict 结构等价),**dict[str, Any] 解包让 mypy 跳过 overload 检查。\n create_kwargs: dict[str, Any] = {\n \"model\": self.model,\n \"max_tokens\": self.max_tokens,\n \"system\": [system_block],\n \"messages\": messages,\n \"tools\": [tool_schema],\n \"tool_choice\": {\"type\": \"tool\", \"name\": tool_schema[\"name\"]},\n \"timeout\": timeout_s,\n }\n # DeepSeek v4 在 Anthropic 兼容端点默认走思考模式,思考模式不兼容\n # tool_choice 强制指定 tool —— 显式禁用(见 ADR 0005)。\n if self.disable_thinking:\n create_kwargs[\"thinking\"] = {\"type\": \"disabled\"}\n msg: Message = await self.client.messages.create(**create_kwargs)\n latency_ms = (time.monotonic() - t0) * 1000\n\n # ---- 解析 tool_use block(强制 tool_choice 所以正常只有这一种) ----\n raw_obj: dict[str, Any] | None = None\n raw_text = \"\"\n for block in msg.content:\n btype = getattr(block, \"type\", None)\n if btype == \"tool_use\":\n tool_input = getattr(block, \"input\", None)\n if isinstance(tool_input, dict):\n raw_obj = tool_input\n raw_text = json.dumps(tool_input, ensure_ascii=False)\n break\n if btype == \"text\":\n raw_text += getattr(block, \"text\", \"\")\n\n if raw_obj is None:\n raise LLMError(\n f\"Claude 未走 tool_use 输出(content blocks: \"\n f\"{[getattr(b, 'type', '?') for b in msg.content]})\"\n )\n\n # ---- 构造返回值,把 token / cache 信息填进 extra(供日志层使用) ----\n usage = msg.usage\n cache_read = getattr(usage, \"cache_read_input_tokens\", None) or 0\n cache_write = getattr(usage, \"cache_creation_input_tokens\", None) or 0\n\n return ProviderResponse(\n raw=raw_obj,\n raw_text=raw_text,\n input_tokens=getattr(usage, \"input_tokens\", 0),\n output_tokens=getattr(usage, \"output_tokens\", 0),\n cache_hit=cache_read > 0,\n latency_ms=latency_ms,\n model=self.model,\n provider=self.name,\n extra={\n \"stop_reason\": msg.stop_reason,\n \"cache_read_input_tokens\": cache_read,\n \"cache_creation_input_tokens\": cache_write,\n },\n )\n"}, "50315a3afca88705b29390c22aebdcd6e3d4f3f7": {"t": 1785758570.1445029, "n": 7803, "h": "309551c89424f080", "c": "\"\"\"AnthropicProvider —— 通过 anthropic SDK 调用 Claude 或 Anthropic 兼容端点。\n\n仅在 production 路径用,单测全部 mock(不调真实 API)。\n本文件 import anthropic SDK,但 LLMProvider Protocol 已在 provider.py\n独立定义,import 失败不影响其他模块。\n\n模型默认 `claude-sonnet-4-6`(设计文档 §7.4),可通过 LLMConfig 切换。\n`base_url` 指向 DeepSeek 的 Anthropic 兼容端点(`https://api.deepseek.com/anthropic`)\n时,同一份代码即可调 DeepSeek V4(见 ADR 0005)。\n\n错误处理策略(设计文档 §7.6):\n- `anthropic.APITimeoutError` / `asyncio.TimeoutError` → 由上层 `asyncio.wait_for` 或\n SDK 内部超时触发,统一被 `IntentParser` 包裹成 `ParseError(kind=TIMEOUT)`。\n- `anthropic.RateLimitError` / 其他 `anthropic.APIError` → 抛出,\n 由 `IntentParser` 的 `except Exception` 包裹成 `ParseError(kind=PROVIDER_ERROR)`。\n- `LLMError`(tool_use 缺失)→ 同上,走 `PROVIDER_ERROR`。\n\n**关键不变量**:AnthropicProvider 本身不吞掉任何异常。所有错误都向上抛,\n由 IntentParser 统一转成 ParseError。\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport time\nfrom typing import TYPE_CHECKING, Any\n\nfrom vibecraft.llm.errors import LLMError\nfrom vibecraft.llm.provider import ProviderResponse\n\nif TYPE_CHECKING:\n from anthropic import AsyncAnthropic\n from anthropic.types import Message\n\n\nclass AnthropicProvider:\n \"\"\"Anthropic Claude 异步 provider,实现 `LLMProvider` Protocol。\n\n 构造方式:\n - 直接构造:``AnthropicProvider(api_key=\"sk-...\")``\n - 从配置工厂:``LLMConfig.from_yaml(path).build_provider()``\n\n API key 优先级:\n 1. 显式传入 ``api_key`` 参数\n 2. SDK 自动读 ``ANTHROPIC_API_KEY`` 环境变量(不传 key 时)\n\n secret **不进 git** —— yaml / 代码里不要写 key 字面值。\n \"\"\"\n\n name: str\n\n def __init__(\n self,\n api_key: str | None = None,\n model: str = \"claude-sonnet-4-6\",\n max_tokens: int = 1024,\n use_prompt_cache: bool = True,\n base_url: str | None = None,\n provider_name: str = \"anthropic\",\n disable_thinking: bool = False,\n ) -> None:\n \"\"\"\n Args:\n api_key: API key;为 None 时 SDK 自己读环境变量。\n model: 模型 id。\n max_tokens: 单次调用最大输出 token 数。\n use_prompt_cache: 是否给静态段(system / few-shot)打 cache_control。\n base_url: API 端点;None 走 anthropic SDK 默认(官方 Anthropic)。\n 指向 DeepSeek 的 Anthropic 兼容端点时传\n `https://api.deepseek.com/anthropic`。\n provider_name: provider 标识,写进 ProviderResponse.provider + 日志。\n 走 DeepSeek 兼容端点时传 \"deepseek\"。\n disable_thinking: 是否显式禁用思考模式。DeepSeek v4 在 Anthropic\n 兼容端点默认走思考模式(= deepseek-reasoner),思考模式不兼容\n `tool_choice` 强制指定 tool。IntentParser 是结构化输出任务、\n 不需要推理,DeepSeek 走该端点时应置 True(见 ADR 0005)。\n \"\"\"\n try:\n from anthropic import AsyncAnthropic\n except ImportError as e:\n raise LLMError(\"未安装 anthropic SDK:`uv add anthropic`\") from e\n\n # base_url=None 时不传给 SDK,走官方默认端点;\n # api_key=None 时 AsyncAnthropic 自己读环境变量\n client_kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n client_kwargs[\"base_url\"] = base_url\n self.client: AsyncAnthropic = AsyncAnthropic(**client_kwargs)\n self.model = model\n self.max_tokens = max_tokens\n self.use_prompt_cache = use_prompt_cache\n self.base_url = base_url\n self.name = provider_name\n self.disable_thinking = disable_thinking\n\n async def parse(\n self,\n *,\n system: str,\n few_shot: str,\n dynamic_context: str,\n user_text: str,\n tool_schema: dict[str, Any],\n timeout_s: float,\n ) -> ProviderResponse:\n \"\"\"调用 Claude API,强制走 tool_use 输出(§7.2 / §7.3)。\n\n Prompt 缓存策略(§7.3):\n - system 段(含 strategy catalog):打 cache_control=ephemeral\n - few_shot 段:打 cache_control=ephemeral\n - dynamic_context + user_text:不缓存(每次新)\n\n 三段静态内容总约 4-5K tokens;Anthropic 的 ephemeral 缓存有效期 5 分钟。\n 实际游戏节奏下两次 parse 间隔 << 5 分钟,缓存命中率极高。\n\n Args:\n system: 第 1-2 段合并(system prompt + strategy catalog),静态缓存。\n few_shot: 第 3 段 few-shot 示例,静态缓存。\n dynamic_context: 第 4 段动态 context(游戏时间 / 资源 / 战况等)。\n user_text: 玩家本条话语。\n tool_schema: `emit_directives` tool 的 JSON Schema。\n timeout_s: 给 SDK 的 HTTP 超时(同时外层有 `asyncio.wait_for`)。\n\n Returns:\n `ProviderResponse`,`raw` 已经是 `dict`(tool_use.input)。\n\n Raises:\n LLMError: Claude 未返回 tool_use block(不应发生,保险起见)。\n anthropic.APITimeoutError: SDK 层超时(上层 `asyncio.wait_for` 也会触发 `asyncio.TimeoutError`)。\n anthropic.RateLimitError: 429 限频。\n anthropic.APIError: 其他 4xx/5xx。\n \"\"\"\n # ---- 构造 system blocks(前 3 段拼成 system;few_shot 进 messages) ----\n system_block: dict[str, Any] = {\"type\": \"text\", \"text\": system}\n if self.use_prompt_cache:\n system_block[\"cache_control\"] = {\"type\": \"ephemeral\"}\n\n few_shot_block: dict[str, Any] = {\"type\": \"text\", \"text\": few_shot}\n if self.use_prompt_cache:\n few_shot_block[\"cache_control\"] = {\"type\": \"ephemeral\"}\n\n # messages:few_shot / dynamic_context / user_text 三块放 user turn\n messages: list[dict[str, Any]] = [\n {\n \"role\": \"user\",\n \"content\": [\n few_shot_block,\n {\"type\": \"text\", \"text\": dynamic_context},\n {\"type\": \"text\", \"text\": f\"\\n玩家话语:{user_text}\\n\"},\n ],\n }\n ]\n\n t0 = time.monotonic()\n\n # 用 dict 收集 kwargs:system/messages/tools 用 dict 字面值(与 SDK 的\n # TypedDict 结构等价),**dict[str, Any] 解包让 mypy 跳过 overload 检查。\n create_kwargs: dict[str, Any] = {\n \"model\": self.model,\n \"max_tokens\": self.max_tokens,\n \"system\": [system_block],\n \"messages\": messages,\n \"tools\": [tool_schema],\n \"tool_choice\": {\"type\": \"tool\", \"name\": tool_schema[\"name\"]},\n \"timeout\": timeout_s,\n }\n # DeepSeek v4 在 Anthropic 兼容端点默认走思考模式,思考模式不兼容\n # tool_choice 强制指定 tool —— 显式禁用(见 ADR 0005)。\n if self.disable_thinking:\n create_kwargs[\"thinking\"] = {\"type\": \"disabled\"}\n msg: Message = await self.client.messages.create(**create_kwargs)\n latency_ms = (time.monotonic() - t0) * 1000\n\n # ---- 解析 tool_use block(强制 tool_choice 所以正常只有这一种) ----\n raw_obj: dict[str, Any] | None = None\n raw_text = \"\"\n for block in msg.content:\n btype = getattr(block, \"type\", None)\n if btype == \"tool_use\":\n tool_input = getattr(block, \"input\", None)\n if isinstance(tool_input, dict):\n raw_obj = tool_input\n raw_text = json.dumps(tool_input, ensure_ascii=False)\n break\n if btype == \"text\":\n raw_text += getattr(block, \"text\", \"\")\n\n if raw_obj is None:\n raise LLMError(\n f\"Claude 未走 tool_use 输出(content blocks: \"\n f\"{[getattr(b, 'type', '?') for b in msg.content]})\"\n )\n\n # ---- 构造返回值,把 token / cache 信息填进 extra(供日志层使用) ----\n usage = msg.usage\n cache_read = getattr(usage, \"cache_read_input_tokens\", None) or 0\n cache_write = getattr(usage, \"cache_creation_input_tokens\", None) or 0\n\n return ProviderResponse(\n raw=raw_obj,\n raw_text=raw_text,\n input_tokens=getattr(usage, \"input_tokens\", 0),\n output_tokens=getattr(usage, \"output_tokens\", 0),\n cache_hit=cache_read > 0,\n latency_ms=latency_ms,\n model=self.model,\n provider=self.name,\n extra={\n \"stop_reason\": msg.stop_reason,\n \"cache_read_input_tokens\": cache_read,\n \"cache_creation_input_tokens\": cache_write,\n },\n )\n"}}}}