diff --git a/client/README.md b/client/README.md index 7f5be618..22d19909 100644 --- a/client/README.md +++ b/client/README.md @@ -164,10 +164,10 @@ export OPENROUTER_API_KEY=sk-or-... ### `llm` 字段说明 -- `adapter`:模型适配器,当前支持 `openai` 和 `openrouter`。不写时默认是 `openai`。 +- `adapter`:模型适配器,当前支持 `openai`、`openrouter`、`anthropic`。不写时默认是 `openai`。 - `model`:模型名,例如 `gpt-4o-mini`、`gpt-4.1`、`qwen/qwen3-coder:free`。 - `api_key`:模型服务密钥。也可以通过环境变量提供。 -- `endpoint`:自定义 OpenAI-compatible base URL。对 `openrouter` 来说会作为 `base_url` 使用。 +- `endpoint`:自定义 provider base URL。对 `openrouter`/`anthropic` 来说分别映射到各自 SDK 的 `base_url`。 - `proxy`:代理配置,支持 `http`、`https`、`no_proxy`、`use_system_proxy`、`disabled`。 - 其他未显式声明的字段会进入 `llm.extra`,并透传给 adapter;例如可以直接写 `temperature`、`max_tokens`。 @@ -258,6 +258,29 @@ export OPENROUTER_MODEL=qwen/qwen3-coder:free export OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 ``` +Anthropic: + +```json +{ + "llm": { + "adapter": "anthropic", + "model": "claude-sonnet-4-5" + } +} +``` + +配合环境变量: + +```bash +export ANTHROPIC_API_KEY=sk-ant-... +# 建议显式写完整模型名;CLI 会直接透传到 Anthropic SDK +export ANTHROPIC_MODEL=claude-sonnet-4-5 +``` + +仓库示例文件: + +- `client/examples/config.anthropic.example.json` + OpenAI-compatible / 自建模型网关: ```json @@ -388,6 +411,7 @@ OpenAI-compatible / 自建模型网关: - `openai` adapter:需要 `langchain-openai` - `openrouter` adapter:需要 `openai` +- `anthropic` adapter:需要 `anthropic` 否则 `doctor` 会提示 adapter probe 或依赖缺失,runtime 也无法正常启动。 diff --git a/client/commands/info.py b/client/commands/info.py index e1f5e478..9f8de17c 100644 --- a/client/commands/info.py +++ b/client/commands/info.py @@ -52,6 +52,7 @@ def build_doctor_report( api_key_sources = { "openrouter": bool(config.llm.api_key or os.getenv("OPENROUTER_API_KEY")), "openai": bool(config.llm.api_key or os.getenv("OPENAI_API_KEY")), + "anthropic": bool(config.llm.api_key or os.getenv("ANTHROPIC_API_KEY")), } workspace = Path(config.workspace_dir).expanduser().resolve() user_dir = Path(config.user_dir).expanduser().resolve() @@ -72,6 +73,7 @@ def build_doctor_report( "httpx_installed": importlib.util.find_spec("httpx") is not None, "openai_sdk_installed": importlib.util.find_spec("openai") is not None, "langchain_openai_installed": importlib.util.find_spec("langchain_openai") is not None, + "anthropic_sdk_installed": importlib.util.find_spec("anthropic") is not None, } diagnostics: dict[str, Any] = { @@ -95,7 +97,7 @@ def build_doctor_report( warnings: list[str] = [] if not diagnostics["workspace_exists"]: warnings.append("workspace_dir does not exist") - if adapter not in {"openai", "openrouter"}: + if adapter not in {"openai", "openrouter", "anthropic"}: warnings.append(f"unsupported adapter configured: {adapter}") if not diagnostics["llm"]["api_key_present"]: warnings.append(f"missing API key for adapter={adapter}") @@ -111,6 +113,8 @@ def build_doctor_report( warnings.append("openai SDK is required for openrouter adapter") if adapter == "openai" and not deps["langchain_openai_installed"]: warnings.append("langchain-openai is required for openai adapter") + if adapter == "anthropic" and not deps["anthropic_sdk_installed"]: + warnings.append("anthropic SDK is required for anthropic adapter") diagnostics["warnings"] = warnings diagnostics["ok"] = len(warnings) == 0 return diagnostics diff --git a/client/examples/config.anthropic.example.json b/client/examples/config.anthropic.example.json new file mode 100644 index 00000000..62ca1f8a --- /dev/null +++ b/client/examples/config.anthropic.example.json @@ -0,0 +1,6 @@ +{ + "llm": { + "adapter": "anthropic", + "model": "claude-sonnet-4-5" + } +} diff --git a/client/main.py b/client/main.py index 84f3a59b..c1643f00 100644 --- a/client/main.py +++ b/client/main.py @@ -1532,7 +1532,7 @@ def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="DARE external CLI") parser.add_argument("--workspace", default=str(Path.cwd()), help="workspace root path") parser.add_argument("--user-dir", default=str(Path.home()), help="user directory path") - parser.add_argument("--adapter", default=None, help="llm adapter override (openai/openrouter)") + parser.add_argument("--adapter", default=None, help="llm adapter override (openai/openrouter/anthropic)") parser.add_argument("--model", default=None, help="llm model override") parser.add_argument("--api-key", default=None, help="llm api key override") parser.add_argument("--endpoint", default=None, help="llm endpoint override") diff --git a/dare_framework/model/__init__.py b/dare_framework/model/__init__.py index 59a6d201..45e2c1da 100644 --- a/dare_framework/model/__init__.py +++ b/dare_framework/model/__init__.py @@ -9,7 +9,7 @@ from dare_framework.model.builtin_prompt_loader import BuiltInPromptLoader from dare_framework.model.filesystem_prompt_loader import FileSystemPromptLoader from dare_framework.model.layered_prompt_store import LayeredPromptStore -from dare_framework.model.adapters import * +from dare_framework.model.adapters import AnthropicModelAdapter, OpenAIModelAdapter, OpenRouterModelAdapter __all__ = [ "IModelAdapter", @@ -24,6 +24,7 @@ "BuiltInPromptLoader", "FileSystemPromptLoader", "LayeredPromptStore", + "AnthropicModelAdapter", "OpenAIModelAdapter", "OpenRouterModelAdapter", ] diff --git a/dare_framework/model/adapters/__init__.py b/dare_framework/model/adapters/__init__.py index b7bfd029..e0662803 100644 --- a/dare_framework/model/adapters/__init__.py +++ b/dare_framework/model/adapters/__init__.py @@ -1,6 +1,7 @@ """Model adapters.""" +from dare_framework.model.adapters.anthropic_adapter import AnthropicModelAdapter from dare_framework.model.adapters.openai_adapter import OpenAIModelAdapter from dare_framework.model.adapters.openrouter_adapter import OpenRouterModelAdapter -__all__ = ["OpenAIModelAdapter", "OpenRouterModelAdapter"] +__all__ = ["AnthropicModelAdapter", "OpenAIModelAdapter", "OpenRouterModelAdapter"] diff --git a/dare_framework/model/adapters/anthropic_adapter.py b/dare_framework/model/adapters/anthropic_adapter.py new file mode 100644 index 00000000..94bf8c86 --- /dev/null +++ b/dare_framework/model/adapters/anthropic_adapter.py @@ -0,0 +1,395 @@ +"""Anthropic model adapter using the official anthropic SDK.""" + +from __future__ import annotations + +import json +import os +from typing import Any + +from dare_framework.model.kernel import IModelAdapter +from dare_framework.model.types import GenerateOptions, ModelInput, ModelResponse + + +class AnthropicModelAdapter(IModelAdapter): + """Model adapter for Anthropic Messages API.""" + + def __init__( + self, + *, + name: str | None = None, + api_key: str | None = None, + model: str | None = None, + base_url: str | None = None, + http_client_options: dict[str, Any] | None = None, + extra: dict[str, Any] | None = None, + ) -> None: + self._name = name or "anthropic" + self._api_key = api_key or os.getenv("ANTHROPIC_API_KEY") + self._model = _resolve_model_name(model=model, env_model=os.getenv("ANTHROPIC_MODEL")) + self._base_url = base_url or os.getenv("ANTHROPIC_BASE_URL") + self._http_client_options = dict(http_client_options or {}) + self._extra = dict(extra or {}) + self._client: Any = None + + if not self._api_key: + raise ValueError("Anthropic API key is required. Set ANTHROPIC_API_KEY environment variable.") + + @property + def name(self) -> str: + return self._name + + @property + def model(self) -> str: + return self._model + + @property + def model_name(self) -> str: + return self._model + + async def generate( + self, + model_input: ModelInput, + *, + options: GenerateOptions | None = None, + ) -> ModelResponse: + client = self._ensure_client() + system_prompt, messages = _serialize_system_and_messages(model_input.messages) + + params: dict[str, Any] = { + "model": self._model, + "messages": messages, + "max_tokens": _resolve_max_tokens(options=options, extra=self._extra), + } + if system_prompt: + params["system"] = system_prompt + if model_input.tools: + params["tools"] = [ + { + "name": tool.name, + "description": tool.description, + "input_schema": tool.input_schema, + } + for tool in model_input.tools + ] + + if self._extra: + extra_params = dict(self._extra) + # Keep normalized max_tokens from _resolve_max_tokens/options; raw extra values + # like None/"128" should not override it after merge. + extra_params.pop("max_tokens", None) + params.update(extra_params) + if options is not None: + if options.temperature is not None: + params["temperature"] = options.temperature + if options.max_tokens is not None: + params["max_tokens"] = options.max_tokens + if options.top_p is not None: + params["top_p"] = options.top_p + if options.stop is not None: + params["stop_sequences"] = options.stop + + response = await client.messages.create(**params) + content_blocks = list(getattr(response, "content", []) or []) + + return ModelResponse( + content=_extract_response_text(content_blocks), + tool_calls=_extract_tool_calls(content_blocks), + usage=_extract_usage(getattr(response, "usage", None)), + thinking_content=_extract_thinking_content(content_blocks), + metadata={ + "model": self._model, + "stop_reason": getattr(response, "stop_reason", None), + }, + ) + + def _ensure_client(self) -> Any: + if self._client is None: + self._client = self._build_client() + return self._client + + def _build_client(self) -> Any: + try: + from anthropic import AsyncAnthropic + except ImportError as exc: + raise ImportError( + "anthropic SDK is required for AnthropicModelAdapter. Install with: pip install anthropic" + ) from exc + + client_kwargs: dict[str, Any] = {"api_key": self._api_key} + if self._base_url: + client_kwargs["base_url"] = self._base_url + + http_client = _build_async_http_client(self._http_client_options) + if http_client is not None: + client_kwargs["http_client"] = http_client + + return AsyncAnthropic(**client_kwargs) + + +def _resolve_model_name(*, model: str | None, env_model: str | None) -> str: + selected = model or env_model + if not selected or not selected.strip(): + raise ValueError( + "Anthropic model is required. Set Config.llm.model or ANTHROPIC_MODEL environment variable." + ) + return selected.strip() + + +def _resolve_max_tokens(*, options: GenerateOptions | None, extra: dict[str, Any]) -> int: + if options is not None and options.max_tokens is not None: + return int(options.max_tokens) + extra_value = extra.get("max_tokens") + if extra_value is not None: + return int(extra_value) + env_value = os.getenv("ANTHROPIC_MAX_TOKENS") + if env_value is not None: + try: + return int(env_value) + except ValueError: + pass + return 2048 + + +def _serialize_system_and_messages(messages: list[Any]) -> tuple[str | None, list[dict[str, Any]]]: + system_parts: list[str] = [] + payload: list[dict[str, Any]] = [] + + for msg in messages: + role = str(getattr(msg, "role", "user")) + content = str(getattr(msg, "content", "")) + if role == "system": + text = content.strip() + if text: + system_parts.append(text) + continue + + if role == "assistant": + message_content: list[dict[str, Any]] = [] + if content: + message_content.append({"type": "text", "text": content}) + tool_calls = _normalize_tool_calls(getattr(msg, "metadata", {}).get("tool_calls", [])) + message_content.extend(tool_calls) + if not message_content: + message_content.append({"type": "text", "text": ""}) + payload.append({"role": "assistant", "content": message_content}) + continue + + if role == "tool": + tool_call_id = getattr(msg, "name", None) or "tool_call" + payload.append( + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": tool_call_id, + "content": content, + } + ], + } + ) + continue + + payload.append({"role": "user", "content": content}) + + system_prompt = "\n\n".join(system_parts) if system_parts else None + return system_prompt, payload + + +def _normalize_tool_calls(tool_calls: Any) -> list[dict[str, Any]]: + if not isinstance(tool_calls, list): + return [] + + normalized: list[dict[str, Any]] = [] + for index, call in enumerate(tool_calls): + if not isinstance(call, dict): + continue + call_id = call.get("id") or call.get("tool_call_id") or f"toolu_{index + 1}" + name = call.get("name") + if not isinstance(name, str) or not name.strip(): + continue + arguments = call.get("arguments", call.get("args", {})) + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except json.JSONDecodeError: + arguments = {"raw": arguments} + if not isinstance(arguments, dict): + arguments = {} + normalized.append( + { + "type": "tool_use", + "id": str(call_id), + "name": name, + "input": arguments, + } + ) + return normalized + + +def _extract_response_text(content_blocks: list[Any]) -> str: + parts: list[str] = [] + for block in content_blocks: + if _block_type(block) != "text": + continue + text = _coerce_text(_block_value(block, "text")) + if text: + parts.append(text) + return "\n".join(parts) + + +def _extract_thinking_content(content_blocks: list[Any]) -> str | None: + parts: list[str] = [] + for block in content_blocks: + if _block_type(block) not in {"thinking", "reasoning"}: + continue + text = ( + _coerce_text(_block_value(block, "thinking")) + or _coerce_text(_block_value(block, "reasoning")) + or _coerce_text(_block_value(block, "text")) + or _coerce_text(_block_value(block, "content")) + ) + if text: + parts.append(text) + if not parts: + return None + return "\n".join(parts) + + +def _extract_tool_calls(content_blocks: list[Any]) -> list[dict[str, Any]]: + calls: list[dict[str, Any]] = [] + for block in content_blocks: + if _block_type(block) != "tool_use": + continue + call_id = _block_value(block, "id") + name = _block_value(block, "name") + if not isinstance(name, str) or not name.strip(): + continue + arguments = _block_value(block, "input") + if not isinstance(arguments, dict): + arguments = {} + calls.append( + { + "id": str(call_id) if call_id is not None else None, + "name": name, + "arguments": arguments, + } + ) + return calls + + +def _extract_usage(usage: Any) -> dict[str, Any] | None: + if usage is None: + return None + + prompt_tokens = _to_int(_get_nested_value(usage, "input_tokens")) + completion_tokens = _to_int(_get_nested_value(usage, "output_tokens")) + total_tokens = ( + prompt_tokens + completion_tokens + if prompt_tokens is not None and completion_tokens is not None + else _to_int(_get_nested_value(usage, "total_tokens")) + ) + + payload: dict[str, Any] = {} + if prompt_tokens is not None: + payload["prompt_tokens"] = prompt_tokens + if completion_tokens is not None: + payload["completion_tokens"] = completion_tokens + if total_tokens is not None: + payload["total_tokens"] = total_tokens + + reasoning_tokens = _extract_reasoning_tokens(usage) + if reasoning_tokens is not None: + payload["reasoning_tokens"] = reasoning_tokens + + if not payload: + return None + return payload + + +def _extract_reasoning_tokens(usage: Any) -> int | None: + candidates = [ + _get_nested_value(usage, "reasoning_tokens"), + _get_nested_value(_get_nested_value(usage, "output_tokens_details"), "reasoning_tokens"), + _get_nested_value(_get_nested_value(usage, "output_tokens_details"), "thinking_tokens"), + _get_nested_value(_get_nested_value(usage, "completion_tokens_details"), "reasoning_tokens"), + ] + for candidate in candidates: + value = _to_int(candidate) + if value is not None: + return value + return None + + +def _block_type(block: Any) -> str | None: + if isinstance(block, dict): + value = block.get("type") + else: + value = getattr(block, "type", None) + return str(value) if value is not None else None + + +def _block_value(block: Any, key: str) -> Any: + if isinstance(block, dict): + return block.get(key) + return getattr(block, key, None) + + +def _get_nested_value(value: Any, key: str) -> Any: + if isinstance(value, dict): + return value.get(key) + return getattr(value, key, None) + + +def _to_int(value: Any) -> int | None: + try: + if value is None: + return None + return int(value) + except (TypeError, ValueError): + return None + + +def _coerce_text(value: Any) -> str | None: + if isinstance(value, str): + text = value.strip() + return text or None + if isinstance(value, dict): + for key in ("text", "content", "thinking", "reasoning"): + text = _coerce_text(value.get(key)) + if text: + return text + return None + if isinstance(value, list): + parts: list[str] = [] + for item in value: + text = _coerce_text(item) + if text: + parts.append(text) + if parts: + return "\n".join(parts) + return None + + +def _build_async_http_client(options: dict[str, Any]) -> Any | None: + if not options: + return None + try: + import httpx + except Exception: + return None + try: + return httpx.AsyncClient(**options) + except Exception: + return None + + +__all__ = [ + "AnthropicModelAdapter", + "_extract_response_text", + "_extract_thinking_content", + "_extract_tool_calls", + "_extract_usage", + "_resolve_model_name", + "_serialize_system_and_messages", +] diff --git a/dare_framework/model/default_model_adapter_manager.py b/dare_framework/model/default_model_adapter_manager.py index 247417ee..71f2b09b 100644 --- a/dare_framework/model/default_model_adapter_manager.py +++ b/dare_framework/model/default_model_adapter_manager.py @@ -7,6 +7,7 @@ from dare_framework.config.types import Config, LLMConfig from dare_framework.model.interfaces import IModelAdapterManager from dare_framework.model.kernel import IModelAdapter +from dare_framework.model.adapters.anthropic_adapter import AnthropicModelAdapter from dare_framework.model.adapters.openai_adapter import OpenAIModelAdapter from dare_framework.model.adapters.openrouter_adapter import OpenRouterModelAdapter @@ -27,8 +28,10 @@ def load_model_adapter(self, *, config: Config | None = None) -> IModelAdapter | return _build_openai_adapter(llm) if adapter_name == "openrouter": return _build_openrouter_adapter(llm) + if adapter_name == "anthropic": + return _build_anthropic_adapter(llm) raise ValueError( - f"Unsupported model adapter '{adapter_name}'. Supported adapters: openai, openrouter." + f"Unsupported model adapter '{adapter_name}'. Supported adapters: openai, openrouter, anthropic." ) @@ -61,6 +64,17 @@ def _build_openrouter_adapter(llm: LLMConfig) -> OpenRouterModelAdapter: ) +def _build_anthropic_adapter(llm: LLMConfig) -> AnthropicModelAdapter: + return AnthropicModelAdapter( + name="anthropic", + api_key=llm.api_key, + model=llm.model, + base_url=llm.endpoint, + http_client_options=_http_client_options_from_proxy(llm), + extra=dict(llm.extra), + ) + + def _http_client_options_from_proxy(llm: LLMConfig) -> dict[str, Any]: proxy = llm.proxy options: dict[str, Any] = {} diff --git a/docs/design/DARE_Formal_Design.md b/docs/design/DARE_Formal_Design.md index 1e40ca6f..69be4b96 100644 --- a/docs/design/DARE_Formal_Design.md +++ b/docs/design/DARE_Formal_Design.md @@ -383,7 +383,7 @@ flowchart LR - **职责**:统一模型调用入口 + Prompt 管理。 - **关键类型**:`ModelInput` / `ModelResponse` / `Prompt`。 - **核心接口**:`IModelAdapter` / `IModelAdapterManager` / `IPromptStore`。 -- **默认实现**:OpenAI/OpenRouter 适配器 + LayeredPromptStore。 +- **默认实现**:OpenAI/OpenRouter/Anthropic 适配器 + LayeredPromptStore。 - **扩展点**:多模型路由、流式输出、Prompt Loader。 - **现状限制**:无流式输出与多阶段 prompt pack。 diff --git a/docs/design/modules/model/README.md b/docs/design/modules/model/README.md index babf0707..79889b55 100644 --- a/docs/design/modules/model/README.md +++ b/docs/design/modules/model/README.md @@ -1,6 +1,6 @@ # Module: model -> Status: detailed design aligned to `dare_framework/model` (2026-02-25). +> Status: detailed design aligned to `dare_framework/model` (2026-03-05). ## 1. 定位与职责 @@ -58,6 +58,12 @@ flowchart TD - **Config**:`Config.llm` 决定 adapter 类型与连接参数。 - **Observability**:从 `usage` 提取 token 指标。 +## 6.1 默认 Adapter 能力矩阵 + +- `openai`: 基于 `langchain-openai`,适配 OpenAI-compatible Chat 接口。 +- `openrouter`: 基于 `openai` SDK,适配 OpenRouter OpenAI-compatible 接口。 +- `anthropic`: 基于 `anthropic` 官方 SDK,适配 Anthropic Messages API(模型名透传 + tool blocks)。 + ## 7. 约束与限制 - 当前流式输出和增量 tool-call 仍是待补齐项。 @@ -91,3 +97,4 @@ flowchart TD - `tests/unit/test_default_model_adapter_manager.py`(模型适配器管理与选择) - `tests/unit/test_openrouter_adapter.py`(adapter 消息序列化与 tool-call 兼容) +- `tests/unit/test_anthropic_model_adapter.py`(Anthropic adapter 请求/响应规范化) diff --git a/docs/features/README.md b/docs/features/README.md index e39e12c7..71dfc904 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -37,6 +37,7 @@ ## Active Entries +- `docs/features/add-anthropic-model-adapter.md` - `docs/features/agentscope-d2-d4-thinking-transport.md` - `docs/features/agentscope-d5-safe-compression.md` - `docs/features/agentscope-d7-plan-state-tools.md` diff --git a/docs/features/add-anthropic-model-adapter.md b/docs/features/add-anthropic-model-adapter.md new file mode 100644 index 00000000..7793f6a6 --- /dev/null +++ b/docs/features/add-anthropic-model-adapter.md @@ -0,0 +1,89 @@ +--- +change_ids: ["add-anthropic-model-adapter"] +doc_kind: feature +topics: ["model", "anthropic", "adapter", "cli-doctor"] +created: 2026-03-05 +updated: 2026-03-05 +status: active +mode: openspec +--- + +# Feature: add-anthropic-model-adapter + +## Scope +新增独立 `AnthropicModelAdapter`,对接 Anthropic 官方 Messages API,并在 runtime/CLI 侧完成 `anthropic` adapter 的加载与诊断接入,不改造既有 OpenAI/OpenRouter adapter 内部实现。 + +## OpenSpec Artifacts +- Proposal: `openspec/changes/add-anthropic-model-adapter/proposal.md` +- Design: `openspec/changes/add-anthropic-model-adapter/design.md` +- Specs: + - `openspec/changes/add-anthropic-model-adapter/specs/anthropic-model-adapter/spec.md` +- Tasks: `openspec/changes/add-anthropic-model-adapter/tasks.md` + +## Progress +- 已完成:Anthropic adapter 代码、manager 接入、CLI doctor 接入、依赖与文档更新。 +- 已完成:定向单测回归通过。 +- 待完成:评审链接与归档动作。 + +## Evidence + +### External References +- Anthropic Messages API: `https://docs.anthropic.com/en/api/messages` +- Anthropic tool use examples: `https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview` +- OpenRouter Anthropic Opus model page: `https://openrouter.ai/anthropic/claude-opus-4.1` +- OpenRouter Anthropic Sonnet model page: `https://openrouter.ai/anthropic/claude-sonnet-4.5` + +### Commands +- `openspec new change "add-anthropic-model-adapter"` +- `openspec status --change "add-anthropic-model-adapter" --json` +- `.venv/bin/pytest -q tests/unit/test_anthropic_model_adapter.py tests/unit/test_default_model_adapter_manager.py tests/unit/test_client_cli.py` + +### Results +- OpenSpec change 创建成功(schema: `spec-driven`)。 +- Anthropic adapter 目标测试集通过:`74 passed, 1 warning`。 + +### Contract Delta +- schema: `changed`(新增 `anthropic` adapter 选择与请求/响应规范化行为,模型名为配置直传)。 +- error semantics: `changed`(`anthropic` SDK 缺失时新增明确错误提示)。 +- retry: `none`(reason: Anthropic adapter 本次仅做请求参数和响应解析适配,未引入新重试策略)。 + +### Golden Cases +- `tests/unit/test_anthropic_model_adapter.py` +- `tests/unit/test_default_model_adapter_manager.py` +- `tests/unit/test_client_cli.py` + +### Regression Summary +- runner: `.venv/bin/pytest -q tests/unit/test_anthropic_model_adapter.py tests/unit/test_default_model_adapter_manager.py tests/unit/test_client_cli.py` +- summary: pass=74, fail=0, skip=0;覆盖新增 adapter 的序列化、模型名直传、manager 选择与 CLI doctor 诊断分支。 + +### Observability and Failure Localization +- start: adapter 初始化入口为 `DefaultModelAdapterManager.load_model_adapter(config)`,关键定位字段包含 `run_id` 与 `trace_id`。 +- tool_call: `AnthropicModelAdapter._serialize_system_and_messages` 处理 tool_use/tool_result,关键定位字段包含 `tool_call_id`、`capability_id`、`attempt`。 +- end: `client.messages.create(**params)` 返回后经 `_extract_response_text` / `_extract_tool_calls` / `_extract_usage` 归一化并结束本次推理链路。 +- fail: `_build_client` 缺依赖或缺 API key/model 时抛错;`build_doctor_report` 提供依赖与密钥诊断,错误定位语义包含 `error_type` / `error_code` / `ToolResult.error`。 + +### Structured Review Report +- Changed Module Boundaries / Public API: 新增 `dare_framework/model/adapters/anthropic_adapter.py`,并在 `model/adapters/__init__.py`、`model/__init__.py`、`DefaultModelAdapterManager` 暴露 `anthropic` adapter 入口。 +- New State: 无新增全局可变状态;adapter 仅维护 lazy 初始化的 SDK client 引用。 +- Concurrency / Timeout / Retry: 复用 async SDK 调用路径;未新增 timeout/retry 语义,保持现有 runtime 并发模型不变。 +- Side Effects and Idempotency: 仅新增 `anthropic` 依赖与 provider 分支;消息序列化与响应解析均为纯转换逻辑,幂等性不变。 +- Coverage and Residual Risk: 相关单测已覆盖 adapter 载入、模型名直传、tool 序列化、doctor 诊断;残余风险是运行时配置错误(缺 model 或 key)导致初始化失败。 + +### Behavior Verification +- Happy path: + - `adapter=anthropic` 时可加载 Anthropic adapter 并完成消息/工具块序列化与响应归一化。 + - 显式模型名(例如 `claude-sonnet-4-5`)会原样透传到 Anthropic SDK。 +- Error branch: + - 缺少 `ANTHROPIC_API_KEY` 或 `api_key` 时抛出显式错误。 + - 缺少 `Config.llm.model` 且 `ANTHROPIC_MODEL` 未设置时抛出模型必填错误。 + - doctor 在 `adapter=anthropic` 且未安装 `anthropic` SDK 时输出依赖告警。 + +### Risks and Rollback +- 风险:运行配置缺少模型名会导致 adapter 初始化失败。 +- 回滚:删除 `anthropic` 分支与新增 adapter 文件即可恢复至原行为,不影响 OpenAI/OpenRouter 路径。 + +### Review and Merge Gate Links +- Intent PR: `https://github.com/zts212653/Deterministic-Agent-Runtime-Engine/pull/126` +- Implementation PR: `https://github.com/zts212653/Deterministic-Agent-Runtime-Engine/pull/199` +- Review request: `https://github.com/zts212653/Deterministic-Agent-Runtime-Engine/pull/199#pullrequestreview-3894789081` +- Merge gate: `https://github.com/zts212653/Deterministic-Agent-Runtime-Engine/pull/199/checks` diff --git a/openspec/changes/add-anthropic-model-adapter/.openspec.yaml b/openspec/changes/add-anthropic-model-adapter/.openspec.yaml new file mode 100644 index 00000000..8f0b8699 --- /dev/null +++ b/openspec/changes/add-anthropic-model-adapter/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-05 diff --git a/openspec/changes/add-anthropic-model-adapter/design.md b/openspec/changes/add-anthropic-model-adapter/design.md new file mode 100644 index 00000000..68dc0550 --- /dev/null +++ b/openspec/changes/add-anthropic-model-adapter/design.md @@ -0,0 +1,55 @@ +## Context + +现有 model domain 的默认适配器仅覆盖 OpenAI / OpenRouter。用户需要“完整新增”Anthropic adapter,而不是在已有 adapter 上做兼容分支。该改动涉及模型调用层、默认 adapter 管理器、CLI 诊断链路和文档契约更新,属于跨模块变更。 + +## Goals / Non-Goals + +**Goals:** +- 提供独立 `AnthropicModelAdapter`,并保持与现有 `IModelAdapter` 契约一致。 +- 模型名仅做直传:由 `Config.llm.model` 或 `ANTHROPIC_MODEL` 提供,不维护硬编码别名映射。 +- 支持 tool calling 对话回放链路:assistant `tool_use` 与 tool `tool_result`。 +- 在 CLI doctor 中加入 `anthropic` adapter 的配置与依赖可观测性。 + +**Non-Goals:** +- 不改造 OpenAI / OpenRouter adapter 的内部逻辑。 +- 不实现 streaming 接口。 +- 不新增多 provider 自动路由策略。 + +## Decisions + +1. **独立实现 Anthropic adapter** +- Decision: 新建 `dare_framework/model/adapters/anthropic_adapter.py`,不复用 `openrouter_adapter.py`。 +- Rationale: 用户要求“完整新增”;Anthropic Messages API 的 message/tool 结构也与 OpenAI-compatible 接口不同。 +- Alternative considered: 在 OpenRouter adapter 内新增 Anthropic 分支,违背隔离要求且会放大耦合。 + +2. **模型名来源固定为配置/环境变量** +- Decision: 适配器不做模型别名转换,仅接受 `Config.llm.model` 或 `ANTHROPIC_MODEL` 的直接值。 +- Rationale: 避免“新模型发布 -> 需要改框架代码”耦合,模型升级由配置侧完成。 +- Alternative considered: 内置别名映射;该方案会引入版本漂移与维护负担。 + +3. **序列化策略对齐 Anthropic Messages API** +- Decision: `system` 消息单独抽取到 `system` 字段;assistant 历史 tool call 序列化为 `tool_use` block;tool 回包序列化为 user `tool_result` block。 +- Rationale: 与 Anthropic 官方 tool-use 协议一致,保证多轮工具调用可追踪。 + +4. **CLI doctor 扩展 anthropic 诊断** +- Decision: 新增 `ANTHROPIC_API_KEY` 来源检查和 `anthropic` SDK 依赖检查。 +- Rationale: 让运行前诊断与新增 adapter 保持一致,避免误判“unsupported adapter”。 + +## Risks / Trade-offs + +- [Risk] Anthropic SDK 未安装导致运行期失败。 + Mitigation: doctor 增加依赖告警;adapter 在 `_build_client` 处抛出明确 ImportError。 +- [Risk] Anthropic API block 字段未来变化导致解析漂移。 + Mitigation: 解析函数统一封装 `_extract_*`,并以单元测试锚定。 +- [Risk] 用户未配置模型名会导致启动失败。 + Mitigation: 在 adapter 初始化阶段给出明确错误信息;README 与示例文件提供最小配置模板。 + +## Migration Plan + +1. 添加 Anthropic adapter 与单测(先红后绿)。 +2. 接入 manager/export/doctor 并补齐相关测试。 +3. 更新设计文档与 client 使用文档。 +4. 运行定向测试验证新增能力。 + +Rollback: +- 若出现兼容问题,可仅回退 `default_model_adapter_manager.py` 中 `anthropic` 分支和新增 adapter 文件,不影响既有 `openai/openrouter` 路径。 diff --git a/openspec/changes/add-anthropic-model-adapter/proposal.md b/openspec/changes/add-anthropic-model-adapter/proposal.md new file mode 100644 index 00000000..eaf10429 --- /dev/null +++ b/openspec/changes/add-anthropic-model-adapter/proposal.md @@ -0,0 +1,37 @@ +## Why + +当前框架仅内置 `openai` / `openrouter` 两类模型适配器,无法直接对接 Anthropic 官方 Messages API。用户明确需要独立 Anthropic adapter,并要求模型名由用户配置直传,避免因新模型发布而改代码。 + +## What Changes + +- 新增独立 `AnthropicModelAdapter`,基于 Anthropic 官方 Python SDK 的 `messages.create` 接口实现。 +- 在 adapter 内统一处理消息序列化(含 tool history / tool result)、响应反序列化(text/tool_use/thinking/usage)。 +- 在 `DefaultModelAdapterManager` 增加 `anthropic` 分支,支持通过 `Config.llm.adapter="anthropic"` 加载。 +- 在 CLI doctor 诊断中增加 `anthropic` adapter 的 API Key 与依赖检查。 +- 更新 model/client 设计与使用文档,补充 Anthropic 配置与官方参考链接。 + +## Capabilities + +### New Capabilities +- `anthropic-model-adapter`: Anthropic 官方 API 的模型适配能力(模型名直传,tool calling,usage/thinking 归一化)。 + +### Modified Capabilities +- None. + +## Impact + +- Affected code: + - `dare_framework/model/adapters/anthropic_adapter.py` + - `dare_framework/model/adapters/__init__.py` + - `dare_framework/model/__init__.py` + - `dare_framework/model/default_model_adapter_manager.py` + - `client/commands/info.py` + - `client/main.py` + - `pyproject.toml` + - `requirements.txt` +- Affected tests: + - `tests/unit/test_anthropic_model_adapter.py` + - `tests/unit/test_default_model_adapter_manager.py` + - `tests/unit/test_client_cli.py` +- New dependency: + - `anthropic` (official SDK) diff --git a/openspec/changes/add-anthropic-model-adapter/specs/anthropic-model-adapter/spec.md b/openspec/changes/add-anthropic-model-adapter/specs/anthropic-model-adapter/spec.md new file mode 100644 index 00000000..93e347c7 --- /dev/null +++ b/openspec/changes/add-anthropic-model-adapter/specs/anthropic-model-adapter/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: Anthropic adapter uses direct model-name pass-through +The model domain SHALL provide an Anthropic adapter that can be selected via `Config.llm.adapter="anthropic"` and MUST accept model names as direct pass-through values from `Config.llm.model` or `ANTHROPIC_MODEL`. + +#### Scenario: Adapter uses explicit model from config +- **WHEN** config provides model `claude-sonnet-4-5` +- **THEN** the adapter request uses `claude-sonnet-4-5` as-is + +#### Scenario: Adapter requires model source when unset +- **WHEN** both `Config.llm.model` and `ANTHROPIC_MODEL` are missing +- **THEN** adapter initialization fails with an explicit model-required error + +### Requirement: Anthropic adapter serializes tool history using Messages API blocks +The Anthropic adapter SHALL serialize assistant tool-call history and tool results according to Anthropic Messages API content blocks. + +#### Scenario: Assistant history contains tool calls +- **WHEN** assistant history metadata includes tool calls +- **THEN** the adapter emits `tool_use` blocks with stable ids, names, and JSON object inputs + +#### Scenario: Tool result is appended to history +- **WHEN** a tool result message is present in history +- **THEN** the adapter emits a user `tool_result` block that references the originating `tool_use_id` + +### Requirement: Anthropic adapter normalizes response content and usage +The Anthropic adapter SHALL normalize response text, tool calls, thinking content, and usage fields into `ModelResponse`. + +#### Scenario: Response includes text and tool_use blocks +- **WHEN** Anthropic response content contains `text` and `tool_use` blocks +- **THEN** `ModelResponse.content` contains the text content +- **AND** `ModelResponse.tool_calls` contains normalized tool calls + +#### Scenario: Response includes usage counters +- **WHEN** Anthropic response usage provides input/output token counters +- **THEN** `ModelResponse.usage` includes `prompt_tokens`, `completion_tokens`, and `total_tokens` diff --git a/openspec/changes/add-anthropic-model-adapter/tasks.md b/openspec/changes/add-anthropic-model-adapter/tasks.md new file mode 100644 index 00000000..d0480c13 --- /dev/null +++ b/openspec/changes/add-anthropic-model-adapter/tasks.md @@ -0,0 +1,17 @@ +## 1. Anthropic adapter implementation + +- [x] 1.1 Add `AnthropicModelAdapter` with Anthropic Messages API request/response normalization. +- [x] 1.2 Enforce model-name pass-through from config/env without hard-coded alias mapping. +- [x] 1.3 Add adapter-focused unit tests for serialization, parsing, and generate-path payload building. + +## 2. Runtime and CLI integration + +- [x] 2.1 Wire `anthropic` into `DefaultModelAdapterManager` and model facade exports. +- [x] 2.2 Extend CLI doctor diagnostics for `anthropic` adapter key/dependency checks. +- [x] 2.3 Update unit tests covering manager selection and doctor behavior. + +## 3. Documentation and dependency sync + +- [x] 3.1 Update model/client documentation to include Anthropic adapter usage and constraints. +- [x] 3.2 Add governance feature/evidence document for this change. +- [x] 3.3 Update dependency manifests to include Anthropic SDK. diff --git a/pyproject.toml b/pyproject.toml index 6c9fc37f..e2f361ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ version = "0.1.0" description = "DARE Framework and external CLI runtime" requires-python = ">=3.12" dependencies = [ + "anthropic", "langchain-openai", "langchain-core", "httpx>=0.27.0", diff --git a/requirements.txt b/requirements.txt index b7b8c67c..beed5456 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +anthropic langchain-openai langchain-core pytest diff --git a/tests/unit/test_anthropic_model_adapter.py b/tests/unit/test_anthropic_model_adapter.py new file mode 100644 index 00000000..4282e9d6 --- /dev/null +++ b/tests/unit/test_anthropic_model_adapter.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from dare_framework.context.types import Message +from dare_framework.model.adapters.anthropic_adapter import ( + AnthropicModelAdapter, + _extract_response_text, + _extract_thinking_content, + _extract_tool_calls, + _extract_usage, + _resolve_model_name, + _serialize_system_and_messages, +) +from dare_framework.model.types import GenerateOptions, ModelInput +from dare_framework.tool.types import CapabilityDescriptor, CapabilityType + + +def test_resolve_model_name_prefers_explicit_model_value() -> None: + assert _resolve_model_name(model="claude-sonnet-4-5", env_model="claude-opus-4-1") == "claude-sonnet-4-5" + + +def test_resolve_model_name_uses_env_model_when_explicit_missing() -> None: + assert _resolve_model_name(model=None, env_model="claude-opus-4-1") == "claude-opus-4-1" + + +def test_resolve_model_name_requires_model_source() -> None: + with pytest.raises(ValueError, match="Anthropic model is required"): + _resolve_model_name(model=None, env_model=None) + + +def test_serialize_system_and_messages_preserves_tool_history() -> None: + system_prompt, payload_messages = _serialize_system_and_messages( + [ + Message(role="system", content="You are a helpful assistant."), + Message(role="user", content="Need a filename"), + Message( + role="assistant", + content="I need your confirmation first.", + metadata={ + "tool_calls": [ + { + "id": "call_1", + "name": "ask_user", + "arguments": {"question": "Pick a file name"}, + } + ] + }, + ), + Message( + role="tool", + name="call_1", + content="a.txt", + ), + ] + ) + + assert system_prompt == "You are a helpful assistant." + assert payload_messages[0] == {"role": "user", "content": "Need a filename"} + assert payload_messages[1]["role"] == "assistant" + assert payload_messages[1]["content"][0]["type"] == "text" + assert payload_messages[1]["content"][1] == { + "type": "tool_use", + "id": "call_1", + "name": "ask_user", + "input": {"question": "Pick a file name"}, + } + assert payload_messages[2] == { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "call_1", "content": "a.txt"}], + } + + +def test_extract_response_fields_from_content_blocks() -> None: + blocks = [ + SimpleNamespace(type="thinking", thinking="internal reasoning"), + SimpleNamespace(type="text", text="final answer"), + SimpleNamespace(type="tool_use", id="toolu_1", name="search", input={"q": "hi"}), + ] + usage = SimpleNamespace( + input_tokens=12, + output_tokens=8, + output_tokens_details=SimpleNamespace(reasoning_tokens=3), + ) + + assert _extract_response_text(blocks) == "final answer" + assert _extract_thinking_content(blocks) == "internal reasoning" + assert _extract_tool_calls(blocks) == [{"id": "toolu_1", "name": "search", "arguments": {"q": "hi"}}] + assert _extract_usage(usage) == { + "prompt_tokens": 12, + "completion_tokens": 8, + "total_tokens": 20, + "reasoning_tokens": 3, + } + + +@pytest.mark.asyncio +async def test_generate_builds_anthropic_payload_and_parses_response() -> None: + calls: list[dict[str, object]] = [] + + class _MessagesAPI: + async def create(self, **kwargs): # noqa: ANN003 + calls.append(kwargs) + return SimpleNamespace( + content=[ + SimpleNamespace(type="thinking", thinking="plan"), + SimpleNamespace(type="tool_use", id="toolu_1", name="search", input={"q": "docs"}), + SimpleNamespace(type="text", text="done"), + ], + usage=SimpleNamespace(input_tokens=7, output_tokens=5), + stop_reason="end_turn", + ) + + class _FakeClient: + def __init__(self) -> None: + self.messages = _MessagesAPI() + + adapter = AnthropicModelAdapter(api_key="test-key", model="claude-opus-4-1") + adapter._client = _FakeClient() # type: ignore[assignment] + + result = await adapter.generate( + ModelInput( + messages=[Message(role="system", content="You are careful."), Message(role="user", content="hello")], + tools=[ + CapabilityDescriptor( + id="search-docs", + type=CapabilityType.TOOL, + name="search", + description="Search docs", + input_schema={"type": "object", "properties": {"q": {"type": "string"}}}, + output_schema={"type": "object"}, + metadata={}, + ) + ], + ), + options=GenerateOptions(max_tokens=128, stop=["STOP"]), + ) + + assert calls + params = calls[0] + assert params["model"] == "claude-opus-4-1" + assert params["system"] == "You are careful." + assert params["stop_sequences"] == ["STOP"] + assert params["max_tokens"] == 128 + assert params["tools"] == [ + { + "name": "search", + "description": "Search docs", + "input_schema": {"type": "object", "properties": {"q": {"type": "string"}}}, + } + ] + + assert result.content == "done" + assert result.thinking_content == "plan" + assert result.tool_calls == [{"id": "toolu_1", "name": "search", "arguments": {"q": "docs"}}] + assert result.usage == {"prompt_tokens": 7, "completion_tokens": 5, "total_tokens": 12} + + +@pytest.mark.asyncio +async def test_generate_keeps_normalized_max_tokens_when_extra_value_is_none() -> None: + calls: list[dict[str, object]] = [] + + class _MessagesAPI: + async def create(self, **kwargs): # noqa: ANN003 + calls.append(kwargs) + return SimpleNamespace(content=[SimpleNamespace(type="text", text="ok")], usage=None, stop_reason="end_turn") + + class _FakeClient: + def __init__(self) -> None: + self.messages = _MessagesAPI() + + adapter = AnthropicModelAdapter(api_key="test-key", model="claude-sonnet-4-5", extra={"max_tokens": None}) + adapter._client = _FakeClient() # type: ignore[assignment] + + await adapter.generate(ModelInput(messages=[Message(role="user", content="hello")])) + + assert calls + assert calls[0]["max_tokens"] == 2048 + + +@pytest.mark.asyncio +async def test_generate_normalizes_string_max_tokens_from_extra() -> None: + calls: list[dict[str, object]] = [] + + class _MessagesAPI: + async def create(self, **kwargs): # noqa: ANN003 + calls.append(kwargs) + return SimpleNamespace(content=[SimpleNamespace(type="text", text="ok")], usage=None, stop_reason="end_turn") + + class _FakeClient: + def __init__(self) -> None: + self.messages = _MessagesAPI() + + adapter = AnthropicModelAdapter(api_key="test-key", model="claude-opus-4-1", extra={"max_tokens": "256"}) + adapter._client = _FakeClient() # type: ignore[assignment] + + await adapter.generate(ModelInput(messages=[Message(role="user", content="hello")])) + + assert calls + assert calls[0]["max_tokens"] == 256 diff --git a/tests/unit/test_client_cli.py b/tests/unit/test_client_cli.py index a77f11b7..eb710b27 100644 --- a/tests/unit/test_client_cli.py +++ b/tests/unit/test_client_cli.py @@ -252,6 +252,25 @@ def test_build_doctor_report_accepts_openai_with_key() -> None: assert "workspace_dir" in payload +def test_build_doctor_report_accepts_anthropic_with_key() -> None: + config = Config.from_dict( + { + "workspace_dir": ".", + "user_dir": ".", + "llm": { + "adapter": "anthropic", + "model": "claude-sonnet-4-5", + "api_key": "dummy", + }, + "mcp_paths": [], + } + ) + payload = build_doctor_report(config=config) + assert payload["llm"]["adapter"] == "anthropic" + assert payload["llm"]["api_key_present"] is True + assert not any("unsupported adapter configured" in item for item in payload["warnings"]) + + @pytest.mark.asyncio async def test_main_doctor_does_not_bootstrap_runtime(monkeypatch, tmp_path) -> None: client_main = importlib.import_module("client.main") diff --git a/tests/unit/test_default_model_adapter_manager.py b/tests/unit/test_default_model_adapter_manager.py index 78b79ee4..048db1fc 100644 --- a/tests/unit/test_default_model_adapter_manager.py +++ b/tests/unit/test_default_model_adapter_manager.py @@ -4,7 +4,7 @@ from dare_framework.agent import BaseAgent from dare_framework.config.types import Config, LLMConfig -from dare_framework.model import OpenAIModelAdapter, OpenRouterModelAdapter +from dare_framework.model import AnthropicModelAdapter, OpenAIModelAdapter, OpenRouterModelAdapter from dare_framework.model.default_model_adapter_manager import DefaultModelAdapterManager @@ -24,6 +24,15 @@ def test_default_manager_returns_openrouter_adapter() -> None: assert adapter.model_name == "openrouter/test" +def test_default_manager_returns_anthropic_adapter() -> None: + manager = DefaultModelAdapterManager() + config = Config(llm=LLMConfig(adapter="anthropic", api_key="test-key", model="claude-sonnet-4-5")) + adapter = manager.load_model_adapter(config=config) + assert isinstance(adapter, AnthropicModelAdapter) + assert adapter.name == "anthropic" + assert adapter.model_name == "claude-sonnet-4-5" + + def test_default_manager_unsupported_adapter_raises() -> None: manager = DefaultModelAdapterManager() config = Config(llm=LLMConfig(adapter="unknown"))