mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-07-09 16:00:45 +00:00
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:
parent
c9adffffbf
commit
745c38cbbe
30 changed files with 994 additions and 1666 deletions
12
.env.example
12
.env.example
|
|
@ -14,19 +14,19 @@ MISTRAL_API_KEY=""
|
|||
CODESTRAL_API_KEY=""
|
||||
|
||||
|
||||
# DeepSeek Config (uses native Anthropic Messages at api.deepseek.com/anthropic)
|
||||
# DeepSeek Config (OpenAI-compatible Chat Completions at api.deepseek.com)
|
||||
DEEPSEEK_API_KEY=""
|
||||
|
||||
|
||||
# Kimi Config (Anthropic-compatible Messages at api.moonshot.ai/anthropic/v1)
|
||||
# Kimi Config (OpenAI-compatible Chat Completions at api.moonshot.ai/v1)
|
||||
KIMI_API_KEY=""
|
||||
|
||||
|
||||
# Wafer Config (uses native Anthropic Messages at pass.wafer.ai/v1/messages)
|
||||
# Wafer Config (OpenAI-compatible Chat Completions at pass.wafer.ai/v1)
|
||||
WAFER_API_KEY=""
|
||||
|
||||
|
||||
# MiniMax Config (Anthropic-compatible Messages at api.minimax.io/anthropic/v1)
|
||||
# MiniMax Config (OpenAI-compatible Chat Completions at api.minimax.io/v1)
|
||||
MINIMAX_API_KEY=""
|
||||
|
||||
|
||||
|
|
@ -50,11 +50,11 @@ COHERE_API_KEY=""
|
|||
GITHUB_MODELS_TOKEN=""
|
||||
|
||||
|
||||
# Z.ai Config (Anthropic-compatible Messages at api.z.ai/api/anthropic/v1)
|
||||
# Z.ai Config (GLM Coding Plan OpenAI-compatible Chat Completions at api.z.ai/api/coding/paas/v4)
|
||||
ZAI_API_KEY=""
|
||||
|
||||
|
||||
# Fireworks AI Config (Anthropic-compatible Messages at api.fireworks.ai/inference/v1)
|
||||
# Fireworks AI Config (OpenAI-compatible Chat Completions at api.fireworks.ai/inference/v1)
|
||||
FIREWORKS_API_KEY=""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -306,7 +306,7 @@ Popular examples:
|
|||
- `wafer/Qwen3.5-397B-A17B`
|
||||
- `wafer/GLM-5.1`
|
||||
|
||||
This provider uses Wafer's Anthropic-compatible endpoint at `https://pass.wafer.ai/v1/messages`.
|
||||
This provider uses Wafer's OpenAI-compatible Chat Completions endpoint at `https://pass.wafer.ai/v1/chat/completions`; FCC maps Claude-style thinking to Wafer's explicit thinking controls.
|
||||
|
||||
### 14. [Kimi](https://platform.moonshot.ai/)
|
||||
|
||||
|
|
@ -314,7 +314,7 @@ Get a key at [platform.moonshot.ai/console/api-keys](https://platform.moonshot.a
|
|||
|
||||
In the Admin UI, paste it into `KIMI_API_KEY`, then set `MODEL` to a Kimi slug such as `kimi/kimi-k2.5`.
|
||||
|
||||
This provider calls Kimi's **Anthropic-compatible** Messages API (`https://api.moonshot.ai/anthropic/v1/messages`; model discovery uses OpenAI-compat `GET https://api.moonshot.ai/v1/models`). It is **not** the OpenAI Chat Completions path.
|
||||
This provider calls Kimi's OpenAI-compatible Chat Completions API at `https://api.moonshot.ai/v1/chat/completions`; FCC keeps Kimi-specific thinking controls inside the Kimi provider.
|
||||
|
||||
Browse models at [platform.moonshot.ai](https://platform.moonshot.ai).
|
||||
|
||||
|
|
@ -324,7 +324,7 @@ Get a key from [MiniMax](https://platform.minimax.io/user-center/basic-informati
|
|||
|
||||
In the Admin UI, paste it into `MINIMAX_API_KEY`, then set `MODEL` to a MiniMax slug such as `minimax/MiniMax-M3`.
|
||||
|
||||
This provider calls MiniMax's **Anthropic-compatible** Messages API (`https://api.minimax.io/anthropic/v1/messages`). `MiniMax-M3` is the recommended default because MiniMax documents controllable Anthropic thinking for that model; other MiniMax models remain discoverable through the provider model list.
|
||||
This provider calls MiniMax's OpenAI-compatible Chat Completions API at `https://api.minimax.io/v1/chat/completions`. `MiniMax-M3` is the recommended default because MiniMax documents controllable thinking for that model; other MiniMax models remain discoverable through the provider model list.
|
||||
|
||||
### 16. [Cerebras Inference](https://inference-docs.cerebras.ai/quickstart)
|
||||
|
||||
|
|
@ -362,7 +362,7 @@ Get an API key at [fireworks.ai/account/api-keys](https://fireworks.ai/account/a
|
|||
|
||||
In the Admin UI, paste it into `FIREWORKS_API_KEY`, then set `MODEL` to a Fireworks model slug such as `fireworks/accounts/fireworks/models/llama-v3p3-70b-instruct`.
|
||||
|
||||
Fireworks exposes an **Anthropic-compatible** Messages API at `https://api.fireworks.ai/inference/v1/messages` (same inference host as before; Chat Completions is not used here). Vendor-specific JSON keys can still be merged from request `extra_body` when allowed.
|
||||
Fireworks exposes an OpenAI-compatible Chat Completions API at `https://api.fireworks.ai/inference/v1/chat/completions`. Vendor-specific JSON keys can still be passed through request `extra_body` when they do not override FCC-owned chat fields.
|
||||
|
||||
Browse models at [fireworks.ai/models](https://fireworks.ai/models).
|
||||
|
||||
|
|
@ -380,7 +380,7 @@ Get an API key at [Z.ai/manage-apikey/apikey-list](https://z.ai/manage-apikey/ap
|
|||
|
||||
In the Admin UI, paste it into `ZAI_API_KEY`, then set `MODEL` to a Z.ai model slug such as `zai/glm-5.2`.
|
||||
|
||||
This provider calls Z.ai's **Anthropic-compatible** Messages API (`https://api.z.ai/api/anthropic/v1/messages`). The former OpenAI Coding Plan base (`https://api.z.ai/api/coding/paas/v4`) is **not** used by this gateway.
|
||||
This provider calls Z.ai's GLM Coding Plan OpenAI-compatible Chat Completions API at `https://api.z.ai/api/coding/paas/v4/chat/completions`. FCC keeps Z.ai `clear_thinking=false` handling inside the Z.ai provider so tool workflows can preserve prior reasoning.
|
||||
|
||||
Popular examples:
|
||||
|
||||
|
|
@ -631,7 +631,7 @@ Important pieces:
|
|||
- `fcc-codex` registers a custom `fcc` provider that points Codex at the local proxy's `/v1/responses` endpoint.
|
||||
- Model routing resolves Claude model names to `MODEL_OPUS`, `MODEL_SONNET`, `MODEL_HAIKU`, or `MODEL`.
|
||||
- NIM, Gemini, DeepSeek, Mistral, Codestral, OpenCode Zen, OpenCode Go, Vercel AI Gateway, Hugging Face, Cohere, GitHub Models, Cerebras, Groq, SambaNova, and Cloudflare use OpenAI chat streaming translated into Anthropic SSE.
|
||||
- Wafer, OpenRouter, Kimi, MiniMax, Fireworks AI, Z.ai, LM Studio, llama.cpp, and Ollama use Anthropic Messages style transports where applicable (with provider-specific quirks and model-list URLs).
|
||||
- Cloud providers use OpenAI-compatible Chat Completions transports with provider-specific quirks kept in each provider package. Native Anthropic Messages transports are local-only for llama.cpp and Ollama.
|
||||
- The proxy normalizes thinking blocks, tool calls, token usage metadata, and provider errors into the shape each client expects.
|
||||
- Request optimizations answer trivial Claude Code probes locally to save latency and quota.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}"}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""OpenRouter provider - Anthropic-compatible native transport."""
|
||||
"""OpenRouter provider exports."""
|
||||
|
||||
from providers.defaults import OPENROUTER_DEFAULT_BASE
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
32
providers/transports/openai_chat/extra_body.py
Normal file
32
providers/transports/openai_chat/extra_body.py
Normal 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)}"
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
2
uv.lock
generated
|
|
@ -561,7 +561,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "free-claude-code"
|
||||
version = "3.4.10"
|
||||
version = "3.4.11"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue