Move cloud providers to OpenAI-chat transport

Move remote cloud providers onto the OpenAI-chat transport and keep native Anthropic transport local-provider only.
This commit is contained in:
Ali Khokhar 2026-07-07 23:02:31 -07:00 committed by GitHub
parent c9adffffbf
commit 745c38cbbe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 994 additions and 1666 deletions

View file

@ -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=""

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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):

View file

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

View file

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

View file

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

View file

@ -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"}
)

View file

@ -1,4 +1,4 @@
"""OpenRouter provider - Anthropic-compatible native transport."""
"""OpenRouter provider exports."""
from providers.defaults import OPENROUTER_DEFAULT_BASE

View file

@ -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
)

View file

@ -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}")

View file

@ -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)}"
)

View file

@ -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)

View file

@ -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"}
)

View file

@ -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"}
)

View file

@ -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"

View file

@ -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",),
),

View file

@ -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"

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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"),

View file

@ -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()

View file

@ -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)

View file

@ -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 <block>yes</block> "
"or <block>no</block>."
),
"messages": [
{
"role": "user",
"content": (
"<transcript>\nWebFetch https://example.com\n"
"</transcript>\n<block> 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()

View file

@ -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()

2
uv.lock generated
View file

@ -561,7 +561,7 @@ wheels = [
[[package]]
name = "free-claude-code"
version = "3.4.10"
version = "3.4.11"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },