1 line
19 KiB
JSON
1 line
19 KiB
JSON
{"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"}}}} |