diff --git a/.env.example b/.env.example index fe5910eb..7da57615 100644 --- a/.env.example +++ b/.env.example @@ -14,19 +14,19 @@ MISTRAL_API_KEY="" CODESTRAL_API_KEY="" -# DeepSeek Config (uses native Anthropic Messages at api.deepseek.com/anthropic) +# DeepSeek Config (OpenAI-compatible Chat Completions at api.deepseek.com) DEEPSEEK_API_KEY="" -# Kimi Config (Anthropic-compatible Messages at api.moonshot.ai/anthropic/v1) +# Kimi Config (OpenAI-compatible Chat Completions at api.moonshot.ai/v1) KIMI_API_KEY="" -# Wafer Config (uses native Anthropic Messages at pass.wafer.ai/v1/messages) +# Wafer Config (OpenAI-compatible Chat Completions at pass.wafer.ai/v1) WAFER_API_KEY="" -# MiniMax Config (Anthropic-compatible Messages at api.minimax.io/anthropic/v1) +# MiniMax Config (OpenAI-compatible Chat Completions at api.minimax.io/v1) MINIMAX_API_KEY="" @@ -50,11 +50,11 @@ COHERE_API_KEY="" GITHUB_MODELS_TOKEN="" -# Z.ai Config (Anthropic-compatible Messages at api.z.ai/api/anthropic/v1) +# Z.ai Config (GLM Coding Plan OpenAI-compatible Chat Completions at api.z.ai/api/coding/paas/v4) ZAI_API_KEY="" -# Fireworks AI Config (Anthropic-compatible Messages at api.fireworks.ai/inference/v1) +# Fireworks AI Config (OpenAI-compatible Chat Completions at api.fireworks.ai/inference/v1) FIREWORKS_API_KEY="" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6c68d500..125f46d9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -341,9 +341,10 @@ There are two transport families under [providers/transports/](providers/transpo and OpenAI-chat recovery event construction. - [providers/transports/anthropic_messages/](providers/transports/anthropic_messages/) implements `AnthropicMessagesTransport` for providers with - Anthropic-compatible `/messages` APIs. The package owns the thin transport - base, native request policy, native stream runner, HTTP response helpers, and - native recovery event construction. + Anthropic-compatible `/messages` APIs. In FCC this transport is intentionally + local-only for llama.cpp and Ollama. The package owns the thin transport base, + native request policy, native stream runner, HTTP response helpers, and native + recovery event construction. Provider request construction mirrors the transport family split. OpenAI-chat providers call the OpenAI request policy for Anthropic-to-OpenAI conversion, @@ -352,15 +353,18 @@ Native Anthropic providers call the native request policy for raw request dumping, default tokens, stream flags, thinking payloads, and `extra_body` handling. Concrete provider packages keep only true upstream quirks such as Gemini thought signatures, NIM tool-schema aliases and retry downgrades, or -DeepSeek attachment/tool/thinking compatibility. DeepSeek intentionally uses its +DeepSeek attachment/tool/thinking compatibility. Cloud providers use OpenAI-chat +unless they are local native runtimes. DeepSeek intentionally uses its OpenAI-compatible Chat Completions endpoint because that is the endpoint that reports prompt-cache hit/miss counters; the provider maps those counters back into Anthropic usage fields for Claude-compatible clients. Cloudflare uses its account-scoped Workers AI OpenAI-compatible Chat Completions endpoint for `@cf/...` model IDs, while account ID composition, model search, and Cloudflare-specific reasoning deltas stay in the Cloudflare provider client. -MiniMax uses its Anthropic-compatible Messages endpoint; its provider client owns -the MiniMax-specific `adaptive` thinking request shape. +OpenRouter, Wafer, Kimi, MiniMax, Fireworks, and Z.ai also use the shared +OpenAI-chat transport; their provider clients own provider-specific thinking, +reasoning replay, model-list filtering, and `extra_body` policy. Z.ai is treated +as the GLM Coding Plan provider and uses Z.ai's Coding Plan OpenAI base. Mistral La Plateforme keeps its native `reasoning_effort` and thinking-chunk request/stream mapping inside [providers/mistral/reasoning.py](providers/mistral/reasoning.py), including its @@ -516,7 +520,7 @@ represent Anthropic server-tool blocks, so the Messages handler rejects unsuppor server-tool requests before provider execution instead of performing a lossy conversion. Forced `web_search` or `web_fetch` requests are handled locally when `ENABLE_WEB_SERVER_TOOLS` is true; otherwise OpenAI-chat upstreams reject them -and native Anthropic Messages transports may receive them. +and the local native Anthropic Messages transports may receive them. ## CLI Launchers And Managed Claude diff --git a/README.md b/README.md index 8be787a0..b89eb31a 100644 --- a/README.md +++ b/README.md @@ -306,7 +306,7 @@ Popular examples: - `wafer/Qwen3.5-397B-A17B` - `wafer/GLM-5.1` -This provider uses Wafer's Anthropic-compatible endpoint at `https://pass.wafer.ai/v1/messages`. +This provider uses Wafer's OpenAI-compatible Chat Completions endpoint at `https://pass.wafer.ai/v1/chat/completions`; FCC maps Claude-style thinking to Wafer's explicit thinking controls. ### 14. [Kimi](https://platform.moonshot.ai/) @@ -314,7 +314,7 @@ Get a key at [platform.moonshot.ai/console/api-keys](https://platform.moonshot.a In the Admin UI, paste it into `KIMI_API_KEY`, then set `MODEL` to a Kimi slug such as `kimi/kimi-k2.5`. -This provider calls Kimi's **Anthropic-compatible** Messages API (`https://api.moonshot.ai/anthropic/v1/messages`; model discovery uses OpenAI-compat `GET https://api.moonshot.ai/v1/models`). It is **not** the OpenAI Chat Completions path. +This provider calls Kimi's OpenAI-compatible Chat Completions API at `https://api.moonshot.ai/v1/chat/completions`; FCC keeps Kimi-specific thinking controls inside the Kimi provider. Browse models at [platform.moonshot.ai](https://platform.moonshot.ai). @@ -324,7 +324,7 @@ Get a key from [MiniMax](https://platform.minimax.io/user-center/basic-informati In the Admin UI, paste it into `MINIMAX_API_KEY`, then set `MODEL` to a MiniMax slug such as `minimax/MiniMax-M3`. -This provider calls MiniMax's **Anthropic-compatible** Messages API (`https://api.minimax.io/anthropic/v1/messages`). `MiniMax-M3` is the recommended default because MiniMax documents controllable Anthropic thinking for that model; other MiniMax models remain discoverable through the provider model list. +This provider calls MiniMax's OpenAI-compatible Chat Completions API at `https://api.minimax.io/v1/chat/completions`. `MiniMax-M3` is the recommended default because MiniMax documents controllable thinking for that model; other MiniMax models remain discoverable through the provider model list. ### 16. [Cerebras Inference](https://inference-docs.cerebras.ai/quickstart) @@ -362,7 +362,7 @@ Get an API key at [fireworks.ai/account/api-keys](https://fireworks.ai/account/a In the Admin UI, paste it into `FIREWORKS_API_KEY`, then set `MODEL` to a Fireworks model slug such as `fireworks/accounts/fireworks/models/llama-v3p3-70b-instruct`. -Fireworks exposes an **Anthropic-compatible** Messages API at `https://api.fireworks.ai/inference/v1/messages` (same inference host as before; Chat Completions is not used here). Vendor-specific JSON keys can still be merged from request `extra_body` when allowed. +Fireworks exposes an OpenAI-compatible Chat Completions API at `https://api.fireworks.ai/inference/v1/chat/completions`. Vendor-specific JSON keys can still be passed through request `extra_body` when they do not override FCC-owned chat fields. Browse models at [fireworks.ai/models](https://fireworks.ai/models). @@ -380,7 +380,7 @@ Get an API key at [Z.ai/manage-apikey/apikey-list](https://z.ai/manage-apikey/ap In the Admin UI, paste it into `ZAI_API_KEY`, then set `MODEL` to a Z.ai model slug such as `zai/glm-5.2`. -This provider calls Z.ai's **Anthropic-compatible** Messages API (`https://api.z.ai/api/anthropic/v1/messages`). The former OpenAI Coding Plan base (`https://api.z.ai/api/coding/paas/v4`) is **not** used by this gateway. +This provider calls Z.ai's GLM Coding Plan OpenAI-compatible Chat Completions API at `https://api.z.ai/api/coding/paas/v4/chat/completions`. FCC keeps Z.ai `clear_thinking=false` handling inside the Z.ai provider so tool workflows can preserve prior reasoning. Popular examples: @@ -631,7 +631,7 @@ Important pieces: - `fcc-codex` registers a custom `fcc` provider that points Codex at the local proxy's `/v1/responses` endpoint. - Model routing resolves Claude model names to `MODEL_OPUS`, `MODEL_SONNET`, `MODEL_HAIKU`, or `MODEL`. - NIM, Gemini, DeepSeek, Mistral, Codestral, OpenCode Zen, OpenCode Go, Vercel AI Gateway, Hugging Face, Cohere, GitHub Models, Cerebras, Groq, SambaNova, and Cloudflare use OpenAI chat streaming translated into Anthropic SSE. -- Wafer, OpenRouter, Kimi, MiniMax, Fireworks AI, Z.ai, LM Studio, llama.cpp, and Ollama use Anthropic Messages style transports where applicable (with provider-specific quirks and model-list URLs). +- Cloud providers use OpenAI-compatible Chat Completions transports with provider-specific quirks kept in each provider package. Native Anthropic Messages transports are local-only for llama.cpp and Ollama. - The proxy normalizes thinking blocks, tool calls, token usage metadata, and provider errors into the shape each client expects. - Request optimizations answer trivial Claude Code probes locally to save latency and quota. diff --git a/api/web_tools/request.py b/api/web_tools/request.py index fb6f3782..a6800715 100644 --- a/api/web_tools/request.py +++ b/api/web_tools/request.py @@ -73,13 +73,13 @@ def openai_chat_upstream_server_tool_error( return ( f"tool_choice forces Anthropic server tool {forced!r}, but local web server tools are " "disabled (ENABLE_WEB_SERVER_TOOLS=false). Enable them or use a native Anthropic " - "Messages transport (e.g. open_router, ollama, lmstudio)." + "Messages transport such as ollama or llama.cpp." ) if not forced and has_listed_anthropic_server_tools(request): return ( "OpenAI Chat upstreams cannot use listed Anthropic server tools " "(web_search / web_fetch) without the local web server tool handler. Use a native " - "Anthropic transport, set ENABLE_WEB_SERVER_TOOLS=true and force the tool with " + "Anthropic transport such as ollama or llama.cpp, set ENABLE_WEB_SERVER_TOOLS=true and force the tool with " "tool_choice, or remove these tools from the request." ) return None diff --git a/config/provider_catalog.py b/config/provider_catalog.py index 8d2c248e..114e8da5 100644 --- a/config/provider_catalog.py +++ b/config/provider_catalog.py @@ -11,10 +11,10 @@ TransportType = Literal["openai_chat", "anthropic_messages"] # Default upstream base URLs (also re-exported via :mod:`providers.defaults`) NVIDIA_NIM_DEFAULT_BASE = "https://integrate.api.nvidia.com/v1" -# Moonshot Kimi Anthropic-compatible Messages API (POST …/messages). -KIMI_DEFAULT_BASE = "https://api.moonshot.ai/anthropic/v1" +# Moonshot Kimi OpenAI-compatible Chat Completions API. +KIMI_DEFAULT_BASE = "https://api.moonshot.ai/v1" WAFER_DEFAULT_BASE = "https://pass.wafer.ai/v1" -MINIMAX_DEFAULT_BASE = "https://api.minimax.io/anthropic/v1" +MINIMAX_DEFAULT_BASE = "https://api.minimax.io/v1" # DeepSeek Chat Completions API; cache usage is reported on this endpoint. DEEPSEEK_DEFAULT_BASE = "https://api.deepseek.com" FIREWORKS_DEFAULT_BASE = "https://api.fireworks.ai/inference/v1" @@ -33,8 +33,8 @@ VERCEL_AI_GATEWAY_DEFAULT_BASE = "https://ai-gateway.vercel.sh/v1" HUGGINGFACE_DEFAULT_BASE = "https://router.huggingface.co/v1" COHERE_DEFAULT_BASE = "https://api.cohere.ai/compatibility/v1" GITHUB_MODELS_DEFAULT_BASE = "https://models.github.ai/inference" -# Z.ai Anthropic-compatible Messages API (not OpenAI Coding Plan chat completions). -ZAI_DEFAULT_BASE = "https://api.z.ai/api/anthropic/v1" +# Z.ai GLM Coding Plan OpenAI-compatible Chat Completions API. +ZAI_DEFAULT_BASE = "https://api.z.ai/api/coding/paas/v4" # Google AI Studio Gemini API OpenAI-compat layer (not Vertex AI). GEMINI_DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta/openai/" GROQ_DEFAULT_BASE = "https://api.groq.com/openai/v1" @@ -74,13 +74,13 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { "open_router": ProviderDescriptor( provider_id="open_router", display_name="OpenRouter", - transport_type="anthropic_messages", + transport_type="openai_chat", credential_env="OPENROUTER_API_KEY", credential_url="https://openrouter.ai/keys", credential_attr="open_router_api_key", default_base_url=OPENROUTER_DEFAULT_BASE, proxy_attr="open_router_proxy", - capabilities=("chat", "streaming", "tools", "thinking", "native_anthropic"), + capabilities=("chat", "streaming", "tools", "thinking"), ), "gemini": ProviderDescriptor( provider_id="gemini", @@ -194,18 +194,18 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { "wafer": ProviderDescriptor( provider_id="wafer", display_name="Wafer", - transport_type="anthropic_messages", + transport_type="openai_chat", credential_env="WAFER_API_KEY", credential_url="https://www.wafer.ai/pass", credential_attr="wafer_api_key", default_base_url=WAFER_DEFAULT_BASE, proxy_attr="wafer_proxy", - capabilities=("chat", "streaming", "tools", "thinking", "native_anthropic"), + capabilities=("chat", "streaming", "tools", "thinking"), ), "kimi": ProviderDescriptor( provider_id="kimi", display_name="Kimi", - transport_type="anthropic_messages", + transport_type="openai_chat", credential_env="KIMI_API_KEY", credential_url="https://platform.moonshot.cn/console/api-keys", credential_attr="kimi_api_key", @@ -216,13 +216,12 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { "streaming", "tools", "thinking", - "native_anthropic", ), ), "minimax": ProviderDescriptor( provider_id="minimax", display_name="MiniMax", - transport_type="anthropic_messages", + transport_type="openai_chat", credential_env="MINIMAX_API_KEY", credential_url="https://platform.minimax.io/user-center/basic-information/interface-key", credential_attr="minimax_api_key", @@ -233,7 +232,6 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { "streaming", "tools", "thinking", - "native_anthropic", "rate_limit", ), ), @@ -273,7 +271,7 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { "fireworks": ProviderDescriptor( provider_id="fireworks", display_name="Fireworks", - transport_type="anthropic_messages", + transport_type="openai_chat", credential_env="FIREWORKS_API_KEY", credential_url="https://fireworks.ai/account/api-keys", credential_attr="fireworks_api_key", @@ -284,7 +282,6 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { "streaming", "tools", "thinking", - "native_anthropic", "rate_limit", ), ), @@ -308,7 +305,7 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { "zai": ProviderDescriptor( provider_id="zai", display_name="Z.ai", - transport_type="anthropic_messages", + transport_type="openai_chat", credential_env="ZAI_API_KEY", credential_attr="zai_api_key", default_base_url=ZAI_DEFAULT_BASE, @@ -318,7 +315,6 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { "streaming", "tools", "thinking", - "native_anthropic", "rate_limit", ), ), @@ -363,7 +359,7 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { # Key order: # NVIDIA NIM first (README default), DeepSeek fourth, OpenCode gateways adjacent, # Vercel / Hugging Face / Cohere / GitHub Models follow gateway-style remotes, -# then native Anthropic remotes and locals per project plan +# then cloud gateways and local providers per project plan # (github.com/cheahjs/free-llm-api-resources Free Providers TOC as rough guide # beyond fixed slots). # ``SUPPORTED_PROVIDER_IDS`` inherits this insertion order for UI and error-message listing. diff --git a/core/anthropic/native_messages_request.py b/core/anthropic/native_messages_request.py index 54bac10f..36dadaa2 100644 --- a/core/anthropic/native_messages_request.py +++ b/core/anthropic/native_messages_request.py @@ -1,7 +1,4 @@ -"""Native Anthropic Messages request body construction (JSON-ready dicts). - -Provider adapters supply policy via parameters (defaults, OpenRouter post-steps). -""" +"""Native Anthropic Messages request body construction (JSON-ready dicts).""" from collections.abc import Sequence from typing import Any @@ -28,48 +25,6 @@ _REQUEST_FIELDS = ( "extra_body", ) -# Keys that would override routed canonical request fields if merged from ``extra_body``. -_OPENROUTER_EXTRA_BODY_FORBIDDEN_KEYS = frozenset( - { - "model", - "messages", - "system", - "tools", - "tool_choice", - "stream", - "max_tokens", - "temperature", - "top_p", - "top_k", - "metadata", - "stop_sequences", - "context_management", - "output_config", - "mcp_servers", - } -) - - -class OpenRouterExtraBodyError(ValueError): - """``extra_body`` contained reserved keys that would override canonical fields.""" - - -def validate_openrouter_extra_body(extra: Any) -> None: - """Reject ``extra_body`` keys that must not override routed request fields.""" - if not isinstance(extra, dict) or not extra: - return - bad = _OPENROUTER_EXTRA_BODY_FORBIDDEN_KEYS & extra.keys() - if bad: - raise OpenRouterExtraBodyError( - f"extra_body must not override canonical request fields: {sorted(bad)}" - ) - - -_INTERNAL_FIELDS = { - "thinking", - "extra_body", -} - def _serialize_value(value: Any) -> Any: """Convert Pydantic models and lightweight objects into JSON-ready values.""" @@ -95,7 +50,7 @@ def _serialize_value(value: Any) -> Any: def _dump_request_fields(request_data: Any) -> dict[str, Any]: - """Extract the public request fields (OpenRouter-style explicit field list).""" + """Extract the public Anthropic request fields.""" if isinstance(request_data, BaseModel): raw = request_data.model_dump(exclude_none=True) return { @@ -183,33 +138,6 @@ def sanitize_native_messages_thinking_policy( return sanitized_messages -def _normalize_system_prompt_for_openrouter(system: Any) -> Any: - """Flatten Claude SDK system blocks for OpenRouter's native endpoint.""" - if not isinstance(system, list): - return system - - text_parts: list[str] = [] - for block in system: - if not isinstance(block, dict): - continue - if block.get("type") == "text" and isinstance(block.get("text"), str): - text_parts.append(block["text"]) - return "\n\n".join(text_parts).strip() if text_parts else system - - -def _apply_openrouter_reasoning_policy(body: dict[str, Any], thinking_cfg: Any) -> None: - """Map Anthropic thinking controls onto OpenRouter reasoning controls.""" - reasoning = body.setdefault("reasoning", {"enabled": True}) - if not isinstance(reasoning, dict): - return - reasoning.setdefault("enabled", True) - if not isinstance(thinking_cfg, dict): - return - budget_tokens = thinking_cfg.get("budget_tokens") - if isinstance(budget_tokens, int): - reasoning.setdefault("max_tokens", budget_tokens) - - def build_base_native_anthropic_request_body( request: Any, *, @@ -240,39 +168,3 @@ def build_base_native_anthropic_request_body( ) return body - - -def build_openrouter_native_request_body( - request_data: Any, - *, - thinking_enabled: bool, - default_max_tokens: int, -) -> dict[str, Any]: - """Build an Anthropic-format request body for OpenRouter (policy hooks built-in).""" - dumped_request = _dump_request_fields(request_data) - request_extra = dumped_request.pop("extra_body", None) - thinking_cfg = dumped_request.get("thinking") - body: dict[str, Any] = { - key: value - for key, value in dumped_request.items() - if key not in _INTERNAL_FIELDS - } - - if isinstance(request_extra, dict): - validate_openrouter_extra_body(request_extra) - body.update(request_extra) - - body["messages"] = sanitize_native_messages_thinking_policy( - body.get("messages"), - thinking_enabled=thinking_enabled, - ) - if "system" in body: - body["system"] = _normalize_system_prompt_for_openrouter(body["system"]) - body["stream"] = True - if body.get("max_tokens") is None: - body["max_tokens"] = default_max_tokens - - if thinking_enabled: - _apply_openrouter_reasoning_policy(body, thinking_cfg) - - return body diff --git a/core/anthropic/native_sse_block_policy.py b/core/anthropic/native_sse_block_policy.py index d39467a2..ec0aad1f 100644 --- a/core/anthropic/native_sse_block_policy.py +++ b/core/anthropic/native_sse_block_policy.py @@ -1,7 +1,7 @@ """Shared native Anthropic SSE thinking policy, block remapping, and overlap repair. -Used by :class:`OpenRouterProvider` and line-mode -:class:`providers.transports.anthropic_messages.AnthropicMessagesTransport` providers. +Used by line-mode :class:`providers.transports.anthropic_messages.AnthropicMessagesTransport` +providers. """ import copy @@ -12,7 +12,6 @@ from typing import Any __all__ = [ "NativeSseBlockPolicyState", "format_native_sse_event", - "is_terminal_openrouter_done_event", "parse_native_sse_event", "transform_native_sse_block_event", ] @@ -60,13 +59,6 @@ def parse_native_sse_event(event: str) -> tuple[str | None, str]: return event_name, "\n".join(data_lines) -def is_terminal_openrouter_done_event(event_name: str | None, data_text: str) -> bool: - """Return whether an event is OpenAI-style terminal noise (``[DONE]``).""" - return (event_name is None or event_name in {"data", "done"}) and ( - data_text.strip().upper() == "[DONE]" - ) - - def _delta_type_to_block_kind(delta_type: Any) -> str | None: """Map a content_block_delta type to a content block kind (text/thinking/tool_use).""" if not isinstance(delta_type, str): diff --git a/providers/defaults.py b/providers/defaults.py index 2c8a79ba..f8447d12 100644 --- a/providers/defaults.py +++ b/providers/defaults.py @@ -6,6 +6,7 @@ from config.provider_catalog import ( CODESTRAL_DEFAULT_BASE, COHERE_DEFAULT_BASE, DEEPSEEK_DEFAULT_BASE, + FIREWORKS_DEFAULT_BASE, GEMINI_DEFAULT_BASE, GITHUB_MODELS_DEFAULT_BASE, GROQ_DEFAULT_BASE, @@ -32,6 +33,7 @@ __all__ = ( "CODESTRAL_DEFAULT_BASE", "COHERE_DEFAULT_BASE", "DEEPSEEK_DEFAULT_BASE", + "FIREWORKS_DEFAULT_BASE", "GEMINI_DEFAULT_BASE", "GITHUB_MODELS_DEFAULT_BASE", "GROQ_DEFAULT_BASE", diff --git a/providers/fireworks/client.py b/providers/fireworks/client.py index 3790a648..9c914553 100644 --- a/providers/fireworks/client.py +++ b/providers/fireworks/client.py @@ -1,48 +1,45 @@ -"""Fireworks AI provider using native Anthropic-compatible Messages.""" +"""Fireworks AI provider using OpenAI-compatible Chat Completions.""" from typing import Any +from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS from providers.base import ProviderConfig -from providers.transports.anthropic_messages import ( - AnthropicMessagesTransport, - NativeMessagesRequestPolicy, - build_native_messages_request_body, +from providers.defaults import FIREWORKS_DEFAULT_BASE +from providers.transports.openai_chat import ( + OpenAIChatRequestPolicy, + OpenAIChatTransport, + build_openai_chat_request_body, +) +from providers.transports.openai_chat.extra_body import ( + validate_extra_body_does_not_override_canonical_fields, ) -FIREWORKS_BASE_URL = "https://api.fireworks.ai/inference/v1" -_ANTHROPIC_VERSION = "2023-06-01" -_REQUEST_POLICY = NativeMessagesRequestPolicy( +FIREWORKS_BASE_URL = FIREWORKS_DEFAULT_BASE + +_REQUEST_POLICY = OpenAIChatRequestPolicy( provider_name="FIREWORKS", - extra_body="merge_validated", + include_extra_body=True, + extra_body_validator=validate_extra_body_does_not_override_canonical_fields, + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, ) -class FireworksProvider(AnthropicMessagesTransport): - """Fireworks AI using Anthropic-compatible Messages.""" +class FireworksProvider(OpenAIChatTransport): + """Fireworks AI using ``https://api.fireworks.ai/inference/v1/chat/completions``.""" def __init__(self, config: ProviderConfig): super().__init__( config, provider_name="FIREWORKS", - default_base_url=FIREWORKS_BASE_URL, + base_url=config.base_url or FIREWORKS_BASE_URL, + api_key=config.api_key, ) def _build_request_body( self, request: Any, thinking_enabled: bool | None = None ) -> dict: - return build_native_messages_request_body( + return build_openai_chat_request_body( request, thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), policy=_REQUEST_POLICY, ) - - def _request_headers(self) -> dict[str, str]: - return { - "Accept": "text/event-stream", - "Authorization": f"Bearer {self._api_key}", - "Content-Type": "application/json", - "anthropic-version": _ANTHROPIC_VERSION, - } - - def _model_list_headers(self) -> dict[str, str]: - return {"Authorization": f"Bearer {self._api_key}"} diff --git a/providers/kimi/client.py b/providers/kimi/client.py index 5e1d9b5b..29e547ba 100644 --- a/providers/kimi/client.py +++ b/providers/kimi/client.py @@ -1,61 +1,55 @@ -"""Kimi (Moonshot) provider using native Anthropic-compatible Messages.""" +"""Kimi (Moonshot) provider using OpenAI-compatible Chat Completions.""" from typing import Any -import httpx - +from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS from providers.base import ProviderConfig from providers.defaults import KIMI_DEFAULT_BASE -from providers.transports.anthropic_messages import ( - AnthropicMessagesTransport, - NativeMessagesRequestPolicy, - build_native_messages_request_body, +from providers.transports.openai_chat import ( + OpenAIChatRequestPolicy, + OpenAIChatTransport, + build_openai_chat_request_body, ) -_MOONSHOT_OPENAI_MODELS_URL = "https://api.moonshot.ai/v1/models" -_ANTHROPIC_VERSION = "2023-06-01" -_REQUEST_POLICY = NativeMessagesRequestPolicy( +_REQUEST_POLICY = OpenAIChatRequestPolicy( provider_name="KIMI", - extra_body="reject", reject_extra_body_message=( - "Kimi native Messages API does not support extra_body on requests." + "Kimi Chat Completions API does not support caller extra_body on requests." ), + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, ) -class KimiProvider(AnthropicMessagesTransport): - """Kimi provider using Anthropic-compatible Messages at api.moonshot.ai/anthropic/v1.""" +class KimiProvider(OpenAIChatTransport): + """Kimi provider using ``https://api.moonshot.ai/v1/chat/completions``.""" def __init__(self, config: ProviderConfig): super().__init__( config, provider_name="KIMI", - default_base_url=KIMI_DEFAULT_BASE, + base_url=config.base_url or KIMI_DEFAULT_BASE, + api_key=config.api_key, ) def _build_request_body( self, request: Any, thinking_enabled: bool | None = None ) -> dict: - return build_native_messages_request_body( + effective_thinking_enabled = self._is_thinking_enabled( + request, thinking_enabled + ) + return build_openai_chat_request_body( request, - thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), + thinking_enabled=effective_thinking_enabled, policy=_REQUEST_POLICY, + postprocessors=(_apply_kimi_thinking_policy,), ) - def _request_headers(self) -> dict[str, str]: - return { - "Accept": "text/event-stream", - "Authorization": f"Bearer {self._api_key}", - "Content-Type": "application/json", - "anthropic-version": _ANTHROPIC_VERSION, - } - async def _send_model_list_request(self) -> httpx.Response: - """Models are listed from the OpenAI-compat root, not ``/anthropic/v1``.""" - return await self._client.get( - _MOONSHOT_OPENAI_MODELS_URL, - headers=self._model_list_headers(), - ) - - def _model_list_headers(self) -> dict[str, str]: - return {"Authorization": f"Bearer {self._api_key}"} +def _apply_kimi_thinking_policy( + body: dict[str, Any], _request: Any, thinking_enabled: bool +) -> None: + if thinking_enabled: + return + extra_body = body.setdefault("extra_body", {}) + if isinstance(extra_body, dict): + extra_body["thinking"] = {"type": "disabled"} diff --git a/providers/minimax/client.py b/providers/minimax/client.py index d53eb2c5..7c66736e 100644 --- a/providers/minimax/client.py +++ b/providers/minimax/client.py @@ -1,27 +1,32 @@ -"""MiniMax provider implementation (Anthropic-compatible Messages API).""" +"""MiniMax provider implementation (OpenAI-compatible Chat Completions).""" from typing import Any +from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS from providers.base import ProviderConfig from providers.defaults import MINIMAX_DEFAULT_BASE -from providers.transports.anthropic_messages import ( - AnthropicMessagesTransport, - NativeMessagesRequestPolicy, - build_native_messages_request_body, +from providers.transports.openai_chat import ( + OpenAIChatRequestPolicy, + OpenAIChatTransport, + build_openai_chat_request_body, ) -_ANTHROPIC_VERSION = "2023-06-01" -_REQUEST_POLICY = NativeMessagesRequestPolicy(provider_name="MINIMAX") +_REQUEST_POLICY = OpenAIChatRequestPolicy( + provider_name="MINIMAX", + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + max_tokens_field="max_completion_tokens", +) -class MiniMaxProvider(AnthropicMessagesTransport): - """MiniMax using Anthropic-compatible Messages at api.minimax.io/anthropic/v1.""" +class MiniMaxProvider(OpenAIChatTransport): + """MiniMax using ``https://api.minimax.io/v1/chat/completions``.""" def __init__(self, config: ProviderConfig): super().__init__( config, provider_name="MINIMAX", - default_base_url=MINIMAX_DEFAULT_BASE, + base_url=config.base_url or MINIMAX_DEFAULT_BASE, + api_key=config.api_key, ) def _build_request_body( @@ -30,32 +35,21 @@ class MiniMaxProvider(AnthropicMessagesTransport): effective_thinking_enabled = self._is_thinking_enabled( request, thinking_enabled ) - return build_native_messages_request_body( + return build_openai_chat_request_body( request, thinking_enabled=effective_thinking_enabled, policy=_REQUEST_POLICY, postprocessors=(_apply_minimax_thinking_policy,), ) - def _request_headers(self) -> dict[str, str]: - return { - "Accept": "text/event-stream", - "Content-Type": "application/json", - "x-api-key": self._api_key, - "anthropic-version": _ANTHROPIC_VERSION, - } - - def _model_list_headers(self) -> dict[str, str]: - return { - "x-api-key": self._api_key, - "anthropic-version": _ANTHROPIC_VERSION, - } - def _apply_minimax_thinking_policy( body: dict[str, Any], _request: Any, thinking_enabled: bool ) -> None: - """Use MiniMax's documented Anthropic thinking control values.""" - body["thinking"] = ( + extra_body = body.setdefault("extra_body", {}) + if not isinstance(extra_body, dict): + return + extra_body["reasoning_split"] = True + extra_body["thinking"] = ( {"type": "adaptive"} if thinking_enabled else {"type": "disabled"} ) diff --git a/providers/open_router/__init__.py b/providers/open_router/__init__.py index df124963..8694628d 100644 --- a/providers/open_router/__init__.py +++ b/providers/open_router/__init__.py @@ -1,4 +1,4 @@ -"""OpenRouter provider - Anthropic-compatible native transport.""" +"""OpenRouter provider exports.""" from providers.defaults import OPENROUTER_DEFAULT_BASE diff --git a/providers/open_router/client.py b/providers/open_router/client.py index d2d7beb0..8dba3c5f 100644 --- a/providers/open_router/client.py +++ b/providers/open_router/client.py @@ -1,15 +1,11 @@ """OpenRouter provider implementation.""" -from collections.abc import Iterator +import json +from collections.abc import Iterator, Mapping, Sequence from typing import Any -from core.anthropic import iter_provider_stream_error_sse_events -from core.anthropic.native_sse_block_policy import ( - NativeSseBlockPolicyState, - is_terminal_openrouter_done_event, - parse_native_sse_event, - transform_native_sse_block_event, -) +from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from core.anthropic.streaming import AnthropicStreamLedger from providers.base import ProviderConfig from providers.defaults import OPENROUTER_DEFAULT_BASE from providers.model_listing import ( @@ -17,111 +13,222 @@ from providers.model_listing import ( extract_openrouter_tool_model_ids, extract_openrouter_tool_model_infos, ) -from providers.transports.anthropic_messages import ( - AnthropicMessagesTransport, - NativeMessagesRequestPolicy, - StreamChunkMode, - build_native_messages_request_body, +from providers.transports.openai_chat import ( + OpenAIChatRequestPolicy, + OpenAIChatTransport, + build_openai_chat_request_body, +) +from providers.transports.openai_chat.extra_body import ( + validate_extra_body_does_not_override_canonical_fields, ) -_ANTHROPIC_VERSION = "2023-06-01" -_REQUEST_POLICY = NativeMessagesRequestPolicy( +_REQUEST_POLICY = OpenAIChatRequestPolicy( provider_name="OPENROUTER", - extra_body="openrouter", + include_extra_body=True, + extra_body_validator=validate_extra_body_does_not_override_canonical_fields, + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, ) -class OpenRouterProvider(AnthropicMessagesTransport): - """OpenRouter provider using the native Anthropic-compatible messages API.""" - - stream_chunk_mode: StreamChunkMode = "event" +class OpenRouterProvider(OpenAIChatTransport): + """OpenRouter provider using the OpenAI-compatible Chat Completions API.""" def __init__(self, config: ProviderConfig): super().__init__( config, provider_name="OPENROUTER", - default_base_url=OPENROUTER_DEFAULT_BASE, + base_url=config.base_url or OPENROUTER_DEFAULT_BASE, + api_key=config.api_key, ) def _build_request_body( self, request: Any, thinking_enabled: bool | None = None ) -> dict: - """Internal helper for tests and direct request dispatch.""" - return build_native_messages_request_body( + effective_thinking_enabled = self._is_thinking_enabled( + request, thinking_enabled + ) + return build_openai_chat_request_body( request, - thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), + thinking_enabled=effective_thinking_enabled, policy=_REQUEST_POLICY, + postprocessors=( + _apply_openrouter_reasoning_policy, + _apply_openrouter_reasoning_details_replay, + ), ) - def _request_headers(self) -> dict[str, str]: - """Return OpenRouter's Anthropic-compatible messages headers.""" - return { - "Accept": "text/event-stream", - "Authorization": f"Bearer {self._api_key}", - "Content-Type": "application/json", - "anthropic-version": _ANTHROPIC_VERSION, - } - - def _model_list_headers(self) -> dict[str, str]: - """Return OpenRouter's OpenAI-compatible model-list headers.""" - return {"Authorization": f"Bearer {self._api_key}"} - - def _extract_model_ids_from_model_list_payload( - self, payload: Any - ) -> frozenset[str]: + async def list_model_ids(self) -> frozenset[str]: """Only advertise OpenRouter models that can run Claude Code tools.""" + payload = await self._client.models.list() return extract_openrouter_tool_model_ids( payload, provider_name=self._provider_name ) - def _extract_model_infos_from_model_list_payload( - self, payload: Any - ) -> frozenset[ProviderModelInfo]: + async def list_model_infos(self) -> frozenset[ProviderModelInfo]: """Advertise OpenRouter tool models with reasoning capability metadata.""" + payload = await self._client.models.list() return extract_openrouter_tool_model_infos( payload, provider_name=self._provider_name ) - def _new_stream_state(self, request: Any, *, thinking_enabled: bool) -> Any: - """Create per-stream state for thinking block filtering.""" - return NativeSseBlockPolicyState() - - def _transform_stream_event( - self, - event: str, - state: Any, - *, - thinking_enabled: bool, - ) -> str | None: - """Drop provider-specific terminal noise and hidden thinking events.""" - if isinstance(state, NativeSseBlockPolicyState): - event_name, data_text = parse_native_sse_event(event) - if state.message_stopped or is_terminal_openrouter_done_event( - event_name, data_text - ): - return None - if event_name == "message_stop": - state.message_stopped = True - - if isinstance(state, NativeSseBlockPolicyState): - return transform_native_sse_block_event( - event, state, thinking_enabled=thinking_enabled - ) - return event - - def _emit_error_events( - self, - *, - request: Any, - input_tokens: int, - error_message: str, - sent_any_event: bool, + def _handle_extra_reasoning( + self, delta: Any, ledger: AnthropicStreamLedger, *, thinking_enabled: bool ) -> Iterator[str]: - """Emit the Anthropic SSE error shape expected by Claude clients.""" - yield from iter_provider_stream_error_sse_events( - request=request, - input_tokens=input_tokens, - error_message=error_message, - sent_any_event=sent_any_event, - log_raw_sse_events=self._config.log_raw_sse_events, - ) + """Map OpenRouter reasoning details onto Anthropic thinking blocks.""" + if not thinking_enabled: + return iter(()) + return _iter_openrouter_reasoning_detail_events(delta, ledger) + + +def _apply_openrouter_reasoning_policy( + body: dict[str, Any], request: Any, thinking_enabled: bool +) -> None: + if not thinking_enabled: + return + extra_body = body.setdefault("extra_body", {}) + if not isinstance(extra_body, dict): + return + reasoning = extra_body.setdefault("reasoning", {"enabled": True}) + if not isinstance(reasoning, dict): + return + reasoning.setdefault("enabled", True) + budget_tokens = _thinking_budget_tokens(getattr(request, "thinking", None)) + if isinstance(budget_tokens, int): + reasoning.setdefault("max_tokens", budget_tokens) + + +def _apply_openrouter_reasoning_details_replay( + body: dict[str, Any], request: Any, thinking_enabled: bool +) -> None: + if not thinking_enabled: + return + assistant_details = _assistant_reasoning_details(getattr(request, "messages", [])) + if not assistant_details: + return + messages = body.get("messages") + if not isinstance(messages, list): + return + + cursor = 0 + for details in assistant_details: + for index in range(cursor, len(messages)): + message = messages[index] + if not isinstance(message, dict) or message.get("role") != "assistant": + continue + existing = message.get("reasoning_details") + if isinstance(existing, list): + existing.extend(details) + else: + message["reasoning_details"] = list(details) + cursor = index + 1 + break + + +def _assistant_reasoning_details(messages: Any) -> list[list[dict[str, Any]]]: + if not _is_sequence(messages): + return [] + result: list[list[dict[str, Any]]] = [] + for message in messages: + if _field(message, "role") != "assistant": + continue + details = _redacted_reasoning_details(_field(message, "content")) + if details: + result.append(details) + return result + + +def _redacted_reasoning_details(content: Any) -> list[dict[str, Any]]: + if not _is_sequence(content): + return [] + details: list[dict[str, Any]] = [] + for block in content: + if _field(block, "type") != "redacted_thinking": + continue + data = _field(block, "data") + if not isinstance(data, str) or not data: + continue + parsed = _json_payload(data) + if isinstance(parsed, list): + details.extend(item for item in parsed if isinstance(item, dict)) + elif isinstance(parsed, dict): + details.append(parsed) + else: + details.append({"type": "reasoning.encrypted", "data": data}) + return details + + +def _thinking_budget_tokens(thinking: Any) -> int | None: + if isinstance(thinking, Mapping): + value = thinking.get("budget_tokens") + else: + value = getattr(thinking, "budget_tokens", None) + return value if isinstance(value, int) and not isinstance(value, bool) else None + + +def _iter_openrouter_reasoning_detail_events( + delta: Any, ledger: AnthropicStreamLedger +) -> Iterator[str]: + details = _field(delta, "reasoning_details") + if details is None: + extra = _field(delta, "model_extra") + if isinstance(extra, Mapping): + details = extra.get("reasoning_details") + if not _is_sequence(details): + return + + native_reasoning = _field(delta, "reasoning_content") + has_native_reasoning = isinstance(native_reasoning, str) and bool(native_reasoning) + for detail in details: + encrypted = _reasoning_detail_encrypted(detail) + if encrypted: + yield from ledger.close_content_blocks() + index = ledger.blocks.allocate_index() + yield ledger.content_block_start(index, "redacted_thinking", data=encrypted) + yield ledger.content_block_stop(index) + continue + if has_native_reasoning: + continue + text = _reasoning_detail_text(detail) + if not text: + continue + yield from ledger.ensure_thinking_block() + yield ledger.emit_thinking_delta(text) + + +def _reasoning_detail_text(detail: Any) -> str | None: + kind = str(_field(detail, "type") or "").lower() + if "encrypted" in kind or "redacted" in kind: + return None + for key in ("text", "content", "reasoning"): + value = _field(detail, key) + if isinstance(value, str) and value: + return value + return None + + +def _reasoning_detail_encrypted(detail: Any) -> str | None: + kind = str(_field(detail, "type") or "").lower() + if "encrypted" not in kind and "redacted" not in kind and "summary" not in kind: + return None + if isinstance(detail, Mapping): + return json.dumps(dict(detail), separators=(",", ":")) + return None + + +def _json_payload(value: str) -> Any: + try: + return json.loads(value) + except json.JSONDecodeError: + return None + + +def _field(item: Any, name: str) -> Any: + if isinstance(item, Mapping): + return item.get(name) + return getattr(item, name, None) + + +def _is_sequence(value: Any) -> bool: + return isinstance(value, Sequence) and not isinstance( + value, str | bytes | bytearray + ) diff --git a/providers/transports/anthropic_messages/request_policy.py b/providers/transports/anthropic_messages/request_policy.py index f24d044d..728a869f 100644 --- a/providers/transports/anthropic_messages/request_policy.py +++ b/providers/transports/anthropic_messages/request_policy.py @@ -8,14 +8,11 @@ from loguru import logger from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS from core.anthropic.native_messages_request import ( - OpenRouterExtraBodyError, build_base_native_anthropic_request_body, - build_openrouter_native_request_body, - validate_openrouter_extra_body, ) from providers.exceptions import InvalidRequestError -NativeExtraBodyPolicy = Literal["drop", "reject", "merge_validated", "openrouter"] +NativeExtraBodyPolicy = Literal["drop", "reject"] NativeMessagesPostprocessor = Callable[[dict[str, Any], Any, bool], None] @@ -45,21 +42,14 @@ def build_native_messages_request_body( len(getattr(request_data, "messages", [])), ) - if policy.extra_body == "openrouter": - body = _build_openrouter_body( - request_data, - thinking_enabled=thinking_enabled, - policy=policy, - ) - else: - body = build_base_native_anthropic_request_body( - request_data, - default_max_tokens=policy.default_max_tokens, - thinking_enabled=thinking_enabled, - ) - _apply_extra_body_policy(body, request_data, policy) - if policy.force_stream: - body["stream"] = True + body = build_base_native_anthropic_request_body( + request_data, + default_max_tokens=policy.default_max_tokens, + thinking_enabled=thinking_enabled, + ) + _apply_extra_body_policy(body, request_data, policy) + if policy.force_stream: + body["stream"] = True for postprocess in postprocessors: postprocess(body, request_data, thinking_enabled) @@ -74,22 +64,6 @@ def build_native_messages_request_body( return body -def _build_openrouter_body( - request_data: Any, - *, - thinking_enabled: bool, - policy: NativeMessagesRequestPolicy, -) -> dict[str, Any]: - try: - return build_openrouter_native_request_body( - request_data, - thinking_enabled=thinking_enabled, - default_max_tokens=policy.default_max_tokens, - ) - except OpenRouterExtraBodyError as exc: - raise InvalidRequestError(str(exc)) from exc - - def _apply_extra_body_policy( body: dict[str, Any], request_data: Any, @@ -107,13 +81,4 @@ def _apply_extra_body_policy( or f"{policy.provider_name} native Messages API does not support extra_body on requests." ) raise InvalidRequestError(message) - if policy.extra_body == "merge_validated": - if isinstance(extra, dict): - try: - validate_openrouter_extra_body(extra) - except OpenRouterExtraBodyError as exc: - raise InvalidRequestError(str(exc)) from exc - body.update(extra) - return - raise AssertionError(f"Unhandled native extra_body policy: {policy.extra_body}") diff --git a/providers/transports/openai_chat/extra_body.py b/providers/transports/openai_chat/extra_body.py new file mode 100644 index 00000000..7f08b1f7 --- /dev/null +++ b/providers/transports/openai_chat/extra_body.py @@ -0,0 +1,32 @@ +"""Validation helpers for OpenAI-chat ``extra_body`` passthrough.""" + +from typing import Any + +CANONICAL_OPENAI_CHAT_BODY_KEYS = frozenset( + { + "model", + "messages", + "tools", + "tool_choice", + "stream", + "max_tokens", + "max_completion_tokens", + "temperature", + "top_p", + "metadata", + "stop", + "stop_sequences", + "stream_options", + } +) + + +def validate_extra_body_does_not_override_canonical_fields( + extra: dict[str, Any], +) -> None: + """Reject extras that would replace FCC-owned chat-completion fields.""" + bad = CANONICAL_OPENAI_CHAT_BODY_KEYS & extra.keys() + if bad: + raise ValueError( + f"extra_body must not override canonical request fields: {sorted(bad)}" + ) diff --git a/providers/transports/openai_chat/request_policy.py b/providers/transports/openai_chat/request_policy.py index 4f575c4c..dace5c38 100644 --- a/providers/transports/openai_chat/request_policy.py +++ b/providers/transports/openai_chat/request_policy.py @@ -13,6 +13,7 @@ from providers.exceptions import InvalidRequestError MaxTokensField = Literal["max_tokens", "max_completion_tokens"] OpenAIChatPostprocessor = Callable[[dict[str, Any], Any, bool], None] +ExtraBodyValidator = Callable[[dict[str, Any]], None] @dataclass(frozen=True, slots=True) @@ -21,6 +22,9 @@ class OpenAIChatRequestPolicy: provider_name: str include_extra_body: bool = False + extra_body_validator: ExtraBodyValidator | None = None + reject_extra_body_message: str | None = None + default_max_tokens: int | None = None max_tokens_field: MaxTokensField = "max_tokens" strip_message_names: bool = False unsupported_body_keys: frozenset[str] = field(default_factory=frozenset) @@ -44,6 +48,7 @@ def build_openai_chat_request_body( try: body = build_base_request_body( request_data, + default_max_tokens=policy.default_max_tokens, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT if thinking_enabled else ReasoningReplayMode.DISABLED, @@ -51,10 +56,18 @@ def build_openai_chat_request_body( except OpenAIConversionError as exc: raise InvalidRequestError(str(exc)) from exc - if policy.include_extra_body: - request_extra = getattr(request_data, "extra_body", None) - if isinstance(request_extra, dict) and request_extra: - body["extra_body"] = deepcopy(request_extra) + request_extra = getattr(request_data, "extra_body", None) + if isinstance(request_extra, dict) and request_extra: + if policy.reject_extra_body_message: + raise InvalidRequestError(policy.reject_extra_body_message) + if policy.include_extra_body: + extra_body = deepcopy(request_extra) + if policy.extra_body_validator is not None: + try: + policy.extra_body_validator(extra_body) + except ValueError as exc: + raise InvalidRequestError(str(exc)) from exc + body["extra_body"] = extra_body _apply_common_openai_chat_policy(body, policy) diff --git a/providers/wafer/client.py b/providers/wafer/client.py index 9b875c87..aaccb680 100644 --- a/providers/wafer/client.py +++ b/providers/wafer/client.py @@ -1,50 +1,52 @@ -"""Wafer provider implementation (native Anthropic-compatible Messages).""" +"""Wafer provider implementation (OpenAI-compatible Chat Completions).""" from typing import Any +from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS from providers.base import ProviderConfig from providers.defaults import WAFER_DEFAULT_BASE -from providers.transports.anthropic_messages import AnthropicMessagesTransport +from providers.transports.openai_chat import ( + OpenAIChatRequestPolicy, + OpenAIChatTransport, + build_openai_chat_request_body, +) -_ANTHROPIC_VERSION = "2023-06-01" +_REQUEST_POLICY = OpenAIChatRequestPolicy( + provider_name="WAFER", + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, +) -class WaferProvider(AnthropicMessagesTransport): - """Wafer using ``https://pass.wafer.ai/v1/messages``.""" +class WaferProvider(OpenAIChatTransport): + """Wafer using ``https://pass.wafer.ai/v1/chat/completions``.""" def __init__(self, config: ProviderConfig): super().__init__( config, provider_name="WAFER", - default_base_url=WAFER_DEFAULT_BASE, + base_url=config.base_url or WAFER_DEFAULT_BASE, + api_key=config.api_key, ) def _build_request_body( self, request: Any, thinking_enabled: bool | None = None ) -> dict: - """Build native body; Wafer rejects omitted thinking as ``reasoning_effort=none``.""" effective_thinking_enabled = self._is_thinking_enabled( request, thinking_enabled ) - body = self._build_request_body_with_resolved_thinking( + return build_openai_chat_request_body( request, thinking_enabled=effective_thinking_enabled, + policy=_REQUEST_POLICY, + postprocessors=(_apply_wafer_thinking_policy,), ) - if "thinking" not in body: - body["thinking"] = ( - {"type": "enabled"} - if effective_thinking_enabled - else {"type": "disabled"} - ) - return body - def _request_headers(self) -> dict[str, str]: - return { - "Accept": "text/event-stream", - "Authorization": f"Bearer {self._api_key}", - "Content-Type": "application/json", - "anthropic-version": _ANTHROPIC_VERSION, - } - def _model_list_headers(self) -> dict[str, str]: - return {"Authorization": f"Bearer {self._api_key}"} +def _apply_wafer_thinking_policy( + body: dict[str, Any], _request: Any, thinking_enabled: bool +) -> None: + extra_body = body.setdefault("extra_body", {}) + if isinstance(extra_body, dict): + extra_body["thinking"] = ( + {"type": "enabled"} if thinking_enabled else {"type": "disabled"} + ) diff --git a/providers/zai/client.py b/providers/zai/client.py index fd89414a..f7681f60 100644 --- a/providers/zai/client.py +++ b/providers/zai/client.py @@ -1,54 +1,58 @@ -"""Z.ai provider implementation (Anthropic-compatible Messages API).""" +"""Z.ai provider implementation (OpenAI-compatible Coding Plan Chat Completions).""" from typing import Any +from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS from providers.base import ProviderConfig from providers.defaults import ZAI_DEFAULT_BASE -from providers.transports.anthropic_messages import ( - AnthropicMessagesTransport, - NativeMessagesRequestPolicy, - build_native_messages_request_body, +from providers.transports.openai_chat import ( + OpenAIChatRequestPolicy, + OpenAIChatTransport, + build_openai_chat_request_body, ) -_ANTHROPIC_VERSION = "2023-06-01" -_REQUEST_POLICY = NativeMessagesRequestPolicy( +_REQUEST_POLICY = OpenAIChatRequestPolicy( provider_name="ZAI", - extra_body="reject", reject_extra_body_message=( - "Z.ai native Messages API does not support extra_body on requests." + "Z.ai Chat Completions API does not support caller extra_body on requests." ), + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, ) -class ZaiProvider(AnthropicMessagesTransport): - """Z.ai using Anthropic-compatible Messages at api.z.ai/api/anthropic/v1.""" +class ZaiProvider(OpenAIChatTransport): + """Z.ai Coding Plan via ``https://api.z.ai/api/coding/paas/v4``.""" def __init__(self, config: ProviderConfig): super().__init__( config, provider_name="ZAI", - default_base_url=ZAI_DEFAULT_BASE, + base_url=config.base_url or ZAI_DEFAULT_BASE, + api_key=config.api_key, ) def _build_request_body( self, request: Any, thinking_enabled: bool | None = None ) -> dict: - return build_native_messages_request_body( + effective_thinking_enabled = self._is_thinking_enabled( + request, thinking_enabled + ) + return build_openai_chat_request_body( request, - thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), + thinking_enabled=effective_thinking_enabled, policy=_REQUEST_POLICY, + postprocessors=(_apply_zai_thinking_policy,), ) - def _request_headers(self) -> dict[str, str]: - return { - "Accept": "text/event-stream", - "Content-Type": "application/json", - "x-api-key": self._api_key, - "anthropic-version": _ANTHROPIC_VERSION, - } - def _model_list_headers(self) -> dict[str, str]: - return { - "x-api-key": self._api_key, - "anthropic-version": _ANTHROPIC_VERSION, - } +def _apply_zai_thinking_policy( + body: dict[str, Any], _request: Any, thinking_enabled: bool +) -> None: + extra_body = body.setdefault("extra_body", {}) + if not isinstance(extra_body, dict): + return + extra_body["thinking"] = ( + {"type": "enabled", "clear_thinking": False} + if thinking_enabled + else {"type": "disabled"} + ) diff --git a/pyproject.toml b/pyproject.toml index 814efbb7..c9c63115 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "3.4.10" +version = "3.4.11" description = "Middleware between Claude Code CLI (Anthropic API) and NVIDIA NIM" readme = "README.md" requires-python = ">=3.14.0" diff --git a/smoke/capabilities.py b/smoke/capabilities.py index 9c20ac45..cd1f1797 100644 --- a/smoke/capabilities.py +++ b/smoke/capabilities.py @@ -319,15 +319,15 @@ CAPABILITY_CONTRACTS: tuple[CapabilityContract, ...] = ( ), CapabilityContract( "openrouter", - "native_anthropic_messages", + "openai_chat_provider", "provider_matrix", "providers.open_router.OpenRouterProvider", - "Anthropic Messages request for OpenRouter", - "native OpenRouter Anthropic SSE", + "OpenAI Chat request for OpenRouter", + "OpenAI stream mapped to Anthropic SSE", "Anthropic SSE error shape", ( "tests/providers/test_open_router.py", - "tests/providers/test_anthropic_messages.py", + "tests/providers/test_openai_compat_5xx_retry.py", ), ("test_configured_provider_models_stream_successfully",), ), diff --git a/tests/contracts/test_import_boundaries.py b/tests/contracts/test_import_boundaries.py index 18e48e0e..7018c6ad 100644 --- a/tests/contracts/test_import_boundaries.py +++ b/tests/contracts/test_import_boundaries.py @@ -221,6 +221,34 @@ def test_provider_transports_live_under_transport_family_packages() -> None: assert offenders == [] +def test_native_anthropic_transport_is_local_provider_only() -> None: + from config.provider_catalog import PROVIDER_CATALOG + + native_ids = { + provider_id + for provider_id, descriptor in PROVIDER_CATALOG.items() + if descriptor.transport_type == "anthropic_messages" + } + + assert native_ids == {"llamacpp", "ollama"} + for provider_id in native_ids: + assert "local" in PROVIDER_CATALOG[provider_id].capabilities + + +def test_cloud_providers_do_not_import_native_anthropic_transport() -> None: + repo_root = Path(__file__).resolve().parents[2] + + for provider_dir in ("open_router", "wafer", "kimi", "minimax", "fireworks", "zai"): + provider_root = repo_root / "providers" / provider_dir + occurrences = [ + path.relative_to(repo_root).as_posix() + for path in provider_root.rglob("*.py") + if "providers.transports.anthropic_messages" + in path.read_text(encoding="utf-8") + ] + assert occurrences == [] + + def test_provider_request_policy_lives_with_transport_families() -> None: repo_root = Path(__file__).resolve().parents[2] providers_root = repo_root / "providers" @@ -257,6 +285,19 @@ def test_provider_request_policy_lives_with_transport_families() -> None: assert offenders == [] +def test_anthropic_core_has_no_cloud_provider_native_policy() -> None: + repo_root = Path(__file__).resolve().parents[2] + anthropic_core = repo_root / "core" / "anthropic" + + occurrences: list[str] = [] + for path in anthropic_core.rglob("*.py"): + text = path.read_text(encoding="utf-8") + if "OpenRouter" in text or "openrouter" in text: + occurrences.append(path.relative_to(repo_root).as_posix()) + + assert occurrences == [] + + def test_anthropic_stream_engine_owns_provider_stream_state() -> None: repo_root = Path(__file__).resolve().parents[2] anthropic_root = repo_root / "core" / "anthropic" diff --git a/tests/providers/test_fireworks.py b/tests/providers/test_fireworks.py index a999e6cc..3d123e0d 100644 --- a/tests/providers/test_fireworks.py +++ b/tests/providers/test_fireworks.py @@ -1,4 +1,4 @@ -"""Tests for Fireworks AI native Anthropic Messages provider.""" +"""Tests for the Fireworks AI OpenAI-chat provider.""" from contextlib import asynccontextmanager from unittest.mock import AsyncMock, MagicMock, patch @@ -10,17 +10,7 @@ from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS from providers.base import ProviderConfig from providers.exceptions import InvalidRequestError from providers.fireworks import FIREWORKS_BASE_URL, FireworksProvider - - -@pytest.fixture -def fireworks_config(): - return ProviderConfig( - api_key="test_fireworks_key", - base_url=FIREWORKS_BASE_URL, - rate_limit=10, - rate_window=60, - enable_thinking=True, - ) +from providers.transports.openai_chat import OpenAIChatTransport @pytest.fixture(autouse=True) @@ -29,9 +19,7 @@ def mock_rate_limiter(): async def _slot(): yield - with patch( - "providers.transports.anthropic_messages.transport.GlobalRateLimiter" - ) as mock: + with patch("providers.transports.openai_chat.transport.GlobalRateLimiter") as mock: instance = mock.get_scoped_instance.return_value async def _passthrough(fn, *args, **kwargs): @@ -43,42 +31,44 @@ def mock_rate_limiter(): @pytest.fixture -def fireworks_provider(fireworks_config): - return FireworksProvider(fireworks_config) +def fireworks_provider(): + return FireworksProvider( + ProviderConfig( + api_key="test_fireworks_key", + base_url=FIREWORKS_BASE_URL, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + ) -def test_init(fireworks_config): - with patch("httpx.AsyncClient") as mock_client: - provider = FireworksProvider(fireworks_config) - assert provider._api_key == "test_fireworks_key" - assert provider._base_url == FIREWORKS_BASE_URL - assert mock_client.called +def test_init_uses_openai_chat_transport(fireworks_provider): + assert isinstance(fireworks_provider, OpenAIChatTransport) + assert fireworks_provider._api_key == "test_fireworks_key" + assert fireworks_provider._base_url == FIREWORKS_BASE_URL def test_base_url_constant(): assert FIREWORKS_BASE_URL == "https://api.fireworks.ai/inference/v1" -def test_request_headers(fireworks_provider): - h = fireworks_provider._request_headers() - assert h["Authorization"] == "Bearer test_fireworks_key" - assert h["anthropic-version"] == "2023-06-01" - assert h["Accept"] == "text/event-stream" - - -def test_build_request_body_native_shape(fireworks_provider): +def test_build_request_body_openai_chat_shape(fireworks_provider): request = MessagesRequest( model="accounts/fireworks/models/glm-5p1", max_tokens=100, messages=[Message(role="user", content="Hello")], system="System prompt", ) + body = fireworks_provider._build_request_body(request) + assert body["model"] == "accounts/fireworks/models/glm-5p1" - assert body["stream"] is True assert body["max_tokens"] == 100 - assert body["system"] == "System prompt" - assert body["messages"][0]["role"] == "user" + assert body["messages"] == [ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": "Hello"}, + ] def test_build_request_body_default_max_tokens(fireworks_provider): @@ -86,7 +76,9 @@ def test_build_request_body_default_max_tokens(fireworks_provider): model="m", messages=[Message(role="user", content="x")], ) + body = fireworks_provider._build_request_body(request) + assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS @@ -103,27 +95,21 @@ def test_build_request_body_global_disable_blocks_thinking(): request = MessagesRequest.model_validate( { "model": "m", - "messages": [{"role": "user", "content": "x"}], - "thinking": {"type": "enabled", "budget_tokens": 1}, + "messages": [ + { + "role": "assistant", + "content": [{"type": "thinking", "thinking": "hidden"}], + } + ], } ) + body = provider._build_request_body(request) - assert "thinking" not in body + + assert "reasoning_content" not in body["messages"][0] -def test_build_request_body_request_disable_blocks_thinking(fireworks_provider): - request = MessagesRequest.model_validate( - { - "model": "m", - "messages": [{"role": "user", "content": "x"}], - "thinking": {"enabled": False}, - } - ) - body = fireworks_provider._build_request_body(request) - assert "thinking" not in body - - -def test_build_request_body_merges_safe_extra_body(fireworks_provider): +def test_build_request_body_preserves_validated_extra_body(fireworks_provider): request = MessagesRequest.model_validate( { "model": "m", @@ -131,8 +117,10 @@ def test_build_request_body_merges_safe_extra_body(fireworks_provider): "extra_body": {"custom_param": "value"}, } ) + body = fireworks_provider._build_request_body(request) - assert body["custom_param"] == "value" + + assert body["extra_body"] == {"custom_param": "value"} def test_build_request_body_rejects_reserved_extra_body_keys(fireworks_provider): @@ -143,43 +131,16 @@ def test_build_request_body_rejects_reserved_extra_body_keys(fireworks_provider) "extra_body": {"temperature": 0.1}, } ) + with pytest.raises(InvalidRequestError, match="extra_body must not override"): fireworks_provider._build_request_body(request) @pytest.mark.asyncio -async def test_stream_uses_post_messages_path(fireworks_provider): - request = MessagesRequest( - model="m", - messages=[Message(role="user", content="hi")], - ) - called: dict[str, str] = {} - - async def fake_send(request, *args, **kwargs): - called["path"] = request.url.path - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.is_closed = False - mock_resp.raise_for_status = lambda: None - - async def aiter(): - if False: # pragma: no cover - yield "" - - mock_resp.aiter_lines = aiter - mock_resp.aclose = AsyncMock() - return mock_resp - - fireworks_provider._client.send = fake_send - _ = [x async for x in fireworks_provider.stream_response(request, request_id="r1")] - - assert called["path"].endswith("/messages") - - -@pytest.mark.asyncio -async def test_cleanup_aclose(fireworks_provider): - fireworks_provider._client = AsyncMock() +async def test_cleanup_closes_openai_client(fireworks_provider): + fireworks_provider._client = MagicMock() + fireworks_provider._client.close = AsyncMock() await fireworks_provider.cleanup() - fireworks_provider._client.aclose.assert_awaited_once() + fireworks_provider._client.close.assert_awaited_once() diff --git a/tests/providers/test_kimi.py b/tests/providers/test_kimi.py index 5be45eb6..7e4fc6c6 100644 --- a/tests/providers/test_kimi.py +++ b/tests/providers/test_kimi.py @@ -1,6 +1,7 @@ -"""Tests for Kimi (Moonshot) native Anthropic Messages provider.""" +"""Tests for the Kimi OpenAI-chat provider.""" from contextlib import asynccontextmanager +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -11,17 +12,7 @@ from providers.base import ProviderConfig from providers.defaults import KIMI_DEFAULT_BASE from providers.exceptions import InvalidRequestError from providers.kimi import KimiProvider - - -@pytest.fixture -def kimi_config(): - return ProviderConfig( - api_key="test_kimi_key", - base_url=KIMI_DEFAULT_BASE, - rate_limit=10, - rate_window=60, - enable_thinking=True, - ) +from providers.transports.openai_chat import OpenAIChatTransport @pytest.fixture(autouse=True) @@ -30,9 +21,7 @@ def mock_rate_limiter(): async def _slot(): yield - with patch( - "providers.transports.anthropic_messages.transport.GlobalRateLimiter" - ) as mock: + with patch("providers.transports.openai_chat.transport.GlobalRateLimiter") as mock: instance = mock.get_scoped_instance.return_value async def _passthrough(fn, *args, **kwargs): @@ -44,34 +33,37 @@ def mock_rate_limiter(): @pytest.fixture -def kimi_provider(kimi_config): - return KimiProvider(kimi_config) +def kimi_provider(): + return KimiProvider( + ProviderConfig( + api_key="test_kimi_key", + base_url=KIMI_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + ) -def test_init(kimi_config): - with patch("httpx.AsyncClient") as mock_client: - provider = KimiProvider(kimi_config) - assert provider._api_key == "test_kimi_key" - assert provider._base_url == KIMI_DEFAULT_BASE - assert mock_client.called +def test_init_uses_openai_chat_transport(kimi_provider): + assert isinstance(kimi_provider, OpenAIChatTransport) + assert kimi_provider._api_key == "test_kimi_key" + assert kimi_provider._base_url == "https://api.moonshot.ai/v1" -def test_request_headers(kimi_provider): - h = kimi_provider._request_headers() - assert h["Authorization"] == "Bearer test_kimi_key" - assert h["anthropic-version"] == "2023-06-01" - - -def test_build_request_body_native(kimi_provider): +def test_build_request_body_openai_chat(kimi_provider): request = MessagesRequest( model="kimi-k2.5", max_tokens=50, messages=[Message(role="user", content="hi")], ) + body = kimi_provider._build_request_body(request) + assert body["model"] == "kimi-k2.5" - assert body["stream"] is True - assert body["messages"][0]["role"] == "user" + assert body["max_tokens"] == 50 + assert body["messages"] == [{"role": "user", "content": "hi"}] + assert "extra_body" not in body def test_build_request_body_default_max_tokens(kimi_provider): @@ -79,11 +71,13 @@ def test_build_request_body_default_max_tokens(kimi_provider): model="m", messages=[Message(role="user", content="x")], ) + body = kimi_provider._build_request_body(request) + assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS -def test_build_request_body_rejects_extra_body(kimi_provider): +def test_build_request_body_rejects_caller_extra_body(kimi_provider): request = MessagesRequest.model_validate( { "model": "m", @@ -91,33 +85,41 @@ def test_build_request_body_rejects_extra_body(kimi_provider): "extra_body": {"x": 1}, } ) - with pytest.raises(InvalidRequestError, match="does not support extra_body"): + + with pytest.raises(InvalidRequestError, match="Kimi Chat Completions"): kimi_provider._build_request_body(request) -@pytest.mark.asyncio -async def test_model_list_uses_moonshot_openai_url(kimi_provider): - called: dict[str, str] = {} +def test_build_request_body_disables_kimi_thinking(kimi_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "thinking": {"type": "disabled"}, + } + ) - async def fake_get(url: str, **_k): - called["url"] = url - mock_resp = MagicMock() - mock_resp.raise_for_status = lambda: None - mock_resp.json = lambda: {"data": [{"id": "kimi-k2.5"}]} - mock_resp.aclose = AsyncMock() - return mock_resp + body = kimi_provider._build_request_body(request) - kimi_provider._client.get = fake_get - - await kimi_provider.list_model_infos() - - assert called["url"] == "https://api.moonshot.ai/v1/models" + assert body["extra_body"]["thinking"] == {"type": "disabled"} @pytest.mark.asyncio -async def test_cleanup_aclose(kimi_provider): - kimi_provider._client = AsyncMock() +async def test_model_list_uses_openai_client_models_endpoint(kimi_provider): + kimi_provider._client.models.list = AsyncMock( + return_value=SimpleNamespace(data=[SimpleNamespace(id="kimi-k2.5")]) + ) + + assert await kimi_provider.list_model_ids() == frozenset({"kimi-k2.5"}) + + kimi_provider._client.models.list.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_cleanup_closes_openai_client(kimi_provider): + kimi_provider._client = MagicMock() + kimi_provider._client.close = AsyncMock() await kimi_provider.cleanup() - kimi_provider._client.aclose.assert_awaited_once() + kimi_provider._client.close.assert_awaited_once() diff --git a/tests/providers/test_minimax.py b/tests/providers/test_minimax.py index a096b7be..08fc02e1 100644 --- a/tests/providers/test_minimax.py +++ b/tests/providers/test_minimax.py @@ -1,44 +1,37 @@ -"""Tests for MiniMax native Anthropic Messages provider.""" +"""Tests for the MiniMax OpenAI-chat provider.""" from contextlib import asynccontextmanager +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch -import httpx import pytest from api.models.anthropic import Message, MessagesRequest, Tool -from core.anthropic.stream_contracts import parse_sse_text, thinking_content +from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from core.anthropic.stream_contracts import ( + parse_sse_text, + text_content, + thinking_content, +) from providers.base import ProviderConfig from providers.minimax import MINIMAX_DEFAULT_BASE, MiniMaxProvider +from providers.transports.openai_chat import OpenAIChatTransport -class FakeResponse: - def __init__(self, *, lines=None): - self.status_code = 200 - self._lines = lines or [] - self.is_closed = False - self.headers = httpx.Headers() - self.request = httpx.Request( - "POST", "https://api.minimax.io/anthropic/v1/messages" - ) +class AsyncStream: + def __init__(self, chunks): + self._chunks = chunks + self.closed = False - async def aiter_lines(self): - for line in self._lines: - yield line + def __aiter__(self): + return self._iter() + + async def _iter(self): + for chunk in self._chunks: + yield chunk async def aclose(self): - self.is_closed = True - - -@pytest.fixture -def minimax_config(): - return ProviderConfig( - api_key="test-minimax-key", - base_url=MINIMAX_DEFAULT_BASE, - rate_limit=10, - rate_window=60, - enable_thinking=True, - ) + self.closed = True @pytest.fixture(autouse=True) @@ -47,9 +40,7 @@ def mock_rate_limiter(): async def _slot(): yield - with patch( - "providers.transports.anthropic_messages.transport.GlobalRateLimiter" - ) as mock: + with patch("providers.transports.openai_chat.transport.GlobalRateLimiter") as mock: instance = mock.get_scoped_instance.return_value async def _passthrough(fn, *args, **kwargs): @@ -61,40 +52,47 @@ def mock_rate_limiter(): @pytest.fixture -def minimax_provider(minimax_config): - return MiniMaxProvider(minimax_config) +def minimax_provider(): + return MiniMaxProvider( + ProviderConfig( + api_key="test-minimax-key", + base_url=MINIMAX_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + ) + + +def _chunk( + *, + content: str | None = None, + reasoning_content: str | None = None, + finish_reason: str | None = None, +): + delta = SimpleNamespace( + content=content, + reasoning_content=reasoning_content, + tool_calls=None, + ) + return SimpleNamespace( + choices=[SimpleNamespace(delta=delta, finish_reason=finish_reason)], + usage=None, + ) def test_default_base_url(): - assert MINIMAX_DEFAULT_BASE == "https://api.minimax.io/anthropic/v1" + assert MINIMAX_DEFAULT_BASE == "https://api.minimax.io/v1" -def test_init_uses_default_base_url_and_strips_trailing_slash(minimax_config): - config = minimax_config.model_copy(update={"base_url": f"{MINIMAX_DEFAULT_BASE}/"}) - - with patch("httpx.AsyncClient"): - provider = MiniMaxProvider(config) - - assert provider._api_key == "test-minimax-key" - assert provider._base_url == MINIMAX_DEFAULT_BASE - assert provider._provider_name == "MINIMAX" +def test_init_uses_openai_chat_transport(minimax_provider): + assert isinstance(minimax_provider, OpenAIChatTransport) + assert minimax_provider._api_key == "test-minimax-key" + assert minimax_provider._base_url == MINIMAX_DEFAULT_BASE + assert minimax_provider._provider_name == "MINIMAX" -def test_headers_use_x_api_key(minimax_provider): - headers = minimax_provider._request_headers() - - assert headers["x-api-key"] == "test-minimax-key" - assert headers["Accept"] == "text/event-stream" - assert headers["Content-Type"] == "application/json" - assert headers["anthropic-version"] == "2023-06-01" - assert "Authorization" not in headers - assert minimax_provider._model_list_headers() == { - "x-api-key": "test-minimax-key", - "anthropic-version": "2023-06-01", - } - - -def test_build_request_body_uses_adaptive_thinking_and_preserves_tools( +def test_build_request_body_uses_adaptive_thinking_and_max_completion_tokens( minimax_provider, ): request = MessagesRequest.model_validate( @@ -115,20 +113,11 @@ def test_build_request_body_uses_adaptive_thinking_and_preserves_tools( body = minimax_provider._build_request_body(request) assert body["model"] == "MiniMax-M3" - assert body["tools"][0]["name"] == "echo" - assert body["thinking"] == {"type": "adaptive"} - assert body["stream"] is True - - -def test_build_request_body_enables_adaptive_thinking_by_default(minimax_provider): - request = MessagesRequest( - model="MiniMax-M3", - messages=[Message(role="user", content="Hello")], - ) - - body = minimax_provider._build_request_body(request) - - assert body["thinking"] == {"type": "adaptive"} + assert body["tools"][0]["function"]["name"] == "echo" + assert body["max_completion_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + assert "max_tokens" not in body + assert body["extra_body"]["reasoning_split"] is True + assert body["extra_body"]["thinking"] == {"type": "adaptive"} def test_build_request_body_honors_no_thinking(minimax_provider): @@ -139,117 +128,56 @@ def test_build_request_body_honors_no_thinking(minimax_provider): body = minimax_provider._build_request_body(request, thinking_enabled=False) - assert body["thinking"] == {"type": "disabled"} - - -def test_build_request_body_preserves_request_disabled_thinking(minimax_provider): - request = MessagesRequest.model_validate( - { - "model": "MiniMax-M3", - "messages": [{"role": "user", "content": "Hello"}], - "thinking": {"type": "disabled"}, - } - ) - - body = minimax_provider._build_request_body(request, thinking_enabled=True) - - assert body["thinking"] == {"type": "disabled"} + assert body["extra_body"]["thinking"] == {"type": "disabled"} @pytest.mark.asyncio -async def test_lists_models_from_anthropic_compatible_models_endpoint( - minimax_provider, -): - with patch.object( - minimax_provider._client, - "get", - new_callable=AsyncMock, - return_value=httpx.Response( - 200, - json={ - "data": [ - {"id": "MiniMax-M3", "type": "model"}, - {"id": "MiniMax-M2.7", "type": "model"}, - ] - }, - request=httpx.Request("GET", "https://api.minimax.io/anthropic/v1/models"), - ), - ) as mock_get: - assert await minimax_provider.list_model_ids() == frozenset( - {"MiniMax-M3", "MiniMax-M2.7"} +async def test_lists_models_from_openai_models_endpoint(minimax_provider): + minimax_provider._client.models.list = AsyncMock( + return_value=SimpleNamespace( + data=[SimpleNamespace(id="MiniMax-M3"), SimpleNamespace(id="MiniMax-M2.7")] ) + ) - mock_get.assert_awaited_once_with( - "/models", - headers={ - "x-api-key": "test-minimax-key", - "anthropic-version": "2023-06-01", - }, + assert await minimax_provider.list_model_ids() == frozenset( + {"MiniMax-M3", "MiniMax-M2.7"} ) @pytest.mark.asyncio -async def test_stream_uses_messages_path_and_preserves_native_thinking( - minimax_provider, -): +async def test_stream_preserves_reasoning_content(minimax_provider): request = MessagesRequest( model="MiniMax-M3", messages=[Message(role="user", content="hi")], ) - response = FakeResponse( - lines=[ - "event: message_start", - 'data: {"type":"message_start"}', - "", - "event: content_block_start", - 'data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}', - "", - "event: content_block_delta", - 'data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"plan"}}', - "", - "event: content_block_stop", - 'data: {"type":"content_block_stop","index":0}', - "", - "event: content_block_start", - 'data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}', - "", - "event: content_block_delta", - 'data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"done"}}', - "", - "event: content_block_stop", - 'data: {"type":"content_block_stop","index":1}', - "", - "event: message_stop", - 'data: {"type":"message_stop"}', - "", + stream = AsyncStream( + [ + _chunk(reasoning_content="plan"), + _chunk(content="done", finish_reason="stop"), ] ) - with ( - patch.object( - minimax_provider._client, "build_request", return_value=MagicMock() - ) as mock_build, - patch.object( - minimax_provider._client, - "send", - new_callable=AsyncMock, - return_value=response, - ), - ): + with patch.object( + minimax_provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream, + ) as create: events = [event async for event in minimax_provider.stream_response(request)] parsed = parse_sse_text("".join(events)) - assert [event.event for event in parsed] == [ - "message_start", - "content_block_start", - "content_block_delta", - "content_block_stop", - "content_block_start", - "content_block_delta", - "content_block_stop", - "message_stop", - ] assert thinking_content(parsed) == "plan" - assert response.is_closed - assert mock_build.call_args.args[:2] == ("POST", "/messages") - assert mock_build.call_args.kwargs["headers"]["x-api-key"] == "test-minimax-key" + assert text_content(parsed) == "done" + assert create.await_args is not None + assert create.await_args.kwargs["extra_body"]["reasoning_split"] is True + assert stream.closed + + +@pytest.mark.asyncio +async def test_cleanup_closes_openai_client(minimax_provider): + minimax_provider._client = MagicMock() + minimax_provider._client.close = AsyncMock() + + await minimax_provider.cleanup() + + minimax_provider._client.close.assert_awaited_once() diff --git a/tests/providers/test_model_validation.py b/tests/providers/test_model_validation.py index 0e7136f1..b05244bf 100644 --- a/tests/providers/test_model_validation.py +++ b/tests/providers/test_model_validation.py @@ -106,84 +106,74 @@ async def test_deepseek_lists_models_from_root_endpoint() -> None: async def test_wafer_lists_models_from_default_models_endpoint() -> None: provider = WaferProvider(ProviderConfig(api_key="wafer-key")) with patch.object( - provider._client, - "get", + provider._client.models, + "list", new_callable=AsyncMock, - return_value=_response(200, {"data": [{"id": "DeepSeek-V4-Pro"}]}), - ) as mock_get: + return_value=SimpleNamespace(data=[SimpleNamespace(id="DeepSeek-V4-Pro")]), + ) as mock_list: assert await provider.list_model_ids() == frozenset({"DeepSeek-V4-Pro"}) - mock_get.assert_awaited_once_with( - "/models", headers={"Authorization": "Bearer wafer-key"} - ) + mock_list.assert_awaited_once_with() @pytest.mark.asyncio async def test_openrouter_lists_only_tool_capable_models() -> None: provider = OpenRouterProvider(ProviderConfig(api_key="open-router-key")) with patch.object( - provider._client, - "get", + provider._client.models, + "list", new_callable=AsyncMock, - return_value=_response( - 200, - { - "data": [ - { - "id": "tool-model", - "supported_parameters": ["tools", "max_tokens"], - }, - { - "id": "tool-choice-model", - "supported_parameters": ["tool_choice"], - }, - { - "id": "chat-only", - "supported_parameters": ["max_tokens", "temperature"], - }, - {"id": "missing-metadata"}, - ] - }, + return_value=SimpleNamespace( + data=[ + SimpleNamespace( + id="tool-model", + supported_parameters=["tools", "max_tokens"], + ), + SimpleNamespace( + id="tool-choice-model", + supported_parameters=["tool_choice"], + ), + SimpleNamespace( + id="chat-only", + supported_parameters=["max_tokens", "temperature"], + ), + SimpleNamespace(id="missing-metadata", supported_parameters=None), + ] ), - ) as mock_get: + ) as mock_list: assert await provider.list_model_ids() == frozenset( {"tool-model", "tool-choice-model"} ) - mock_get.assert_awaited_once_with( - "/models", headers={"Authorization": "Bearer open-router-key"} - ) + mock_list.assert_awaited_once_with() @pytest.mark.asyncio async def test_openrouter_lists_tool_metadata_with_thinking_support() -> None: provider = OpenRouterProvider(ProviderConfig(api_key="open-router-key")) with patch.object( - provider._client, - "get", + provider._client.models, + "list", new_callable=AsyncMock, - return_value=_response( - 200, - { - "data": [ - { - "id": "reasoning-tool-model", - "supported_parameters": [ - "tools", - "reasoning", - "include_reasoning", - ], - }, - { - "id": "plain-tool-model", - "supported_parameters": ["tool_choice", "include_reasoning"], - }, - { - "id": "chat-only", - "supported_parameters": ["reasoning", "max_tokens"], - }, - ] - }, + return_value=SimpleNamespace( + data=[ + SimpleNamespace( + id="reasoning-tool-model", + supported_parameters=[ + "tools", + "reasoning", + "include_reasoning", + ], + ), + SimpleNamespace( + id="plain-tool-model", + supported_parameters=["tool_choice", "include_reasoning"], + ), + SimpleNamespace( + id="chat-only", + supported_parameters=["reasoning", "max_tokens"], + ), + ] ), ): infos = await provider.list_model_infos() @@ -200,17 +190,14 @@ async def test_openrouter_lists_tool_metadata_with_thinking_support() -> None: async def test_openrouter_lists_empty_set_when_no_tool_capable_models() -> None: provider = OpenRouterProvider(ProviderConfig(api_key="open-router-key")) with patch.object( - provider._client, - "get", + provider._client.models, + "list", new_callable=AsyncMock, - return_value=_response( - 200, - { - "data": [ - {"id": "chat-only", "supported_parameters": ["max_tokens"]}, - {"id": "missing-metadata"}, - ] - }, + return_value=SimpleNamespace( + data=[ + SimpleNamespace(id="chat-only", supported_parameters=["max_tokens"]), + SimpleNamespace(id="missing-metadata", supported_parameters=None), + ] ), ): assert await provider.list_model_ids() == frozenset() @@ -221,12 +208,11 @@ async def test_openrouter_model_metadata_rejects_malformed_ids() -> None: provider = OpenRouterProvider(ProviderConfig(api_key="open-router-key")) with ( patch.object( - provider._client, - "get", + provider._client.models, + "list", new_callable=AsyncMock, - return_value=_response( - 200, - {"data": [{"supported_parameters": ["tools", "reasoning"]}]}, + return_value=SimpleNamespace( + data=[SimpleNamespace(supported_parameters=["tools", "reasoning"])] ), ), pytest.raises(ModelListResponseError, match="malformed"), diff --git a/tests/providers/test_open_router.py b/tests/providers/test_open_router.py index c5a0ebec..0d5f72c3 100644 --- a/tests/providers/test_open_router.py +++ b/tests/providers/test_open_router.py @@ -1,21 +1,34 @@ -"""Tests for OpenRouter providers.""" +"""Tests for the OpenRouter OpenAI-chat provider.""" -import json from contextlib import asynccontextmanager +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest +from api.models.anthropic import MessagesRequest from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS -from core.anthropic.stream_contracts import ( - assert_anthropic_stream_contract, - parse_sse_text, - text_content, - thinking_content, -) +from core.anthropic.stream_contracts import parse_sse_text, text_content from providers.base import ProviderConfig from providers.exceptions import InvalidRequestError from providers.open_router import OpenRouterProvider +from providers.transports.openai_chat import OpenAIChatTransport + + +class AsyncStream: + def __init__(self, chunks): + self._chunks = chunks + self.closed = False + + def __aiter__(self): + return self._iter() + + async def _iter(self): + for chunk in self._chunks: + yield chunk + + async def aclose(self): + self.closed = True class MockMessage: @@ -37,48 +50,9 @@ class MockRequest: self.tool_choice = None self.metadata = None self.extra_body = {} - self.thinking = MagicMock() - self.thinking.enabled = True - for k, v in kwargs.items(): - setattr(self, k, v) - - -class FakeResponse: - def __init__(self, *, status_code=200, lines=None, text=""): - self.status_code = status_code - self._lines = lines or [] - self._text = text - self.is_closed = False - - async def aiter_lines(self): - for line in self._lines: - yield line - - async def aread(self): - return self._text.encode() - - def raise_for_status(self): - import httpx - - response = httpx.Response( - self.status_code, - request=httpx.Request("POST", "https://openrouter.ai/api/v1/messages"), - text=self._text, - ) - response.raise_for_status() - - async def aclose(self): - self.is_closed = True - - -@pytest.fixture -def open_router_config(): - return ProviderConfig( - api_key="test_openrouter_key", - base_url="https://openrouter.ai/api/v1", - rate_limit=10, - rate_window=60, - ) + self.thinking = {"type": "enabled"} + for key, value in kwargs.items(): + setattr(self, key, value) @pytest.fixture(autouse=True) @@ -87,9 +61,7 @@ def mock_rate_limiter(): async def _slot(): yield - with patch( - "providers.transports.anthropic_messages.transport.GlobalRateLimiter" - ) as mock: + with patch("providers.transports.openai_chat.transport.GlobalRateLimiter") as mock: instance = mock.get_scoped_instance.return_value async def _passthrough(fn, *args, **kwargs): @@ -101,601 +73,184 @@ def mock_rate_limiter(): @pytest.fixture -def open_router_provider(open_router_config): - return OpenRouterProvider(open_router_config) - - -def test_init(open_router_config): - """Test provider initialization.""" - with patch("httpx.AsyncClient") as mock_client: - provider = OpenRouterProvider(open_router_config) - assert provider._api_key == "test_openrouter_key" - assert provider._base_url == "https://openrouter.ai/api/v1" - mock_client.assert_called_once() - - -def test_init_uses_configurable_timeouts(): - """Provider passes configurable read/write/connect timeouts to httpx.""" - config = ProviderConfig( - api_key="test_openrouter_key", - base_url="https://openrouter.ai/api/v1", - http_read_timeout=600.0, - http_write_timeout=15.0, - http_connect_timeout=5.0, +def open_router_provider(): + return OpenRouterProvider( + ProviderConfig( + api_key="test_openrouter_key", + base_url="https://openrouter.ai/api/v1", + rate_limit=10, + rate_window=60, + ) ) - with patch("httpx.AsyncClient") as mock_client: - OpenRouterProvider(config) - timeout = mock_client.call_args.kwargs["timeout"] - assert timeout.read == 600.0 - assert timeout.write == 15.0 - assert timeout.connect == 5.0 -def test_build_request_body_is_native_anthropic(open_router_provider): - req = MockRequest() - body = open_router_provider._build_request_body(req) +def _chunk( + *, + content: str | None = None, + reasoning_content: str | None = None, + reasoning_details: list[dict] | None = None, + finish_reason: str | None = None, +): + delta = SimpleNamespace( + content=content, + reasoning_content=reasoning_content, + tool_calls=None, + ) + if reasoning_details is not None: + delta.reasoning_details = reasoning_details + choice = SimpleNamespace(delta=delta, finish_reason=finish_reason) + return SimpleNamespace(choices=[choice], usage=None) + + +def test_init_uses_openai_chat_transport(open_router_provider): + assert isinstance(open_router_provider, OpenAIChatTransport) + assert open_router_provider._api_key == "test_openrouter_key" + assert open_router_provider._base_url == "https://openrouter.ai/api/v1" + + +def test_build_request_body_uses_openai_chat_shape(open_router_provider): + body = open_router_provider._build_request_body(MockRequest()) assert body["model"] == "moonshotai/kimi-k2.6:free" assert body["temperature"] == 0.5 - assert body["stream"] is True - assert body["messages"] == [{"role": "user", "content": "Hello"}] - assert body["system"] == "System prompt" - assert body["reasoning"] == {"enabled": True} - assert "extra_body" not in body + assert body["messages"] == [ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": "Hello"}, + ] + assert body["max_tokens"] == 100 + assert body["extra_body"]["reasoning"] == {"enabled": True} -def test_openrouter_extra_body_rejects_overriding_reserved_fields() -> None: - r = MockRequest() - r.extra_body = {"model": "hijack"} - provider = OpenRouterProvider(ProviderConfig(api_key="test_openrouter_key")) - with pytest.raises(InvalidRequestError, match="model"): - provider._build_request_body(r, thinking_enabled=True) +def test_build_request_body_default_max_tokens(open_router_provider): + body = open_router_provider._build_request_body(MockRequest(max_tokens=None)) + + assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS -def test_openrouter_extra_body_allows_openrouter_only_keys() -> None: - r = MockRequest() - r.extra_body = {"transforms": ["no-web"], "plugins": []} - provider = OpenRouterProvider(ProviderConfig(api_key="test_openrouter_key")) - body = provider._build_request_body(r, thinking_enabled=False) - assert body["transforms"] == ["no-web"] - assert body["plugins"] == [] - - -def test_build_request_body_omits_reasoning_when_globally_disabled( - open_router_config, +def test_openrouter_extra_body_rejects_overriding_reserved_fields( + open_router_provider, ): - provider = OpenRouterProvider( - open_router_config.model_copy(update={"enable_thinking": False}) + with pytest.raises(InvalidRequestError, match="model"): + open_router_provider._build_request_body( + MockRequest(extra_body={"model": "hijack"}) + ) + + +def test_openrouter_extra_body_allows_provider_keys(open_router_provider): + body = open_router_provider._build_request_body( + MockRequest(extra_body={"transforms": ["no-web"], "plugins": []}), + thinking_enabled=False, ) - body = provider._build_request_body(MockRequest()) - - assert "reasoning" not in body + assert body["extra_body"] == {"transforms": ["no-web"], "plugins": []} -def test_build_request_body_omits_reasoning_when_request_disables_thinking( +def test_build_request_body_omits_reasoning_when_thinking_disabled( open_router_provider, ): - req = MockRequest() - req.thinking.enabled = False + body = open_router_provider._build_request_body( + MockRequest(thinking={"type": "disabled"}) + ) - body = open_router_provider._build_request_body(req) - - assert "reasoning" not in body - - -def test_build_request_body_omits_reasoning_when_native_thinking_disabled( - open_router_provider, -): - req = MockRequest(thinking={"type": "disabled"}) - - body = open_router_provider._build_request_body(req) - - assert "reasoning" not in body + assert "extra_body" not in body def test_build_request_body_maps_thinking_budget_to_reasoning_max_tokens( open_router_provider, ): - req = MockRequest(thinking={"type": "enabled", "budget_tokens": 4096}) - - body = open_router_provider._build_request_body(req) - - assert body["reasoning"] == {"enabled": True, "max_tokens": 4096} - - -def test_build_request_body_default_max_tokens(open_router_provider): - req = MockRequest(max_tokens=None) - - body = open_router_provider._build_request_body(req) - - assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS - - -def test_build_request_body_strips_unsigned_thinking_history(open_router_provider): - req = MockRequest( - messages=[ - MockMessage("user", "hello"), - MockMessage( - "assistant", - [ - {"type": "thinking", "thinking": "hidden"}, - {"type": "redacted_thinking", "data": "opaque"}, - {"type": "text", "text": "Hello"}, - ], - ), - MockMessage("user", "can you think hard about 2+2"), - ] + body = open_router_provider._build_request_body( + MockRequest(thinking={"type": "enabled", "budget_tokens": 4096}) ) - body = open_router_provider._build_request_body(req) - - assert body["messages"][1]["content"] == [ - {"type": "redacted_thinking", "data": "opaque"}, - {"type": "text", "text": "Hello"}, - ] + assert body["extra_body"]["reasoning"] == {"enabled": True, "max_tokens": 4096} -def test_build_request_body_strips_redacted_when_thinking_disabled( - open_router_config, -): - """Disabled thinking must remove all assistant thinking history including redacted.""" - provider = OpenRouterProvider( - open_router_config.model_copy(update={"enable_thinking": False}) - ) - req = MockRequest( - messages=[ - MockMessage( - "assistant", - [ - {"type": "redacted_thinking", "data": "opaque"}, - {"type": "text", "text": "Hi"}, - ], - ) - ] - ) - body = provider._build_request_body(req) - assert body["messages"][0]["content"] == [{"type": "text", "text": "Hi"}] - - -def test_build_request_body_preserves_signed_thinking_history(open_router_provider): - req = MockRequest( - messages=[ - MockMessage( - "assistant", - [ - { - "type": "thinking", - "thinking": "signed", - "signature": "sig_123", - } - ], - ) - ] - ) - - body = open_router_provider._build_request_body(req) - - assert body["messages"][0]["content"] == [ - {"type": "thinking", "thinking": "signed", "signature": "sig_123"} - ] - - -def test_build_request_body_flattens_system_blocks(open_router_provider): - req = MockRequest( - system=[ - {"type": "text", "text": "First system block."}, - {"type": "text", "text": "Second system block."}, - ] - ) - - body = open_router_provider._build_request_body(req) - - assert body["system"] == "First system block.\n\nSecond system block." - - -@pytest.mark.asyncio -async def test_stream_response_passes_native_sse_events(open_router_provider): - req = MockRequest() - response = FakeResponse( - lines=[ - "event: message_start", - 'data: {"type":"message_start","message":{}}', - "", - "event: content_block_delta", - 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}', - "", - "event: message_stop", - 'data: {"type":"message_stop"}', - "", - "event: data", - "data: [DONE]", - "", - ] - ) - - with ( - patch.object(open_router_provider._client, "build_request") as mock_build, - patch.object( - open_router_provider._client, - "send", - new_callable=AsyncMock, - return_value=response, - ), - ): - events = [e async for e in open_router_provider.stream_response(req)] - - _, kwargs = mock_build.call_args - assert kwargs["headers"]["Authorization"] == "Bearer test_openrouter_key" - assert kwargs["headers"]["anthropic-version"] == "2023-06-01" - assert events[0].startswith("event: message_start") - assert events[-1].startswith("event: message_stop") - assert any("Hello" in event for event in events) - assert "[DONE]" not in "".join(events) - assert response.is_closed - - -@pytest.mark.asyncio -async def test_stream_response_suppresses_native_thinking_when_disabled( - open_router_config, -): - provider = OpenRouterProvider( - open_router_config.model_copy(update={"enable_thinking": False}) - ) - response = FakeResponse( - lines=[ - "event: content_block_start", - 'data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}', - "", - "event: content_block_delta", - 'data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"secret"}}', - "", - "event: content_block_stop", - 'data: {"type":"content_block_stop","index":0}', - "", - "event: content_block_start", - 'data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}', - "", - "event: content_block_delta", - 'data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Answer"}}', - "", - "event: content_block_stop", - 'data: {"type":"content_block_stop","index":1}', - "", - ] - ) - - with ( - patch.object(provider._client, "build_request"), - patch.object( - provider._client, - "send", - new_callable=AsyncMock, - return_value=response, - ), - ): - events = [e async for e in provider.stream_response(MockRequest())] - - event_text = "".join(events) - assert "thinking_delta" not in event_text - assert "secret" not in event_text - assert "Answer" in event_text - - text_start = next(event for event in events if "content_block_start" in event) - payload = parse_sse_text(text_start)[0].data - assert payload["index"] == 0 - - -@pytest.mark.asyncio -async def test_stream_response_preserves_redacted_thinking_when_enabled( +def test_build_request_body_replays_openrouter_reasoning_details( open_router_provider, ): - response = FakeResponse( - lines=[ - "event: content_block_start", - 'data: {"type":"content_block_start","index":0,"content_block":{"type":"redacted_thinking","data":"opaque"}}', - "", - "event: content_block_stop", - 'data: {"type":"content_block_stop","index":0}', - "", - "event: content_block_start", - 'data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}', - "", - "event: content_block_delta", - 'data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Answer"}}', - "", - "event: content_block_stop", - 'data: {"type":"content_block_stop","index":1}', - "", - "event: message_stop", - 'data: {"type":"message_stop"}', - "", - ] + detail = {"type": "reasoning.encrypted", "data": "opaque"} + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "redacted_thinking", + "data": '{"type":"reasoning.encrypted","data":"opaque"}', + }, + {"type": "text", "text": "Need a tool."}, + ], + }, + {"role": "user", "content": "continue"}, + ], + } ) - with ( - patch.object(open_router_provider._client, "build_request"), - patch.object( - open_router_provider._client, - "send", - new_callable=AsyncMock, - return_value=response, - ), + body = open_router_provider._build_request_body(request) + + assistant = next(msg for msg in body["messages"] if msg["role"] == "assistant") + assert assistant["reasoning_details"] == [detail] + + +@pytest.mark.asyncio +async def test_stream_maps_reasoning_content_and_details(open_router_provider): + redacted = {"type": "reasoning.encrypted", "data": "opaque"} + stream = AsyncStream( + [ + _chunk(reasoning_content="plan "), + _chunk(reasoning_details=[redacted]), + _chunk(content="done", finish_reason="stop"), + ] + ) + with patch.object( + open_router_provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream, ): - events = [e async for e in open_router_provider.stream_response(MockRequest())] + events = [ + event async for event in open_router_provider.stream_response(MockRequest()) + ] event_text = "".join(events) + assert "thinking_delta" in event_text + assert "plan " in event_text assert "redacted_thinking" in event_text assert "opaque" in event_text - assert "Answer" in event_text - - parsed = parse_sse_text(event_text) - first_start = next( - p - for p in parsed - if p.event == "content_block_start" - and p.data.get("content_block", {}).get("type") == "redacted_thinking" - ) - assert first_start.data["index"] == 0 + assert "done" in text_content(parse_sse_text(event_text)) + assert stream.closed @pytest.mark.asyncio -async def test_stream_response_drops_redacted_thinking_when_disabled( - open_router_config, -): - provider = OpenRouterProvider( - open_router_config.model_copy(update={"enable_thinking": False}) - ) - response = FakeResponse( - lines=[ - "event: content_block_start", - 'data: {"type":"content_block_start","index":0,"content_block":{"type":"redacted_thinking","data":"opaque"}}', - "", - "event: content_block_stop", - 'data: {"type":"content_block_stop","index":0}', - "", - "event: content_block_start", - 'data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}', - "", - "event: content_block_delta", - 'data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Answer"}}', - "", - "event: content_block_stop", - 'data: {"type":"content_block_stop","index":1}', - "", - ] - ) - - with ( - patch.object(provider._client, "build_request"), - patch.object( - provider._client, - "send", - new_callable=AsyncMock, - return_value=response, - ), - ): - events = [e async for e in provider.stream_response(MockRequest())] - - event_text = "".join(events) - assert "redacted_thinking" not in event_text - assert "opaque" not in event_text - assert "Answer" in event_text - - start_event = next(event for event in events if "content_block_start" in event) - payload = parse_sse_text(start_event)[0].data - assert payload["index"] == 0 - assert payload["content_block"]["type"] == "text" - - -@pytest.mark.asyncio -async def test_stream_response_reopens_interleaved_thinking_after_text( +async def test_model_infos_filter_tool_models_and_thinking_metadata( open_router_provider, ): - """Overthinking+text+more thinking: downstream indices must not reuse closed blocks.""" - response = FakeResponse( - lines=[ - "event: message_start", - 'data: {"type":"message_start","message":{}}', - "", - "event: content_block_start", - 'data: {"type":"content_block_start","index":0,' - '"content_block":{"type":"thinking","thinking":"","signature":""}}', - "", - "event: content_block_delta", - 'data: {"type":"content_block_delta","index":0,' - '"delta":{"type":"thinking_delta","thinking":"first"}}', - "", - "event: content_block_start", - 'data: {"type":"content_block_start","index":1,' - '"content_block":{"type":"text","text":""}}', - "", - "event: content_block_delta", - 'data: {"type":"content_block_delta","index":0,' - '"delta":{"type":"thinking_delta","thinking":" second"}}', - "", - "event: content_block_delta", - 'data: {"type":"content_block_delta","index":1,' - '"delta":{"type":"text_delta","text":"Answer"}}', - "", - "event: content_block_stop", - 'data: {"type":"content_block_stop","index":1}', - "", - "event: content_block_stop", - 'data: {"type":"content_block_stop","index":0}', - "", - "event: message_stop", - 'data: {"type":"message_stop"}', - "", - ] + open_router_provider._client.models.list = AsyncMock( + return_value=SimpleNamespace( + data=[ + SimpleNamespace( + id="tool-model", + supported_parameters=["tools", "reasoning"], + ), + SimpleNamespace(id="plain-model", supported_parameters=[]), + ] + ) ) - with ( - patch.object(open_router_provider._client, "build_request"), - patch.object( - open_router_provider._client, - "send", - new_callable=AsyncMock, - return_value=response, - ), - ): - events = [e async for e in open_router_provider.stream_response(MockRequest())] + infos = await open_router_provider.list_model_infos() - parsed = parse_sse_text("".join(events)) - assert_anthropic_stream_contract(parsed) - assert thinking_content(parsed) == "first second" - assert "Answer" in text_content(parsed) - stop_payloads = [ - p.data - for p in parsed - if p.event == "content_block_stop" - and p.data.get("type") == "content_block_stop" - ] - seen_stop_indices: set[int] = set() - for s in stop_payloads: - idx = s.get("index") - assert isinstance(idx, int) - assert idx not in seen_stop_indices, "stop reused or duplicated index" - seen_stop_indices.add(idx) - # Two distinct thinking block indices: initial + reopened segment - think_starts = [ - p - for p in parsed - if p.event == "content_block_start" - and p.data.get("content_block", {}).get("type") == "thinking" - ] - assert len(think_starts) == 2, ( - "reopened thinking must have its own `content_block_start`" - ) + assert {(info.model_id, info.supports_thinking) for info in infos} == { + ("tool-model", True) + } @pytest.mark.asyncio -async def test_stream_response_reopened_tool_use_preserves_tool_identity( - open_router_provider, -): - """After overlapping close, resumed input_json_delta must keep original tool id/name.""" - lines: list[str] = [] - for payload in ( - { - "type": "content_block_start", - "index": 0, - "content_block": { - "type": "tool_use", - "id": "toolu_real_1", - "name": "Read", - "input": {}, - }, - }, - { - "type": "content_block_delta", - "index": 0, - "delta": {"type": "input_json_delta", "partial_json": '{"path'}, - }, - { - "type": "content_block_start", - "index": 1, - "content_block": {"type": "text", "text": ""}, - }, - { - "type": "content_block_delta", - "index": 0, - "delta": {"type": "input_json_delta", "partial_json": '":"/tmp"}'}, - }, - {"type": "content_block_stop", "index": 1}, - {"type": "content_block_stop", "index": 0}, - ): - event_name = payload["type"] - lines.extend((f"event: {event_name}", f"data: {json.dumps(payload)}", "")) +async def test_cleanup_closes_openai_client(open_router_provider): + open_router_provider._client = MagicMock() + open_router_provider._client.close = AsyncMock() - response = FakeResponse(lines=lines) + await open_router_provider.cleanup() - with ( - patch.object(open_router_provider._client, "build_request"), - patch.object( - open_router_provider._client, - "send", - new_callable=AsyncMock, - return_value=response, - ), - ): - events = [e async for e in open_router_provider.stream_response(MockRequest())] - - parsed = parse_sse_text("".join(events)) - tool_starts = [ - p - for p in parsed - if p.event == "content_block_start" - and p.data.get("content_block", {}).get("type") == "tool_use" - ] - assert len(tool_starts) == 2 - for start in tool_starts: - block = start.data["content_block"] - assert block["id"] == "toolu_real_1" - assert block["name"] == "Read" - - -@pytest.mark.asyncio -async def test_stream_response_closes_overlapping_thinking_before_text( - open_router_provider, -): - response = FakeResponse( - lines=[ - "event: content_block_start", - 'data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}', - "", - "event: content_block_delta", - 'data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"reason"}}', - "", - "event: content_block_start", - 'data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}', - "", - "event: content_block_delta", - 'data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Answer"}}', - "", - "event: content_block_stop", - 'data: {"type":"content_block_stop","index":1}', - "", - "event: content_block_stop", - 'data: {"type":"content_block_stop","index":0}', - "", - ] - ) - - with ( - patch.object(open_router_provider._client, "build_request"), - patch.object( - open_router_provider._client, - "send", - new_callable=AsyncMock, - return_value=response, - ), - ): - events = [e async for e in open_router_provider.stream_response(MockRequest())] - - event_text = "".join(events) - thinking_stop = event_text.index('"type": "content_block_stop", "index": 0') - text_start = event_text.index('"content_block": {"type": "text"') - assert thinking_stop < text_start - assert event_text.count('"index": 0') == 3 - assert "Answer" in event_text - - -@pytest.mark.asyncio -async def test_stream_response_error_path(open_router_provider): - req = MockRequest() - - with ( - patch.object(open_router_provider._client, "build_request"), - patch.object( - open_router_provider._client, - "send", - new_callable=AsyncMock, - side_effect=RuntimeError("API failed"), - ), - ): - events = [e async for e in open_router_provider.stream_response(req)] - - event_text = "".join(events) - assert "message_start" in event_text - assert "API failed" in event_text - assert "message_stop" in event_text + open_router_provider._client.close.assert_awaited_once() diff --git a/tests/providers/test_provider_runtime.py b/tests/providers/test_provider_runtime.py index 2c023074..f32d041c 100644 --- a/tests/providers/test_provider_runtime.py +++ b/tests/providers/test_provider_runtime.py @@ -139,8 +139,10 @@ def test_ollama_descriptor_uses_native_anthropic_transport(): def test_zai_descriptor_uses_fixed_cloud_base_url(): descriptor = PROVIDER_CATALOG["zai"] + assert descriptor.transport_type == "openai_chat" assert descriptor.default_base_url == ZAI_DEFAULT_BASE assert descriptor.base_url_attr is None + assert "native_anthropic" not in descriptor.capabilities def test_zai_provider_config_ignores_stale_base_url_setting(): @@ -154,13 +156,13 @@ def test_zai_provider_config_ignores_stale_base_url_setting(): assert config.base_url == ZAI_DEFAULT_BASE -def test_minimax_descriptor_uses_native_anthropic_transport(): +def test_minimax_descriptor_uses_openai_chat_transport(): descriptor = PROVIDER_CATALOG["minimax"] - assert descriptor.transport_type == "anthropic_messages" + assert descriptor.transport_type == "openai_chat" assert descriptor.default_base_url == MINIMAX_DEFAULT_BASE assert descriptor.credential_env == "MINIMAX_API_KEY" - assert "native_anthropic" in descriptor.capabilities + assert "native_anthropic" not in descriptor.capabilities def test_cloudflare_descriptor_uses_api_root_not_account_url(): @@ -306,8 +308,8 @@ def test_build_provider_config_github_models_uses_token_and_proxy() -> None: assert config.proxy == "http://proxy.test:8080" -def test_create_provider_uses_native_openrouter_by_default(): - with patch("httpx.AsyncClient"): +def test_create_provider_uses_openai_chat_openrouter_by_default(): + with patch("providers.transports.openai_chat.transport.AsyncOpenAI"): provider = create_provider("open_router", _make_settings()) assert isinstance(provider, OpenRouterProvider) diff --git a/tests/providers/test_wafer.py b/tests/providers/test_wafer.py index 6fa43358..1420955e 100644 --- a/tests/providers/test_wafer.py +++ b/tests/providers/test_wafer.py @@ -1,48 +1,16 @@ -"""Tests for Wafer native Anthropic Messages provider.""" +"""Tests for the Wafer OpenAI-chat provider.""" from contextlib import asynccontextmanager from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -import httpx import pytest from api.models.anthropic import Message, MessagesRequest, Tool from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS -from core.anthropic.stream_contracts import parse_sse_text from providers.base import ProviderConfig +from providers.transports.openai_chat import OpenAIChatTransport from providers.wafer import WAFER_DEFAULT_BASE, WaferProvider -from tests.stream_contract import assert_canonical_stream_error_envelope - - -class FakeResponse: - def __init__(self, *, status_code=200, lines=None, text=""): - self.status_code = status_code - self._lines = lines or [] - self._text = text - self.is_closed = False - self.headers = httpx.Headers() - self.request = httpx.Request("POST", "https://pass.wafer.ai/v1/messages") - - async def aiter_lines(self): - for line in self._lines: - yield line - - async def aclose(self): - self.is_closed = True - - async def aiter_bytes(self, chunk_size: int = 65_536): - data = self._text.encode("utf-8") - for offset in range(0, len(data), chunk_size): - yield data[offset : offset + chunk_size] - - def raise_for_status(self): - response = httpx.Response( - self.status_code, - request=self.request, - text=self._text, - ) - response.raise_for_status() class CountingWaferProvider(WaferProvider): @@ -57,25 +25,13 @@ class CountingWaferProvider(WaferProvider): return super()._is_thinking_enabled(request, thinking_enabled) -@pytest.fixture -def wafer_config(): - return ProviderConfig( - api_key="test-wafer-key", - base_url=WAFER_DEFAULT_BASE, - rate_limit=10, - rate_window=60, - ) - - @pytest.fixture(autouse=True) def mock_rate_limiter(): @asynccontextmanager async def _slot(): yield - with patch( - "providers.transports.anthropic_messages.transport.GlobalRateLimiter" - ) as mock: + with patch("providers.transports.openai_chat.transport.GlobalRateLimiter") as mock: instance = mock.get_scoped_instance.return_value async def _passthrough(fn, *args, **kwargs): @@ -86,6 +42,16 @@ def mock_rate_limiter(): yield instance +@pytest.fixture +def wafer_config(): + return ProviderConfig( + api_key="test-wafer-key", + base_url=WAFER_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + ) + + @pytest.fixture def wafer_provider(wafer_config): return WaferProvider(wafer_config) @@ -95,30 +61,14 @@ def test_default_base_url(): assert WAFER_DEFAULT_BASE == "https://pass.wafer.ai/v1" -def test_init_uses_default_base_url_and_strips_trailing_slash(): - config = ProviderConfig(api_key="test-wafer-key", base_url=f"{WAFER_DEFAULT_BASE}/") - with patch("httpx.AsyncClient"): - provider = WaferProvider(config) - - assert provider._api_key == "test-wafer-key" - assert provider._base_url == WAFER_DEFAULT_BASE - assert provider._provider_name == "WAFER" +def test_init_uses_openai_chat_transport(wafer_provider): + assert isinstance(wafer_provider, OpenAIChatTransport) + assert wafer_provider._api_key == "test-wafer-key" + assert wafer_provider._base_url == WAFER_DEFAULT_BASE + assert wafer_provider._provider_name == "WAFER" -def test_request_headers_use_bearer_auth_not_x_api_key(wafer_provider): - headers = wafer_provider._request_headers() - - assert headers["Authorization"] == "Bearer test-wafer-key" - assert headers["Accept"] == "text/event-stream" - assert headers["Content-Type"] == "application/json" - assert headers["anthropic-version"] == "2023-06-01" - assert "x-api-key" not in headers - assert wafer_provider._model_list_headers() == { - "Authorization": "Bearer test-wafer-key" - } - - -def test_build_request_body_native_shape_and_defaults(wafer_provider): +def test_build_request_body_openai_shape_and_defaults(wafer_provider): request = MessagesRequest.model_validate( { "model": "DeepSeek-V4-Pro", @@ -137,31 +87,13 @@ def test_build_request_body_native_shape_and_defaults(wafer_provider): body = wafer_provider._build_request_body(request) assert body["model"] == "DeepSeek-V4-Pro" - assert body["messages"][0]["role"] == "user" - assert body["tools"][0]["name"] == "echo" - assert body["thinking"] == {"type": "enabled", "budget_tokens": 2048} + assert body["messages"][0] == {"role": "user", "content": "Hello"} + assert body["tools"][0]["function"]["name"] == "echo" + assert body["extra_body"]["thinking"] == {"type": "enabled"} assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS - assert body["stream"] is True -def test_build_request_body_drops_reasoning_effort_none(wafer_provider): - request = MessagesRequest.model_validate( - { - "model": "DeepSeek-V4-Pro", - "messages": [{"role": "user", "content": "Explore the codebase."}], - "reasoning_effort": "none", - } - ) - - body = wafer_provider._build_request_body(request) - - assert "reasoning_effort" not in body - assert body["thinking"] == {"type": "enabled"} - - -def test_build_request_body_honors_effective_no_thinking( - wafer_provider, -): +def test_build_request_body_honors_effective_no_thinking(wafer_provider): request = MessagesRequest.model_validate( { "model": "DeepSeek-V4-Pro", @@ -171,12 +103,10 @@ def test_build_request_body_honors_effective_no_thinking( body = wafer_provider._build_request_body(request, thinking_enabled=False) - assert body["thinking"] == {"type": "disabled"} + assert body["extra_body"]["thinking"] == {"type": "disabled"} -def test_build_request_body_preserves_request_disabled_thinking( - wafer_provider, -): +def test_build_request_body_preserves_request_disabled_thinking(wafer_provider): request = MessagesRequest.model_validate( { "model": "DeepSeek-V4-Pro", @@ -187,7 +117,7 @@ def test_build_request_body_preserves_request_disabled_thinking( body = wafer_provider._build_request_body(request, thinking_enabled=True) - assert body["thinking"] == {"type": "disabled"} + assert body["extra_body"]["thinking"] == {"type": "disabled"} def test_build_request_body_resolves_thinking_once(wafer_config): @@ -201,131 +131,30 @@ def test_build_request_body_resolves_thinking_once(wafer_config): body = provider._build_request_body(request, thinking_enabled=False) - assert body["thinking"] == {"type": "disabled"} + assert body["extra_body"]["thinking"] == {"type": "disabled"} assert provider.thinking_checks == 1 -def test_build_request_body_classifier_no_thinking_uses_disabled_payload( - wafer_provider, -): - request = MessagesRequest.model_validate( - { - "model": "DeepSeek-V4-Pro", - "system": ( - "You are a security monitor. Respond with yes " - "or no." - ), - "messages": [ - { - "role": "user", - "content": ( - "\nWebFetch https://example.com\n" - "\n immediately." - ), - } - ], - } - ) - - body = wafer_provider._build_request_body(request, thinking_enabled=False) - - assert body["thinking"] == {"type": "disabled"} - - @pytest.mark.asyncio -async def test_lists_models_from_openai_compatible_models_endpoint(wafer_provider): - with patch.object( - wafer_provider._client, - "get", - new_callable=AsyncMock, - return_value=httpx.Response( - 200, - json={ - "object": "list", - "data": [ - {"id": "DeepSeek-V4-Pro", "object": "model"}, - {"id": "MiniMax-M2.7", "object": "model"}, - ], - }, - request=httpx.Request("GET", "https://pass.wafer.ai/v1/models"), - ), - ) as mock_get: - assert await wafer_provider.list_model_ids() == frozenset( - {"DeepSeek-V4-Pro", "MiniMax-M2.7"} +async def test_lists_models_from_openai_models_endpoint(wafer_provider): + wafer_provider._client.models.list = AsyncMock( + return_value=MagicMock( + data=[MagicMock(id="DeepSeek-V4-Pro"), MagicMock(id="MiniMax-M2.7")] ) - - mock_get.assert_awaited_once_with( - "/models", headers={"Authorization": "Bearer test-wafer-key"} ) + assert await wafer_provider.list_model_ids() == frozenset( + {"DeepSeek-V4-Pro", "MiniMax-M2.7"} + ) + + wafer_provider._client.models.list.assert_awaited_once_with() + @pytest.mark.asyncio -async def test_stream_uses_post_messages_path(wafer_provider): - request = MessagesRequest( - model="MiniMax-M2.7", - messages=[Message(role="user", content="hi")], - ) - response = FakeResponse( - lines=[ - "event: message_start", - 'data: {"type":"message_start"}', - "", - "event: message_stop", - 'data: {"type":"message_stop"}', - "", - ] - ) +async def test_cleanup_closes_openai_client(wafer_provider): + wafer_provider._client = MagicMock() + wafer_provider._client.close = AsyncMock() - with ( - patch.object( - wafer_provider._client, "build_request", return_value=MagicMock() - ) as mock_build, - patch.object( - wafer_provider._client, - "send", - new_callable=AsyncMock, - return_value=response, - ), - ): - events = [event async for event in wafer_provider.stream_response(request)] + await wafer_provider.cleanup() - assert [event.event for event in parse_sse_text("".join(events))] == [ - "message_start", - "message_stop", - ] - assert response.is_closed - assert mock_build.call_args.args[:2] == ("POST", "/messages") - assert mock_build.call_args.kwargs["headers"]["Authorization"] == ( - "Bearer test-wafer-key" - ) - - -@pytest.mark.asyncio -async def test_stream_non_200_maps_to_anthropic_error_event(wafer_provider): - request = MessagesRequest( - model="GLM-5.1", - messages=[Message(role="user", content="hi")], - ) - response = FakeResponse(status_code=500, text="Internal Server Error") - - with ( - patch.object(wafer_provider._client, "build_request", return_value=MagicMock()), - patch.object( - wafer_provider._client, - "send", - new_callable=AsyncMock, - return_value=response, - ), - ): - events = [ - event - async for event in wafer_provider.stream_response( - request, request_id="REQ_WAFER" - ) - ] - - assert response.is_closed - assert_canonical_stream_error_envelope( - events, user_message_substr="Provider API request failed" - ) - assert "REQ_WAFER" in "".join(events) + wafer_provider._client.close.assert_awaited_once() diff --git a/tests/providers/test_zai.py b/tests/providers/test_zai.py index 30ffaca7..d078f6f1 100644 --- a/tests/providers/test_zai.py +++ b/tests/providers/test_zai.py @@ -1,7 +1,7 @@ -"""Tests for Z.ai native Anthropic Messages provider.""" +"""Tests for the Z.ai OpenAI-chat Coding Plan provider.""" from contextlib import asynccontextmanager -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -10,29 +10,17 @@ from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS from providers.base import ProviderConfig from providers.defaults import ZAI_DEFAULT_BASE from providers.exceptions import InvalidRequestError +from providers.transports.openai_chat import OpenAIChatTransport from providers.zai import ZaiProvider -@pytest.fixture -def zai_config(): - return ProviderConfig( - api_key="test_zai_key", - base_url=ZAI_DEFAULT_BASE, - rate_limit=10, - rate_window=60, - enable_thinking=True, - ) - - @pytest.fixture(autouse=True) def mock_rate_limiter(): @asynccontextmanager async def _slot(): yield - with patch( - "providers.transports.anthropic_messages.transport.GlobalRateLimiter" - ) as mock: + with patch("providers.transports.openai_chat.transport.GlobalRateLimiter") as mock: instance = mock.get_scoped_instance.return_value async def _passthrough(fn, *args, **kwargs): @@ -44,39 +32,40 @@ def mock_rate_limiter(): @pytest.fixture -def zai_provider(zai_config): - return ZaiProvider(zai_config) +def zai_provider(): + return ZaiProvider( + ProviderConfig( + api_key="test_zai_key", + base_url=ZAI_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + ) -def test_init(zai_config): - with patch("httpx.AsyncClient") as mock_client: - provider = ZaiProvider(zai_config) - assert provider._api_key == "test_zai_key" - assert provider._base_url == ZAI_DEFAULT_BASE - assert mock_client.called +def test_init_uses_openai_chat_coding_endpoint(zai_provider): + assert isinstance(zai_provider, OpenAIChatTransport) + assert zai_provider._api_key == "test_zai_key" + assert zai_provider._base_url == "https://api.z.ai/api/coding/paas/v4" -def test_request_headers(zai_provider): - h = zai_provider._request_headers() - assert h["x-api-key"] == "test_zai_key" - assert h["anthropic-version"] == "2023-06-01" - - -def test_model_list_headers(zai_provider): - h = zai_provider._model_list_headers() - assert h["x-api-key"] == "test_zai_key" - - -def test_build_request_body_native(zai_provider): +def test_build_request_body_openai_chat(zai_provider): request = MessagesRequest( model="glm-5.2", max_tokens=100, messages=[Message(role="user", content="Hello")], ) + body = zai_provider._build_request_body(request) + assert body["model"] == "glm-5.2" - assert body["stream"] is True assert body["max_tokens"] == 100 + assert body["messages"] == [{"role": "user", "content": "Hello"}] + assert body["extra_body"]["thinking"] == { + "type": "enabled", + "clear_thinking": False, + } def test_build_request_body_default_max_tokens(zai_provider): @@ -84,11 +73,13 @@ def test_build_request_body_default_max_tokens(zai_provider): model="m", messages=[Message(role="user", content="x")], ) + body = zai_provider._build_request_body(request) + assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS -def test_build_request_body_rejects_extra_body(zai_provider): +def test_build_request_body_rejects_caller_extra_body(zai_provider): request = MessagesRequest.model_validate( { "model": "m", @@ -96,14 +87,53 @@ def test_build_request_body_rejects_extra_body(zai_provider): "extra_body": {"x": 1}, } ) - with pytest.raises(InvalidRequestError, match="does not support extra_body"): + + with pytest.raises(InvalidRequestError, match=r"Z\.ai Chat Completions"): zai_provider._build_request_body(request) +def test_build_request_body_disables_zai_thinking(zai_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "thinking": {"type": "disabled"}, + } + ) + + body = zai_provider._build_request_body(request) + + assert body["extra_body"]["thinking"] == {"type": "disabled"} + + +def test_build_request_body_replays_prior_reasoning_content(zai_provider): + request = MessagesRequest.model_validate( + { + "model": "glm-5.2", + "messages": [ + { + "role": "assistant", + "content": [{"type": "thinking", "thinking": "prior"}], + }, + {"role": "user", "content": "continue"}, + ], + } + ) + + body = zai_provider._build_request_body(request) + + assert body["messages"][0]["reasoning_content"] == "prior" + assert body["extra_body"]["thinking"] == { + "type": "enabled", + "clear_thinking": False, + } + + @pytest.mark.asyncio -async def test_cleanup_aclose(zai_provider): - zai_provider._client = AsyncMock() +async def test_cleanup_closes_openai_client(zai_provider): + zai_provider._client = MagicMock() + zai_provider._client.close = AsyncMock() await zai_provider.cleanup() - zai_provider._client.aclose.assert_awaited_once() + zai_provider._client.close.assert_awaited_once() diff --git a/uv.lock b/uv.lock index 6187f62b..07713027 100644 --- a/uv.lock +++ b/uv.lock @@ -561,7 +561,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "3.4.10" +version = "3.4.11" source = { editable = "." } dependencies = [ { name = "aiohttp" },