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" },