Refactor provider request policy ownership (#929)

This commit is contained in:
Ali Khokhar 2026-06-27 22:51:30 -07:00 committed by GitHub
parent cdeb1aa9e2
commit 43b3e5e330
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 1080 additions and 1140 deletions

View file

@ -115,8 +115,8 @@ new places to add unrelated behavior:
- [providers/transports/](providers/transports/) owns provider transport
families. The OpenAI-chat and native Anthropic transport packages split thin
transport bases from per-request stream runners, recovery event construction,
and transport-specific parsing. Shared protocol rules should continue moving
toward [core/](core/) when they are not provider-specific.
request policy, and transport-specific parsing. Shared protocol rules should
continue moving toward [core/](core/) when they are not provider-specific.
- [messaging/workflow.py](messaging/workflow.py) coordinates messaging runtime
dependencies. Inbound turn intake, queued node execution, slash command
dependencies, and tree queue internals live in separate modules so new
@ -337,13 +337,22 @@ There are two transport families under [providers/transports/](providers/transpo
- [providers/transports/openai_chat/](providers/transports/openai_chat/)
implements `OpenAIChatTransport` for providers with OpenAI-compatible
`/chat/completions` APIs. The package owns the thin transport base,
per-request stream runner, OpenAI tool-call assembly, and OpenAI-chat recovery
event construction.
per-request stream runner, OpenAI request policy, OpenAI tool-call assembly,
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 stream runner, HTTP response helpers, and native recovery event
construction.
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,
thinking replay selection, `extra_body`, and chat-completion field normalization.
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.
Shared provider responsibilities include upstream rate limiting, model listing,
safe error mapping, transport cleanup, thinking/tool handling, retry or recovery
@ -373,6 +382,7 @@ where supported, and returning Anthropic SSE strings to the service layer.
[core/anthropic/](core/anthropic/) owns Anthropic-side protocol behavior:
- content and message conversion for OpenAI-compatible upstreams;
- request serialization primitives shared by provider request policies;
- tool schema and tool-result handling;
- thinking block handling;
- stream lifecycle through `core/anthropic/streaming`, including the neutral

View file

@ -14,6 +14,7 @@ from .errors import (
)
from .native_messages_request import sanitize_native_messages_thinking_policy
from .provider_stream_error import iter_provider_stream_error_sse_events
from .request_serialization import serialize_tool_result_content
from .streaming import (
AnthropicStreamLedger,
StreamBlockLedger,
@ -49,5 +50,6 @@ __all__ = [
"iter_provider_stream_error_sse_events",
"map_stop_reason",
"sanitize_native_messages_thinking_policy",
"serialize_tool_result_content",
"set_if_not_none",
]

View file

@ -9,6 +9,7 @@ from typing import Any
from pydantic import BaseModel
from .content import get_block_attr, get_block_type
from .request_serialization import serialize_tool_result_content
from .utils import set_if_not_none
@ -53,27 +54,6 @@ def _tool_input_schema(tool: Any) -> dict[str, Any]:
return {"type": "object", "properties": {}}
def _serialize_tool_result_content(tool_content: Any) -> str:
"""Serialize tool_result content for OpenAI ``role: tool`` messages (stable JSON for structured values)."""
if tool_content is None:
return ""
if isinstance(tool_content, str):
return tool_content
if isinstance(tool_content, dict):
return json.dumps(tool_content, ensure_ascii=False)
if isinstance(tool_content, list):
parts: list[str] = []
for item in tool_content:
if isinstance(item, dict) and item.get("type") == "text":
parts.append(str(item.get("text", "")))
elif isinstance(item, dict):
parts.append(json.dumps(item, ensure_ascii=False))
else:
parts.append(str(item))
return "\n".join(parts)
return str(tool_content)
def _clean_reasoning_content(value: Any) -> str | None:
if not isinstance(value, str):
return None
@ -435,7 +415,7 @@ class AnthropicToOpenAIConverter:
elif block_type == "tool_result":
flush_text()
tool_content = get_block_attr(block, "content", "")
serialized = _serialize_tool_result_content(tool_content)
serialized = serialize_tool_result_content(tool_content)
tuid = get_block_attr(block, "tool_use_id")
tuid_s = str(tuid) if tuid is not None else ""
result.append(
@ -485,7 +465,7 @@ class AnthropicToOpenAIConverter:
elif block_type == "tool_result":
flush_text()
tool_content = get_block_attr(block, "content", "")
serialized = _serialize_tool_result_content(tool_content)
serialized = serialize_tool_result_content(tool_content)
result.append(
{
"role": "tool",

View file

@ -0,0 +1,27 @@
"""Shared Anthropic request serialization helpers."""
from __future__ import annotations
import json
from typing import Any
def serialize_tool_result_content(content: Any) -> str:
"""Serialize Anthropic ``tool_result.content`` into provider-safe text."""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, dict):
return json.dumps(content, ensure_ascii=False)
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
parts.append(str(item.get("text", "")))
elif isinstance(item, dict):
parts.append(json.dumps(item, ensure_ascii=False))
else:
parts.append(str(item))
return "\n".join(parts)
return str(content)

View file

@ -6,9 +6,17 @@ from typing import Any
from providers.base import ProviderConfig
from providers.defaults import CEREBRAS_DEFAULT_BASE
from providers.transports.openai_chat import OpenAIChatTransport
from providers.transports.openai_chat import (
OpenAIChatRequestPolicy,
OpenAIChatTransport,
build_openai_chat_request_body,
)
from .request import build_request_body
_REQUEST_POLICY = OpenAIChatRequestPolicy(
provider_name="CEREBRAS",
include_extra_body=True,
max_tokens_field="max_completion_tokens",
)
class CerebrasProvider(OpenAIChatTransport):
@ -25,7 +33,8 @@ class CerebrasProvider(OpenAIChatTransport):
def _build_request_body(
self, request: Any, thinking_enabled: bool | None = None
) -> dict:
return build_request_body(
return build_openai_chat_request_body(
request,
thinking_enabled=self._is_thinking_enabled(request, thinking_enabled),
policy=_REQUEST_POLICY,
)

View file

@ -1,55 +0,0 @@
"""Request builder for Cerebras Inference (OpenAI-compatible chat completions).
Docs: https://inference-docs.cerebras.ai/resources/openai use ``max_completion_tokens``
in API examples; non-standard fields via ``extra_body`` with the OpenAI client.
"""
from __future__ import annotations
from typing import Any
from loguru import logger
from core.anthropic import ReasoningReplayMode, build_base_request_body
from core.anthropic.conversion import OpenAIConversionError
from providers.exceptions import InvalidRequestError
def _normalize_max_completion_tokens(body: dict[str, Any]) -> None:
if "max_completion_tokens" in body:
body.pop("max_tokens", None)
return
if "max_tokens" in body and body["max_tokens"] is not None:
body["max_completion_tokens"] = body.pop("max_tokens")
def build_request_body(request_data: Any, *, thinking_enabled: bool) -> dict:
"""Build OpenAI-format request body from an Anthropic request for Cerebras."""
logger.debug(
"CEREBRAS_REQUEST: conversion start model={} msgs={}",
getattr(request_data, "model", "?"),
len(getattr(request_data, "messages", [])),
)
try:
body = build_base_request_body(
request_data,
reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
if thinking_enabled
else ReasoningReplayMode.DISABLED,
)
except OpenAIConversionError as exc:
raise InvalidRequestError(str(exc)) from exc
request_extra = getattr(request_data, "extra_body", None)
if isinstance(request_extra, dict) and request_extra:
body["extra_body"] = dict(request_extra)
_normalize_max_completion_tokens(body)
logger.debug(
"CEREBRAS_REQUEST: conversion done model={} msgs={} tools={}",
body.get("model"),
len(body.get("messages", [])),
len(body.get("tools", [])),
)
return body

View file

@ -6,15 +6,20 @@ from typing import Any
from providers.base import ProviderConfig
from providers.defaults import CODESTRAL_DEFAULT_BASE
from providers.mistral.request import build_request_body
from providers.transports.openai_chat import OpenAIChatTransport
from providers.transports.openai_chat import (
OpenAIChatRequestPolicy,
OpenAIChatTransport,
build_openai_chat_request_body,
)
_REQUEST_POLICY = OpenAIChatRequestPolicy(provider_name="CODESTRAL")
class CodestralProvider(OpenAIChatTransport):
"""Codestral host using ``https://codestral.mistral.ai/v1/chat/completions``.
Uses a separate Codestral API key from La Plateforme (``MISTRAL_API_KEY``).
Request shaping matches Mistral La Plateforme (shared ``build_request_body``).
Request shaping matches Mistral La Plateforme.
"""
def __init__(self, config: ProviderConfig):
@ -28,7 +33,8 @@ class CodestralProvider(OpenAIChatTransport):
def _build_request_body(
self, request: Any, thinking_enabled: bool | None = None
) -> dict:
return build_request_body(
return build_openai_chat_request_body(
request,
thinking_enabled=self._is_thinking_enabled(request, thinking_enabled),
policy=_REQUEST_POLICY,
)

View file

@ -10,7 +10,7 @@ from providers.base import ProviderConfig
from providers.defaults import DEEPSEEK_ANTHROPIC_DEFAULT_BASE
from providers.transports.anthropic_messages import AnthropicMessagesTransport
from .request import build_request_body
from .compat import build_deepseek_request_body
class DeepSeekProvider(AnthropicMessagesTransport):
@ -26,7 +26,7 @@ class DeepSeekProvider(AnthropicMessagesTransport):
def _build_request_body(
self, request: Any, thinking_enabled: bool | None = None
) -> dict:
return build_request_body(
return build_deepseek_request_body(
request,
thinking_enabled=self._is_thinking_enabled(request, thinking_enabled),
)

View file

@ -1,18 +1,17 @@
"""Request builder and DeepSeek native Anthropic compatibility sanitizer."""
"""DeepSeek native Anthropic compatibility request policy."""
from __future__ import annotations
import json
from collections.abc import Mapping
from typing import Any
from loguru import logger
from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
from core.anthropic import serialize_tool_result_content
from core.anthropic.native_messages_request import dump_raw_messages_request
from providers.exceptions import InvalidRequestError
# Block types not supported on DeepSeek partial Anthropic-compatible API.
_UNSUPPORTED_MESSAGE_BLOCK_TYPES = frozenset(
{
"image",
@ -22,10 +21,6 @@ _UNSUPPORTED_MESSAGE_BLOCK_TYPES = frozenset(
"web_fetch_tool_result",
}
)
# Block types silently stripped for DeepSeek since the content is typically
# also provided via tool_result (e.g. Claude Code attaches PDFs as document
# blocks alongside a Read tool_result containing the text).
_STRIPPABLE_MESSAGE_BLOCK_TYPES = frozenset({"image", "document"})
_OMITTED_ATTACHMENT_TEXT = (
"[attachment omitted: DeepSeek does not support image or document inputs]"
@ -33,356 +28,7 @@ _OMITTED_ATTACHMENT_TEXT = (
_OMITTED_ATTACHMENT_BLOCK = {"type": "text", "text": _OMITTED_ATTACHMENT_TEXT}
def _strip_unsupported_attachment_blocks(messages: Any) -> Any:
"""Remove image/document blocks that DeepSeek cannot process.
Claude Code sends PDFs as ``document`` blocks alongside a Read ``tool_result``
that already contains the extracted text. Stripping preserves the request
instead of failing with an unsupported block error.
"""
if not isinstance(messages, list):
return messages
stripped: list[Any] = []
top_level_dropped: dict[str, int] = {}
nested_dropped: dict[str, int] = {}
placeholder_replacements = 0
for message in messages:
if not isinstance(message, dict):
stripped.append(message)
continue
content = message.get("content")
if not isinstance(content, list):
stripped.append(message)
continue
new_content: list[Any] = []
message_dropped_attachment = False
for block in content:
if isinstance(block, dict):
btype = block.get("type")
if btype in _STRIPPABLE_MESSAGE_BLOCK_TYPES:
top_level_dropped[btype] = top_level_dropped.get(btype, 0) + 1
message_dropped_attachment = True
continue
if btype == "tool_result":
inner = block.get("content")
if isinstance(inner, list):
filtered_inner: list[Any] = []
for sub in inner:
if (
isinstance(sub, dict)
and sub.get("type") in _STRIPPABLE_MESSAGE_BLOCK_TYPES
):
sub_type = sub["type"]
nested_dropped[sub_type] = (
nested_dropped.get(sub_type, 0) + 1
)
continue
filtered_inner.append(sub)
if not filtered_inner:
filtered_inner = [_OMITTED_ATTACHMENT_BLOCK]
placeholder_replacements += 1
new_block = dict(block)
new_block["content"] = filtered_inner
new_content.append(new_block)
continue
new_content.append(block)
if not new_content and message_dropped_attachment:
new_content = [_OMITTED_ATTACHMENT_BLOCK]
placeholder_replacements += 1
new_msg = dict(message)
new_msg["content"] = new_content
stripped.append(new_msg)
if top_level_dropped or nested_dropped:
logger.warning(
"DEEPSEEK_REQUEST: stripped unsupported attachment blocks "
"(top_level={} nested_in_tool_result={} placeholder_tool_results={}). "
"DeepSeek has no vision/document support; the model will not see this content.",
dict(top_level_dropped),
dict(nested_dropped),
placeholder_replacements,
)
return stripped
def _is_server_listed_tool(tool: Mapping[str, Any]) -> bool:
"""True for Anthropic web_search / web_fetch-style tool definitions (listed tools)."""
name = (tool.get("name") or "").strip()
if name in ("web_search", "web_fetch"):
return True
typ = tool.get("type")
if isinstance(typ, str):
return typ.startswith("web_search") or typ.startswith("web_fetch")
return False
def _walk_block_list_for_unsupported(blocks: Any, *, where: str) -> None:
if not isinstance(blocks, list):
return
for block in blocks:
if not isinstance(block, dict):
continue
btype = block.get("type")
if btype in _UNSUPPORTED_MESSAGE_BLOCK_TYPES:
raise InvalidRequestError(
f"DeepSeek native does not support {btype!r} blocks ({where})."
)
if btype == "tool_result" and "content" in block:
_walk_block_list_for_unsupported(
block["content"], where=f"{where} (tool_result content)"
)
def _validate_deepseek_native_request_dict(data: dict[str, Any]) -> None:
mcp = data.get("mcp_servers")
if mcp:
raise InvalidRequestError(
"DeepSeek native does not support mcp_servers on requests."
)
for tool in data.get("tools") or ():
if not isinstance(tool, dict):
continue
if _is_server_listed_tool(tool):
raise InvalidRequestError(
"DeepSeek native does not support listed Anthropic server tools "
"(web_search / web_fetch). Remove them or use a different provider."
)
for i, message in enumerate(data.get("messages") or ()):
if not isinstance(message, dict):
continue
c = message.get("content")
if isinstance(c, list):
_walk_block_list_for_unsupported(c, where=f"messages[{i}].content")
if isinstance(c, str) and "<think>" in c:
# Unusual, but block encoded redacted content — treat as unsafe for DeepSeek.
pass
system = data.get("system")
if isinstance(system, list):
_walk_block_list_for_unsupported(system, where="system")
def _has_tool_history_blocks(message: Mapping[str, Any]) -> bool:
role = message.get("role")
content = message.get("content")
if not isinstance(content, list):
return False
for block in content:
if not isinstance(block, dict):
continue
btype = block.get("type")
if role == "assistant" and btype == "tool_use":
return True
if role == "user" and btype == "tool_result":
return True
return False
def _has_replayable_thinking_before_tool_use(message: Mapping[str, Any]) -> bool:
if message.get("role") != "assistant":
return False
content = message.get("content")
if not isinstance(content, list):
return False
has_thinking = False
for block in content:
if not isinstance(block, dict):
continue
btype = block.get("type")
if btype == "thinking" and isinstance(block.get("thinking"), str):
has_thinking = bool(block["thinking"])
continue
if btype == "tool_use":
return has_thinking
return False
def _has_tool_history(data: dict[str, Any]) -> bool:
for message in data.get("messages") or ():
if isinstance(message, Mapping) and _has_tool_history_blocks(message):
return True
return False
def _has_replayable_tool_thinking(data: dict[str, Any]) -> bool:
for message in data.get("messages") or ():
if isinstance(message, Mapping) and _has_replayable_thinking_before_tool_use(
message
):
return True
return False
def _remove_deepseek_thinking_hints(data: dict[str, Any]) -> None:
"""Remove request hints that can keep DeepSeek in thinking mode after fallback."""
output_config = data.get("output_config")
if isinstance(output_config, dict) and "effort" in output_config:
cleaned_output_config = dict(output_config)
cleaned_output_config.pop("effort", None)
if cleaned_output_config:
data["output_config"] = cleaned_output_config
else:
data.pop("output_config", None)
context_management = data.get("context_management")
if not isinstance(context_management, dict):
return
edits = context_management.get("edits")
if not isinstance(edits, list):
return
filtered_edits = [
edit
for edit in edits
if not (
isinstance(edit, dict)
and isinstance(edit.get("type"), str)
and edit["type"].startswith("clear_thinking_")
)
]
if len(filtered_edits) == len(edits):
return
cleaned_context_management = dict(context_management)
if filtered_edits:
cleaned_context_management["edits"] = filtered_edits
data["context_management"] = cleaned_context_management
else:
cleaned_context_management.pop("edits", None)
if cleaned_context_management:
data["context_management"] = cleaned_context_management
else:
data.pop("context_management", None)
def sanitize_deepseek_messages_for_native(
messages: Any, *, thinking_enabled: bool
) -> Any:
"""Filter assistant content for DeepSeek: unsigned ``thinking`` is allowed; no ``redacted_thinking``."""
if not isinstance(messages, list):
return messages
sanitized: list[Any] = []
for message in messages:
if not isinstance(message, dict):
sanitized.append(message)
continue
if message.get("role") != "assistant":
sanitized.append(message)
continue
content = message.get("content")
if not isinstance(content, list):
sanitized.append(message)
continue
if not thinking_enabled:
filtered = [
block
for block in content
if not (
isinstance(block, dict)
and block.get("type") in ("thinking", "redacted_thinking")
)
]
else:
filtered = [
block
for block in content
if not (
isinstance(block, dict) and block.get("type") == "redacted_thinking"
)
]
new_msg = dict(message)
new_msg["content"] = filtered or ""
sanitized.append(new_msg)
return sanitized
def _serialize_tool_result_content(content: Any) -> str:
"""Serialize tool_result content to string for DeepSeek API.
DeepSeek's Anthropic-compatible API expects tool_result.content to be a string,
not an array of content blocks.
"""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, dict):
return json.dumps(content, ensure_ascii=False)
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
parts.append(str(item.get("text", "")))
elif isinstance(item, dict):
parts.append(json.dumps(item, ensure_ascii=False))
else:
parts.append(str(item))
return "\n".join(parts)
return str(content)
def _normalize_tool_result_content(messages: Any) -> Any:
"""Normalize tool_result content to strings for DeepSeek API compatibility."""
if not isinstance(messages, list):
return messages
normalized: list[Any] = []
for message in messages:
if not isinstance(message, dict):
normalized.append(message)
continue
content = message.get("content")
if not isinstance(content, list):
normalized.append(message)
continue
# Process content blocks
new_content: list[Any] = []
for block in content:
if not isinstance(block, dict):
new_content.append(block)
continue
if block.get("type") == "tool_result":
# Normalize tool_result content to string
normalized_block = dict(block)
normalized_block["content"] = _serialize_tool_result_content(
block.get("content")
)
new_content.append(normalized_block)
else:
new_content.append(block)
new_msg = dict(message)
new_msg["content"] = new_content
normalized.append(new_msg)
return normalized
def _strip_reasoning_content_when_native(messages: Any) -> Any:
"""``reasoning_content`` is OpenAI-helper metadata; not part of native Anthropic body."""
if not isinstance(messages, list):
return messages
out: list[Any] = []
for m in messages:
if not isinstance(m, dict):
out.append(m)
continue
msg = {k: v for k, v in m.items() if k != "reasoning_content"}
out.append(msg)
return out
def build_request_body(request_data: Any, *, thinking_enabled: bool) -> dict:
def build_deepseek_request_body(request_data: Any, *, thinking_enabled: bool) -> dict:
"""Build a DeepSeek ``/v1/messages`` JSON body (Anthropic format)."""
logger.debug(
"DEEPSEEK_REQUEST: native build model={} msgs={}",
@ -459,6 +105,316 @@ def build_request_body(request_data: Any, *, thinking_enabled: bool) -> dict:
return data
def sanitize_deepseek_messages_for_native(
messages: Any, *, thinking_enabled: bool
) -> Any:
"""Filter assistant content for DeepSeek's partial native Anthropic support."""
if not isinstance(messages, list):
return messages
sanitized: list[Any] = []
for message in messages:
if not isinstance(message, dict):
sanitized.append(message)
continue
if message.get("role") != "assistant":
sanitized.append(message)
continue
content = message.get("content")
if not isinstance(content, list):
sanitized.append(message)
continue
if not thinking_enabled:
filtered = [
block
for block in content
if not (
isinstance(block, dict)
and block.get("type") in ("thinking", "redacted_thinking")
)
]
else:
filtered = [
block
for block in content
if not (
isinstance(block, dict) and block.get("type") == "redacted_thinking"
)
]
new_msg = dict(message)
new_msg["content"] = filtered or ""
sanitized.append(new_msg)
return sanitized
def _strip_unsupported_attachment_blocks(messages: Any) -> Any:
if not isinstance(messages, list):
return messages
stripped: list[Any] = []
top_level_dropped: dict[str, int] = {}
nested_dropped: dict[str, int] = {}
placeholder_replacements = 0
for message in messages:
if not isinstance(message, dict):
stripped.append(message)
continue
content = message.get("content")
if not isinstance(content, list):
stripped.append(message)
continue
new_content: list[Any] = []
message_dropped_attachment = False
for block in content:
if isinstance(block, dict):
btype = block.get("type")
if btype in _STRIPPABLE_MESSAGE_BLOCK_TYPES:
top_level_dropped[btype] = top_level_dropped.get(btype, 0) + 1
message_dropped_attachment = True
continue
if btype == "tool_result":
inner = block.get("content")
if isinstance(inner, list):
filtered_inner: list[Any] = []
for sub in inner:
if (
isinstance(sub, dict)
and sub.get("type") in _STRIPPABLE_MESSAGE_BLOCK_TYPES
):
sub_type = sub["type"]
nested_dropped[sub_type] = (
nested_dropped.get(sub_type, 0) + 1
)
continue
filtered_inner.append(sub)
if not filtered_inner:
filtered_inner = [_OMITTED_ATTACHMENT_BLOCK]
placeholder_replacements += 1
new_block = dict(block)
new_block["content"] = filtered_inner
new_content.append(new_block)
continue
new_content.append(block)
if not new_content and message_dropped_attachment:
new_content = [_OMITTED_ATTACHMENT_BLOCK]
placeholder_replacements += 1
new_msg = dict(message)
new_msg["content"] = new_content
stripped.append(new_msg)
if top_level_dropped or nested_dropped:
logger.warning(
"DEEPSEEK_REQUEST: stripped unsupported attachment blocks "
"(top_level={} nested_in_tool_result={} placeholder_tool_results={}). "
"DeepSeek has no vision/document support; the model will not see this content.",
dict(top_level_dropped),
dict(nested_dropped),
placeholder_replacements,
)
return stripped
def _is_server_listed_tool(tool: Mapping[str, Any]) -> bool:
name = (tool.get("name") or "").strip()
if name in ("web_search", "web_fetch"):
return True
typ = tool.get("type")
if isinstance(typ, str):
return typ.startswith("web_search") or typ.startswith("web_fetch")
return False
def _walk_block_list_for_unsupported(blocks: Any, *, where: str) -> None:
if not isinstance(blocks, list):
return
for block in blocks:
if not isinstance(block, dict):
continue
btype = block.get("type")
if btype in _UNSUPPORTED_MESSAGE_BLOCK_TYPES:
raise InvalidRequestError(
f"DeepSeek native does not support {btype!r} blocks ({where})."
)
if btype == "tool_result" and "content" in block:
_walk_block_list_for_unsupported(
block["content"], where=f"{where} (tool_result content)"
)
def _validate_deepseek_native_request_dict(data: dict[str, Any]) -> None:
mcp = data.get("mcp_servers")
if mcp:
raise InvalidRequestError(
"DeepSeek native does not support mcp_servers on requests."
)
for tool in data.get("tools") or ():
if not isinstance(tool, dict):
continue
if _is_server_listed_tool(tool):
raise InvalidRequestError(
"DeepSeek native does not support listed Anthropic server tools "
"(web_search / web_fetch). Remove them or use a different provider."
)
for i, message in enumerate(data.get("messages") or ()):
if not isinstance(message, dict):
continue
content = message.get("content")
if isinstance(content, list):
_walk_block_list_for_unsupported(content, where=f"messages[{i}].content")
system = data.get("system")
if isinstance(system, list):
_walk_block_list_for_unsupported(system, where="system")
def _has_tool_history_blocks(message: Mapping[str, Any]) -> bool:
role = message.get("role")
content = message.get("content")
if not isinstance(content, list):
return False
for block in content:
if not isinstance(block, dict):
continue
btype = block.get("type")
if role == "assistant" and btype == "tool_use":
return True
if role == "user" and btype == "tool_result":
return True
return False
def _has_replayable_thinking_before_tool_use(message: Mapping[str, Any]) -> bool:
if message.get("role") != "assistant":
return False
content = message.get("content")
if not isinstance(content, list):
return False
has_thinking = False
for block in content:
if not isinstance(block, dict):
continue
btype = block.get("type")
if btype == "thinking" and isinstance(block.get("thinking"), str):
has_thinking = bool(block["thinking"])
continue
if btype == "tool_use":
return has_thinking
return False
def _has_tool_history(data: dict[str, Any]) -> bool:
for message in data.get("messages") or ():
if isinstance(message, Mapping) and _has_tool_history_blocks(message):
return True
return False
def _has_replayable_tool_thinking(data: dict[str, Any]) -> bool:
for message in data.get("messages") or ():
if isinstance(message, Mapping) and _has_replayable_thinking_before_tool_use(
message
):
return True
return False
def _remove_deepseek_thinking_hints(data: dict[str, Any]) -> None:
output_config = data.get("output_config")
if isinstance(output_config, dict) and "effort" in output_config:
cleaned_output_config = dict(output_config)
cleaned_output_config.pop("effort", None)
if cleaned_output_config:
data["output_config"] = cleaned_output_config
else:
data.pop("output_config", None)
context_management = data.get("context_management")
if not isinstance(context_management, dict):
return
edits = context_management.get("edits")
if not isinstance(edits, list):
return
filtered_edits = [
edit
for edit in edits
if not (
isinstance(edit, dict)
and isinstance(edit.get("type"), str)
and edit["type"].startswith("clear_thinking_")
)
]
if len(filtered_edits) == len(edits):
return
cleaned_context_management = dict(context_management)
if filtered_edits:
cleaned_context_management["edits"] = filtered_edits
data["context_management"] = cleaned_context_management
else:
cleaned_context_management.pop("edits", None)
if cleaned_context_management:
data["context_management"] = cleaned_context_management
else:
data.pop("context_management", None)
def _normalize_tool_result_content(messages: Any) -> Any:
if not isinstance(messages, list):
return messages
normalized: list[Any] = []
for message in messages:
if not isinstance(message, dict):
normalized.append(message)
continue
content = message.get("content")
if not isinstance(content, list):
normalized.append(message)
continue
new_content: list[Any] = []
for block in content:
if not isinstance(block, dict):
new_content.append(block)
continue
if block.get("type") == "tool_result":
normalized_block = dict(block)
normalized_block["content"] = serialize_tool_result_content(
block.get("content")
)
new_content.append(normalized_block)
else:
new_content.append(block)
new_msg = dict(message)
new_msg["content"] = new_content
normalized.append(new_msg)
return normalized
def _strip_reasoning_content_when_native(messages: Any) -> Any:
if not isinstance(messages, list):
return messages
out: list[Any] = []
for message in messages:
if not isinstance(message, dict):
out.append(message)
continue
out.append(
{key: value for key, value in message.items() if key != "reasoning_content"}
)
return out
def _downgrade_forced_tool_choice(data: dict[str, Any]) -> None:
tool_choice = data.get("tool_choice")
if not isinstance(tool_choice, dict):

View file

@ -5,12 +5,18 @@ from __future__ import annotations
from typing import Any
from providers.base import ProviderConfig
from providers.transports.anthropic_messages import AnthropicMessagesTransport
from .request import build_request_body
from providers.transports.anthropic_messages import (
AnthropicMessagesTransport,
NativeMessagesRequestPolicy,
build_native_messages_request_body,
)
FIREWORKS_BASE_URL = "https://api.fireworks.ai/inference/v1"
_ANTHROPIC_VERSION = "2023-06-01"
_REQUEST_POLICY = NativeMessagesRequestPolicy(
provider_name="FIREWORKS",
extra_body="merge_validated",
)
class FireworksProvider(AnthropicMessagesTransport):
@ -26,11 +32,10 @@ class FireworksProvider(AnthropicMessagesTransport):
def _build_request_body(
self, request: Any, thinking_enabled: bool | None = None
) -> dict:
if thinking_enabled is None:
thinking_enabled = self._is_thinking_enabled(request)
return build_request_body(
return build_native_messages_request_body(
request,
thinking_enabled=thinking_enabled,
thinking_enabled=self._is_thinking_enabled(request, thinking_enabled),
policy=_REQUEST_POLICY,
)
def _request_headers(self) -> dict[str, str]:

View file

@ -1,48 +0,0 @@
"""Native Anthropic Messages request builder for Fireworks AI."""
from __future__ import annotations
from typing import Any
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,
validate_openrouter_extra_body,
)
from providers.exceptions import InvalidRequestError
def build_request_body(request_data: Any, *, thinking_enabled: bool) -> dict:
"""Build JSON for Fireworks Anthropic-compat ``POST …/messages``."""
logger.debug(
"FIREWORKS_REQUEST: native build model={} msgs={}",
getattr(request_data, "model", "?"),
len(getattr(request_data, "messages", [])),
)
body = build_base_native_anthropic_request_body(
request_data,
default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
thinking_enabled=thinking_enabled,
)
extra = getattr(request_data, "extra_body", None)
if isinstance(extra, dict) and extra:
try:
validate_openrouter_extra_body(extra)
except OpenRouterExtraBodyError as exc:
raise InvalidRequestError(str(exc)) from exc
body.update(extra)
body["stream"] = True
logger.debug(
"FIREWORKS_REQUEST: build done model={} msgs={} tools={}",
body.get("model"),
len(body.get("messages", [])),
len(body.get("tools", [])),
)
return body

View file

@ -7,11 +7,16 @@ from typing import Any
from providers.base import ProviderConfig
from providers.defaults import GEMINI_DEFAULT_BASE
from providers.transports.openai_chat import OpenAIChatTransport
from providers.transports.openai_chat import (
OpenAIChatRequestPolicy,
OpenAIChatTransport,
build_openai_chat_request_body,
)
from .request import build_request_body
from .quirks import apply_gemini_request_quirks
_MAX_TOOL_CALL_EXTRA_CONTENT_CACHE = 4096
_REQUEST_POLICY = OpenAIChatRequestPolicy(provider_name="GEMINI")
class GeminiProvider(OpenAIChatTransport):
@ -42,8 +47,16 @@ class GeminiProvider(OpenAIChatTransport):
def _build_request_body(
self, request: Any, thinking_enabled: bool | None = None
) -> dict:
return build_request_body(
return build_openai_chat_request_body(
request,
thinking_enabled=self._is_thinking_enabled(request, thinking_enabled),
tool_call_extra_content_by_id=self._tool_call_extra_content_by_id,
policy=_REQUEST_POLICY,
postprocessors=(
lambda body, request_data, enabled: apply_gemini_request_quirks(
body,
request_data,
enabled,
tool_call_extra_content_by_id=self._tool_call_extra_content_by_id,
),
),
)

View file

@ -1,19 +1,40 @@
"""Request builder for Google Gemini API (AI Studio OpenAI-compatible chat completions)."""
"""Gemini request-body quirks for the OpenAI-compatible transport."""
from __future__ import annotations
from copy import deepcopy
from typing import Any, cast
from loguru import logger
from core.anthropic import ReasoningReplayMode, build_base_request_body
from core.anthropic.conversion import OpenAIConversionError
from providers.exceptions import InvalidRequestError
GEMINI_SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
def apply_gemini_request_quirks(
body: dict[str, Any],
request_data: Any,
thinking_enabled: bool,
*,
tool_call_extra_content_by_id: dict[str, dict[str, Any]] | None = None,
) -> None:
"""Apply Google-specific request extensions after common OpenAI conversion."""
extra_body: dict[str, Any] = {}
request_extra = getattr(request_data, "extra_body", None)
if isinstance(request_extra, dict):
extra_body.update(deepcopy(request_extra))
if thinking_enabled:
_apply_thinking_config(extra_body)
else:
body["reasoning_effort"] = "none"
if extra_body:
body["extra_body"] = extra_body
_apply_gemini_tool_call_signatures(
body,
tool_call_extra_content_by_id=tool_call_extra_content_by_id,
)
def _ensure_dict(container: dict[str, Any], key: str) -> dict[str, Any]:
value = container.get(key)
if isinstance(value, dict):
@ -148,52 +169,3 @@ def _apply_gemini_tool_call_signatures(
return
_apply_cached_tool_call_signatures(messages, tool_call_extra_content_by_id or {})
_apply_gemini_3_missing_current_turn_signatures(body, messages)
def build_request_body(
request_data: Any,
*,
thinking_enabled: bool,
tool_call_extra_content_by_id: dict[str, dict[str, Any]] | None = None,
) -> dict:
"""Build OpenAI-format request body from an Anthropic request for Gemini."""
logger.debug(
"GEMINI_REQUEST: conversion start model={} msgs={}",
getattr(request_data, "model", "?"),
len(getattr(request_data, "messages", [])),
)
try:
body = build_base_request_body(
request_data,
reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
if thinking_enabled
else ReasoningReplayMode.DISABLED,
)
except OpenAIConversionError as exc:
raise InvalidRequestError(str(exc)) from exc
extra_body: dict[str, Any] = {}
request_extra = getattr(request_data, "extra_body", None)
if isinstance(request_extra, dict):
extra_body.update(deepcopy(request_extra))
if thinking_enabled:
_apply_thinking_config(extra_body)
else:
body["reasoning_effort"] = "none"
if extra_body:
body["extra_body"] = extra_body
_apply_gemini_tool_call_signatures(
body,
tool_call_extra_content_by_id=tool_call_extra_content_by_id,
)
logger.debug(
"GEMINI_REQUEST: conversion done model={} msgs={} tools={}",
body.get("model"),
len(body.get("messages", [])),
len(body.get("tools", [])),
)
return body

View file

@ -6,9 +6,20 @@ from typing import Any
from providers.base import ProviderConfig
from providers.defaults import GROQ_DEFAULT_BASE
from providers.transports.openai_chat import OpenAIChatTransport
from providers.transports.openai_chat import (
OpenAIChatRequestPolicy,
OpenAIChatTransport,
build_openai_chat_request_body,
)
from .request import build_request_body
_REQUEST_POLICY = OpenAIChatRequestPolicy(
provider_name="GROQ",
include_extra_body=True,
max_tokens_field="max_completion_tokens",
strip_message_names=True,
unsupported_body_keys=frozenset({"logprobs", "logit_bias", "top_logprobs"}),
normalize_n_to_one=True,
)
class GroqProvider(OpenAIChatTransport):
@ -25,7 +36,8 @@ class GroqProvider(OpenAIChatTransport):
def _build_request_body(
self, request: Any, thinking_enabled: bool | None = None
) -> dict:
return build_request_body(
return build_openai_chat_request_body(
request,
thinking_enabled=self._is_thinking_enabled(request, thinking_enabled),
policy=_REQUEST_POLICY,
)

View file

@ -1,83 +0,0 @@
"""Request builder for Groq (OpenAI-compatible chat completions).
See Groq docs: https://console.groq.com/docs/openai ``messages[].name`` and
unsupported token fields yield 400; ``max_completion_tokens`` is preferred over
deprecated ``max_tokens``.
"""
from __future__ import annotations
from typing import Any
from loguru import logger
from core.anthropic import ReasoningReplayMode, build_base_request_body
from core.anthropic.conversion import OpenAIConversionError
from providers.exceptions import InvalidRequestError
_GROQ_UNSUPPORTED_TOP_KEYS = frozenset({"logprobs", "logit_bias", "top_logprobs"})
def _strip_message_names(messages: Any) -> None:
"""Remove ``name`` from each chat message (Groq rejects ``messages[].name``)."""
if not isinstance(messages, list):
return
for msg in messages:
if isinstance(msg, dict):
msg.pop("name", None)
def _strip_unsupported_body_keys(body: dict[str, Any]) -> None:
for key in _GROQ_UNSUPPORTED_TOP_KEYS:
body.pop(key, None)
def _normalize_max_completion_tokens(body: dict[str, Any]) -> None:
if "max_completion_tokens" in body:
body.pop("max_tokens", None)
return
if "max_tokens" in body and body["max_tokens"] is not None:
body["max_completion_tokens"] = body.pop("max_tokens")
def _normalize_n_candidates(body: dict[str, Any]) -> None:
"""Groq only supports ``n`` = 1; coerce if present."""
if body.get("n") is None:
return
body["n"] = 1
def build_request_body(request_data: Any, *, thinking_enabled: bool) -> dict:
"""Build OpenAI-format request body from an Anthropic request for Groq."""
logger.debug(
"GROQ_REQUEST: conversion start model={} msgs={}",
getattr(request_data, "model", "?"),
len(getattr(request_data, "messages", [])),
)
try:
body = build_base_request_body(
request_data,
reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
if thinking_enabled
else ReasoningReplayMode.DISABLED,
)
except OpenAIConversionError as exc:
raise InvalidRequestError(str(exc)) from exc
request_extra = getattr(request_data, "extra_body", None)
if isinstance(request_extra, dict) and request_extra:
merged = dict(request_extra)
body["extra_body"] = merged
_strip_message_names(body.get("messages"))
_strip_unsupported_body_keys(body)
_normalize_max_completion_tokens(body)
_normalize_n_candidates(body)
logger.debug(
"GROQ_REQUEST: conversion done model={} msgs={} tools={}",
body.get("model"),
len(body.get("messages", [])),
len(body.get("tools", [])),
)
return body

View file

@ -8,12 +8,21 @@ import httpx
from providers.base import ProviderConfig
from providers.defaults import KIMI_DEFAULT_BASE
from providers.transports.anthropic_messages import AnthropicMessagesTransport
from .request import build_request_body
from providers.transports.anthropic_messages import (
AnthropicMessagesTransport,
NativeMessagesRequestPolicy,
build_native_messages_request_body,
)
_MOONSHOT_OPENAI_MODELS_URL = "https://api.moonshot.ai/v1/models"
_ANTHROPIC_VERSION = "2023-06-01"
_REQUEST_POLICY = NativeMessagesRequestPolicy(
provider_name="KIMI",
extra_body="reject",
reject_extra_body_message=(
"Kimi native Messages API does not support extra_body on requests."
),
)
class KimiProvider(AnthropicMessagesTransport):
@ -29,9 +38,10 @@ class KimiProvider(AnthropicMessagesTransport):
def _build_request_body(
self, request: Any, thinking_enabled: bool | None = None
) -> dict:
return build_request_body(
return build_native_messages_request_body(
request,
thinking_enabled=self._is_thinking_enabled(request, thinking_enabled),
policy=_REQUEST_POLICY,
)
def _request_headers(self) -> dict[str, str]:

View file

@ -1,42 +0,0 @@
"""Native Anthropic Messages request builder for Kimi (Moonshot)."""
from __future__ import annotations
from typing import Any
from loguru import logger
from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
from core.anthropic.native_messages_request import (
build_base_native_anthropic_request_body,
)
from providers.exceptions import InvalidRequestError
def build_request_body(request_data: Any, *, thinking_enabled: bool) -> dict:
"""Build JSON for Kimi Anthropic-compat ``POST …/messages``."""
logger.debug(
"KIMI_REQUEST: native build model={} msgs={}",
getattr(request_data, "model", "?"),
len(getattr(request_data, "messages", [])),
)
body = build_base_native_anthropic_request_body(
request_data,
default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
thinking_enabled=thinking_enabled,
)
extra = getattr(request_data, "extra_body", None)
if extra:
raise InvalidRequestError(
"Kimi native Messages API does not support extra_body on requests."
)
body["stream"] = True
logger.debug(
"KIMI_REQUEST: build done model={} msgs={} tools={}",
body.get("model"),
len(body.get("messages", [])),
len(body.get("tools", [])),
)
return body

View file

@ -6,9 +6,13 @@ from typing import Any
from providers.base import ProviderConfig
from providers.defaults import MISTRAL_DEFAULT_BASE
from providers.transports.openai_chat import OpenAIChatTransport
from providers.transports.openai_chat import (
OpenAIChatRequestPolicy,
OpenAIChatTransport,
build_openai_chat_request_body,
)
from .request import build_request_body
_REQUEST_POLICY = OpenAIChatRequestPolicy(provider_name="MISTRAL")
class MistralProvider(OpenAIChatTransport):
@ -25,7 +29,8 @@ class MistralProvider(OpenAIChatTransport):
def _build_request_body(
self, request: Any, thinking_enabled: bool | None = None
) -> dict:
return build_request_body(
return build_openai_chat_request_body(
request,
thinking_enabled=self._is_thinking_enabled(request, thinking_enabled),
policy=_REQUEST_POLICY,
)

View file

@ -1,37 +0,0 @@
"""Request builder for Mistral La Plateforme (OpenAI-compatible chat completions)."""
from __future__ import annotations
from typing import Any
from loguru import logger
from core.anthropic import ReasoningReplayMode, build_base_request_body
from core.anthropic.conversion import OpenAIConversionError
from providers.exceptions import InvalidRequestError
def build_request_body(request_data: Any, *, thinking_enabled: bool) -> dict:
"""Build OpenAI-format request body from Anthropic request for Mistral."""
logger.debug(
"MISTRAL_REQUEST: conversion start model={} msgs={}",
getattr(request_data, "model", "?"),
len(getattr(request_data, "messages", [])),
)
try:
body = build_base_request_body(
request_data,
reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
if thinking_enabled
else ReasoningReplayMode.DISABLED,
)
except OpenAIConversionError as exc:
raise InvalidRequestError(str(exc)) from exc
logger.debug(
"MISTRAL_REQUEST: conversion done model={} msgs={} tools={}",
body.get("model"),
len(body.get("messages", [])),
len(body.get("tools", [])),
)
return body

View file

@ -11,12 +11,14 @@ from providers.base import ProviderConfig
from providers.defaults import NVIDIA_NIM_DEFAULT_BASE
from providers.transports.openai_chat import OpenAIChatTransport
from .request import (
body_without_nim_tool_argument_aliases,
build_request_body,
from .request_options import build_nim_request_body
from .retry import (
clone_body_without_chat_template,
clone_body_without_reasoning_budget,
clone_body_without_reasoning_content,
)
from .tool_schema import (
body_without_nim_tool_argument_aliases,
nim_tool_argument_aliases_from_body,
)
@ -37,7 +39,7 @@ class NvidiaNimProvider(OpenAIChatTransport):
self, request: Any, thinking_enabled: bool | None = None
) -> dict:
"""Internal helper for tests and shared building."""
return build_request_body(
return build_nim_request_body(
request,
self._nim_settings,
thinking_enabled=self._is_thinking_enabled(request, thinking_enabled),

View file

@ -0,0 +1,109 @@
"""NVIDIA NIM request option injection."""
from __future__ import annotations
from typing import Any
from config.nim import NimSettings
from core.anthropic import set_if_not_none
from providers.transports.openai_chat import (
OpenAIChatRequestPolicy,
build_openai_chat_request_body,
)
from .tool_schema import sanitize_nim_tool_schemas
_REQUEST_POLICY = OpenAIChatRequestPolicy(provider_name="NIM")
def build_nim_request_body(
request_data: Any, nim: NimSettings, *, thinking_enabled: bool
) -> dict[str, Any]:
"""Build OpenAI-format request body from Anthropic request plus NIM settings."""
return build_openai_chat_request_body(
request_data,
thinking_enabled=thinking_enabled,
policy=_REQUEST_POLICY,
postprocessors=(
lambda body, request, enabled: apply_nim_request_options(
body,
request,
enabled,
nim=nim,
),
),
)
def apply_nim_request_options(
body: dict[str, Any],
request_data: Any,
thinking_enabled: bool,
*,
nim: NimSettings,
) -> None:
"""Apply NIM schema repairs and configured request defaults."""
sanitize_nim_tool_schemas(body)
max_tokens = body.get("max_tokens") or getattr(request_data, "max_tokens", None)
if max_tokens is None:
max_tokens = nim.max_tokens
elif nim.max_tokens:
max_tokens = min(max_tokens, nim.max_tokens)
set_if_not_none(body, "max_tokens", max_tokens)
if body.get("temperature") is None and nim.temperature is not None:
body["temperature"] = nim.temperature
if body.get("top_p") is None and nim.top_p is not None:
body["top_p"] = nim.top_p
if "stop" not in body and nim.stop:
body["stop"] = nim.stop
if nim.presence_penalty != 0.0:
body["presence_penalty"] = nim.presence_penalty
if nim.frequency_penalty != 0.0:
body["frequency_penalty"] = nim.frequency_penalty
if nim.seed is not None:
body["seed"] = nim.seed
body["parallel_tool_calls"] = nim.parallel_tool_calls
extra_body: dict[str, Any] = {}
request_extra = getattr(request_data, "extra_body", None)
if request_extra:
extra_body.update(request_extra)
if thinking_enabled:
chat_template_kwargs = extra_body.setdefault(
"chat_template_kwargs", {"thinking": True, "enable_thinking": True}
)
if isinstance(chat_template_kwargs, dict):
chat_template_kwargs.setdefault("reasoning_budget", max_tokens)
req_top_k = getattr(request_data, "top_k", None)
top_k = req_top_k if req_top_k is not None else nim.top_k
_set_extra(extra_body, "top_k", top_k, ignore_value=-1)
_set_extra(extra_body, "min_p", nim.min_p, ignore_value=0.0)
_set_extra(
extra_body, "repetition_penalty", nim.repetition_penalty, ignore_value=1.0
)
_set_extra(extra_body, "min_tokens", nim.min_tokens, ignore_value=0)
_set_extra(extra_body, "chat_template", nim.chat_template)
_set_extra(extra_body, "request_id", nim.request_id)
_set_extra(extra_body, "ignore_eos", nim.ignore_eos)
if extra_body:
body["extra_body"] = extra_body
def _set_extra(
extra_body: dict[str, Any], key: str, value: Any, ignore_value: Any = None
) -> None:
if key in extra_body:
return
if value is None:
return
if ignore_value is not None and value == ignore_value:
return
extra_body[key] = value

View file

@ -0,0 +1,71 @@
"""NVIDIA NIM retry-body downgrade helpers."""
from __future__ import annotations
from collections.abc import Callable
from copy import deepcopy
from typing import Any
def clone_body_without_reasoning_budget(body: dict[str, Any]) -> dict[str, Any] | None:
"""Clone a request body and strip only reasoning_budget fields."""
return _clone_strip_extra_body(body, _strip_reasoning_budget_fields)
def clone_body_without_chat_template(body: dict[str, Any]) -> dict[str, Any] | None:
"""Clone a request body and strip only chat_template."""
return _clone_strip_extra_body(body, _strip_chat_template_field)
def clone_body_without_reasoning_content(
body: dict[str, Any],
) -> dict[str, Any] | None:
"""Clone a request body and strip assistant message ``reasoning_content`` fields."""
cloned_body = deepcopy(body)
if not _strip_message_reasoning_content(cloned_body):
return None
return cloned_body
def _clone_strip_extra_body(
body: dict[str, Any],
strip: Callable[[dict[str, Any]], bool],
) -> dict[str, Any] | None:
cloned_body = deepcopy(body)
extra_body = cloned_body.get("extra_body")
if not isinstance(extra_body, dict):
return None
if not strip(extra_body):
return None
if not extra_body:
cloned_body.pop("extra_body", None)
return cloned_body
def _strip_reasoning_budget_fields(extra_body: dict[str, Any]) -> bool:
removed = extra_body.pop("reasoning_budget", None) is not None
chat_template_kwargs = extra_body.get("chat_template_kwargs")
if (
isinstance(chat_template_kwargs, dict)
and chat_template_kwargs.pop("reasoning_budget", None) is not None
):
removed = True
return removed
def _strip_chat_template_field(extra_body: dict[str, Any]) -> bool:
return extra_body.pop("chat_template", None) is not None
def _strip_message_reasoning_content(body: dict[str, Any]) -> bool:
removed = False
messages = body.get("messages")
if not isinstance(messages, list):
return False
for message in messages:
if (
isinstance(message, dict)
and message.pop("reasoning_content", None) is not None
):
removed = True
return removed

View file

@ -1,20 +1,9 @@
"""Request builder for NVIDIA NIM provider."""
"""NVIDIA NIM tool schema sanitization and private argument aliases."""
from __future__ import annotations
from collections.abc import Callable
from copy import deepcopy
from typing import Any
from loguru import logger
from config.nim import NimSettings
from core.anthropic import (
ReasoningReplayMode,
build_base_request_body,
set_if_not_none,
)
from core.anthropic.conversion import OpenAIConversionError
from providers.exceptions import InvalidRequestError
_SCHEMA_VALUE_KEYS = frozenset(
{
"additionalProperties",
@ -39,52 +28,71 @@ _NIM_TOOL_PARAMETER_ALIAS_PREFIX = "_fcc_arg_"
_NIM_UNSAFE_TOOL_PARAMETER_NAMES = frozenset({"type"})
def _clone_strip_extra_body(
def sanitize_nim_tool_schemas(body: dict[str, Any]) -> None:
"""Sanitize only tool parameter schemas, preserving tool calls/history."""
tools = body.get("tools")
if not isinstance(tools, list):
return
tool_argument_aliases: dict[str, dict[str, str]] = {}
sanitized_tools: list[Any] = []
for tool in tools:
if not isinstance(tool, dict):
sanitized_tools.append(tool)
continue
sanitized_tool = dict(tool)
function = tool.get("function")
if isinstance(function, dict):
sanitized_function = dict(function)
parameters = function.get("parameters")
if isinstance(parameters, dict):
_, sanitized_parameters = _sanitize_nim_schema_node(parameters)
sanitized_parameters, argument_aliases = _alias_nim_tool_parameters(
sanitized_parameters
)
sanitized_function["parameters"] = sanitized_parameters
tool_name = function.get("name")
if argument_aliases and isinstance(tool_name, str) and tool_name:
tool_argument_aliases[tool_name] = argument_aliases
sanitized_tool["function"] = sanitized_function
sanitized_tools.append(sanitized_tool)
body["tools"] = sanitized_tools
if tool_argument_aliases:
body[NIM_TOOL_ARGUMENT_ALIASES_KEY] = tool_argument_aliases
else:
body.pop(NIM_TOOL_ARGUMENT_ALIASES_KEY, None)
def nim_tool_argument_aliases_from_body(
body: dict[str, Any],
strip: Callable[[dict[str, Any]], bool],
) -> dict[str, Any] | None:
"""Deep-clone ``body`` and remove fields via ``strip`` on ``extra_body`` only.
) -> dict[str, dict[str, str]]:
"""Return validated private NIM tool argument aliases from a built body."""
raw_aliases = body.get(NIM_TOOL_ARGUMENT_ALIASES_KEY)
if not isinstance(raw_aliases, dict):
return {}
Returns ``None`` when there is no ``extra_body`` dict or ``strip`` reports no change.
"""
cloned_body = deepcopy(body)
extra_body = cloned_body.get("extra_body")
if not isinstance(extra_body, dict):
return None
if not strip(extra_body):
return None
if not extra_body:
cloned_body.pop("extra_body", None)
return cloned_body
aliases: dict[str, dict[str, str]] = {}
for tool_name, tool_aliases in raw_aliases.items():
if not isinstance(tool_name, str) or not isinstance(tool_aliases, dict):
continue
sanitized_aliases = {
alias: original
for alias, original in tool_aliases.items()
if isinstance(alias, str) and isinstance(original, str)
}
if sanitized_aliases:
aliases[tool_name] = sanitized_aliases
return aliases
def _strip_reasoning_budget_fields(extra_body: dict[str, Any]) -> bool:
removed = extra_body.pop("reasoning_budget", None) is not None
chat_template_kwargs = extra_body.get("chat_template_kwargs")
if (
isinstance(chat_template_kwargs, dict)
and chat_template_kwargs.pop("reasoning_budget", None) is not None
):
removed = True
return removed
def _strip_chat_template_field(extra_body: dict[str, Any]) -> bool:
return extra_body.pop("chat_template", None) is not None
def _strip_message_reasoning_content(body: dict[str, Any]) -> bool:
removed = False
messages = body.get("messages")
if not isinstance(messages, list):
return False
for message in messages:
if (
isinstance(message, dict)
and message.pop("reasoning_content", None) is not None
):
removed = True
return removed
def body_without_nim_tool_argument_aliases(body: dict[str, Any]) -> dict[str, Any]:
"""Return a request body with private alias metadata stripped before upstream I/O."""
if NIM_TOOL_ARGUMENT_ALIASES_KEY not in body:
return body
upstream_body = dict(body)
upstream_body.pop(NIM_TOOL_ARGUMENT_ALIASES_KEY, None)
return upstream_body
def _sanitize_nim_schema_node(value: Any) -> tuple[bool, Any]:
@ -228,6 +236,7 @@ def _alias_nim_schema_property_names(
alias_to_original=alias_to_original,
original_to_alias=original_to_alias,
)
return aliased_value
@ -246,185 +255,3 @@ def _alias_nim_tool_parameters(
if not alias_to_original:
return parameters, {}
return aliased_parameters, alias_to_original
def _sanitize_nim_tool_schemas(body: dict[str, Any]) -> None:
"""Sanitize only tool parameter schemas, preserving tool calls/history."""
tools = body.get("tools")
if not isinstance(tools, list):
return
tool_argument_aliases: dict[str, dict[str, str]] = {}
sanitized_tools: list[Any] = []
for tool in tools:
if not isinstance(tool, dict):
sanitized_tools.append(tool)
continue
sanitized_tool = dict(tool)
function = tool.get("function")
if isinstance(function, dict):
sanitized_function = dict(function)
parameters = function.get("parameters")
if isinstance(parameters, dict):
_, sanitized_parameters = _sanitize_nim_schema_node(parameters)
sanitized_parameters, argument_aliases = _alias_nim_tool_parameters(
sanitized_parameters
)
sanitized_function["parameters"] = sanitized_parameters
tool_name = function.get("name")
if argument_aliases and isinstance(tool_name, str) and tool_name:
tool_argument_aliases[tool_name] = argument_aliases
sanitized_tool["function"] = sanitized_function
sanitized_tools.append(sanitized_tool)
body["tools"] = sanitized_tools
if tool_argument_aliases:
body[NIM_TOOL_ARGUMENT_ALIASES_KEY] = tool_argument_aliases
else:
body.pop(NIM_TOOL_ARGUMENT_ALIASES_KEY, None)
def nim_tool_argument_aliases_from_body(
body: dict[str, Any],
) -> dict[str, dict[str, str]]:
"""Return validated private NIM tool argument aliases from a built body."""
raw_aliases = body.get(NIM_TOOL_ARGUMENT_ALIASES_KEY)
if not isinstance(raw_aliases, dict):
return {}
aliases: dict[str, dict[str, str]] = {}
for tool_name, tool_aliases in raw_aliases.items():
if not isinstance(tool_name, str) or not isinstance(tool_aliases, dict):
continue
sanitized_aliases = {
alias: original
for alias, original in tool_aliases.items()
if isinstance(alias, str) and isinstance(original, str)
}
if sanitized_aliases:
aliases[tool_name] = sanitized_aliases
return aliases
def body_without_nim_tool_argument_aliases(body: dict[str, Any]) -> dict[str, Any]:
"""Return a request body with private alias metadata stripped before upstream I/O."""
if NIM_TOOL_ARGUMENT_ALIASES_KEY not in body:
return body
upstream_body = dict(body)
upstream_body.pop(NIM_TOOL_ARGUMENT_ALIASES_KEY, None)
return upstream_body
def _set_extra(
extra_body: dict[str, Any], key: str, value: Any, ignore_value: Any = None
) -> None:
if key in extra_body:
return
if value is None:
return
if ignore_value is not None and value == ignore_value:
return
extra_body[key] = value
def clone_body_without_reasoning_budget(body: dict[str, Any]) -> dict[str, Any] | None:
"""Clone a request body and strip only reasoning_budget fields."""
return _clone_strip_extra_body(body, _strip_reasoning_budget_fields)
def clone_body_without_chat_template(body: dict[str, Any]) -> dict[str, Any] | None:
"""Clone a request body and strip only chat_template."""
return _clone_strip_extra_body(body, _strip_chat_template_field)
def clone_body_without_reasoning_content(body: dict[str, Any]) -> dict[str, Any] | None:
"""Clone a request body and strip assistant message ``reasoning_content`` fields."""
cloned_body = deepcopy(body)
if not _strip_message_reasoning_content(cloned_body):
return None
return cloned_body
def build_request_body(
request_data: Any, nim: NimSettings, *, thinking_enabled: bool
) -> dict:
"""Build OpenAI-format request body from Anthropic request."""
logger.debug(
"NIM_REQUEST: conversion start model={} msgs={}",
getattr(request_data, "model", "?"),
len(getattr(request_data, "messages", [])),
)
try:
body = build_base_request_body(
request_data,
reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
if thinking_enabled
else ReasoningReplayMode.DISABLED,
)
except OpenAIConversionError as exc:
raise InvalidRequestError(str(exc)) from exc
_sanitize_nim_tool_schemas(body)
# NIM-specific max_tokens: cap against nim.max_tokens
max_tokens = body.get("max_tokens") or getattr(request_data, "max_tokens", None)
if max_tokens is None:
max_tokens = nim.max_tokens
elif nim.max_tokens:
max_tokens = min(max_tokens, nim.max_tokens)
set_if_not_none(body, "max_tokens", max_tokens)
# NIM-specific temperature/top_p: fall back to NIM defaults if request didn't set
if body.get("temperature") is None and nim.temperature is not None:
body["temperature"] = nim.temperature
if body.get("top_p") is None and nim.top_p is not None:
body["top_p"] = nim.top_p
# NIM-specific stop sequences fallback
if "stop" not in body and nim.stop:
body["stop"] = nim.stop
if nim.presence_penalty != 0.0:
body["presence_penalty"] = nim.presence_penalty
if nim.frequency_penalty != 0.0:
body["frequency_penalty"] = nim.frequency_penalty
if nim.seed is not None:
body["seed"] = nim.seed
body["parallel_tool_calls"] = nim.parallel_tool_calls
# Handle non-standard parameters via extra_body
extra_body: dict[str, Any] = {}
request_extra = getattr(request_data, "extra_body", None)
if request_extra:
extra_body.update(request_extra)
if thinking_enabled:
chat_template_kwargs = extra_body.setdefault(
"chat_template_kwargs", {"thinking": True, "enable_thinking": True}
)
if isinstance(chat_template_kwargs, dict):
chat_template_kwargs.setdefault("reasoning_budget", max_tokens)
req_top_k = getattr(request_data, "top_k", None)
top_k = req_top_k if req_top_k is not None else nim.top_k
_set_extra(extra_body, "top_k", top_k, ignore_value=-1)
_set_extra(extra_body, "min_p", nim.min_p, ignore_value=0.0)
_set_extra(
extra_body, "repetition_penalty", nim.repetition_penalty, ignore_value=1.0
)
_set_extra(extra_body, "min_tokens", nim.min_tokens, ignore_value=0)
_set_extra(extra_body, "chat_template", nim.chat_template)
_set_extra(extra_body, "request_id", nim.request_id)
_set_extra(extra_body, "ignore_eos", nim.ignore_eos)
if extra_body:
body["extra_body"] = extra_body
logger.debug(
"NIM_REQUEST: conversion done model={} msgs={} tools={}",
body.get("model"),
len(body.get("messages", [])),
len(body.get("tools", [])),
)
return body

View file

@ -21,12 +21,16 @@ from providers.model_listing import (
)
from providers.transports.anthropic_messages import (
AnthropicMessagesTransport,
NativeMessagesRequestPolicy,
StreamChunkMode,
build_native_messages_request_body,
)
from .request import build_request_body
_ANTHROPIC_VERSION = "2023-06-01"
_REQUEST_POLICY = NativeMessagesRequestPolicy(
provider_name="OPENROUTER",
extra_body="openrouter",
)
class OpenRouterProvider(AnthropicMessagesTransport):
@ -45,9 +49,10 @@ class OpenRouterProvider(AnthropicMessagesTransport):
self, request: Any, thinking_enabled: bool | None = None
) -> dict:
"""Internal helper for tests and direct request dispatch."""
return build_request_body(
return build_native_messages_request_body(
request,
thinking_enabled=self._is_thinking_enabled(request, thinking_enabled),
policy=_REQUEST_POLICY,
)
def _request_headers(self) -> dict[str, str]:

View file

@ -1,42 +0,0 @@
"""Native Anthropic Messages request builder for OpenRouter."""
from __future__ import annotations
from typing import Any
from loguru import logger
from config.constants import (
ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS as OPENROUTER_DEFAULT_MAX_TOKENS,
)
from core.anthropic.native_messages_request import (
OpenRouterExtraBodyError,
build_openrouter_native_request_body,
)
from providers.exceptions import InvalidRequestError
def build_request_body(request_data: Any, *, thinking_enabled: bool) -> dict:
"""Build an Anthropic-format request body for OpenRouter's messages API."""
logger.debug(
"OPENROUTER_REQUEST: conversion start model={} msgs={}",
getattr(request_data, "model", "?"),
len(getattr(request_data, "messages", [])),
)
try:
body = build_openrouter_native_request_body(
request_data,
thinking_enabled=thinking_enabled,
default_max_tokens=OPENROUTER_DEFAULT_MAX_TOKENS,
)
except OpenRouterExtraBodyError as exc:
raise InvalidRequestError(str(exc)) from exc
logger.debug(
"OPENROUTER_REQUEST: conversion done model={} msgs={} tools={}",
body.get("model"),
len(body.get("messages", [])),
len(body.get("tools", [])),
)
return body

View file

@ -6,9 +6,11 @@ from typing import Any
from providers.base import ProviderConfig
from providers.defaults import OPENCODE_DEFAULT_BASE
from providers.transports.openai_chat import OpenAIChatTransport
from .request import build_request_body
from providers.transports.openai_chat import (
OpenAIChatRequestPolicy,
OpenAIChatTransport,
build_openai_chat_request_body,
)
class OpenCodeProvider(OpenAIChatTransport):
@ -21,11 +23,13 @@ class OpenCodeProvider(OpenAIChatTransport):
base_url=config.base_url or OPENCODE_DEFAULT_BASE,
api_key=config.api_key,
)
self._request_policy = OpenAIChatRequestPolicy(provider_name=provider_name)
def _build_request_body(
self, request: Any, thinking_enabled: bool | None = None
) -> dict:
return build_request_body(
return build_openai_chat_request_body(
request,
thinking_enabled=self._is_thinking_enabled(request, thinking_enabled),
policy=self._request_policy,
)

View file

@ -1,35 +0,0 @@
"""Request builder for OpenCode Zen provider."""
from typing import Any
from loguru import logger
from core.anthropic import ReasoningReplayMode, build_base_request_body
from core.anthropic.conversion import OpenAIConversionError
from providers.exceptions import InvalidRequestError
def build_request_body(request_data: Any, *, thinking_enabled: bool) -> dict:
"""Build OpenAI-format request body from Anthropic request for OpenCode Zen."""
logger.debug(
"OPENCODE_REQUEST: conversion start model={} msgs={}",
getattr(request_data, "model", "?"),
len(getattr(request_data, "messages", [])),
)
try:
body = build_base_request_body(
request_data,
reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
if thinking_enabled
else ReasoningReplayMode.DISABLED,
)
except OpenAIConversionError as exc:
raise InvalidRequestError(str(exc)) from exc
logger.debug(
"OPENCODE_REQUEST: conversion done model={} msgs={} tools={}",
body.get("model"),
len(body.get("messages", [])),
len(body.get("tools", [])),
)
return body

View file

@ -1,5 +1,14 @@
"""Native Anthropic Messages transport family."""
from .request_policy import (
NativeMessagesRequestPolicy,
build_native_messages_request_body,
)
from .transport import AnthropicMessagesTransport, StreamChunkMode
__all__ = ["AnthropicMessagesTransport", "StreamChunkMode"]
__all__ = [
"AnthropicMessagesTransport",
"NativeMessagesRequestPolicy",
"StreamChunkMode",
"build_native_messages_request_body",
]

View file

@ -0,0 +1,121 @@
"""Request-body policy for native Anthropic-compatible providers."""
from __future__ import annotations
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from typing import Any, Literal
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"]
NativeMessagesPostprocessor = Callable[[dict[str, Any], Any, bool], None]
@dataclass(frozen=True, slots=True)
class NativeMessagesRequestPolicy:
"""Provider policy for native Anthropic Messages request construction."""
provider_name: str
extra_body: NativeExtraBodyPolicy = "drop"
default_max_tokens: int = ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
force_stream: bool = True
reject_extra_body_message: str | None = None
def build_native_messages_request_body(
request_data: Any,
*,
thinking_enabled: bool,
policy: NativeMessagesRequestPolicy,
postprocessors: Iterable[NativeMessagesPostprocessor] = (),
) -> dict[str, Any]:
"""Build a native Anthropic-compatible Messages request body."""
logger.debug(
"{}_REQUEST: native build model={} msgs={}",
policy.provider_name,
getattr(request_data, "model", "?"),
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
for postprocess in postprocessors:
postprocess(body, request_data, thinking_enabled)
logger.debug(
"{}_REQUEST: build done model={} msgs={} tools={}",
policy.provider_name,
body.get("model"),
len(body.get("messages", [])),
len(body.get("tools", [])),
)
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,
policy: NativeMessagesRequestPolicy,
) -> None:
extra = getattr(request_data, "extra_body", None)
if not extra:
return
if policy.extra_body == "drop":
return
if policy.extra_body == "reject":
message = (
policy.reject_extra_body_message
or f"{policy.provider_name} native Messages API does not support extra_body on requests."
)
raise InvalidRequestError(message)
if policy.extra_body == "merge_validated":
if isinstance(extra, dict):
try:
validate_openrouter_extra_body(extra)
except OpenRouterExtraBodyError as exc:
raise InvalidRequestError(str(exc)) from exc
body.update(extra)
return
raise AssertionError(f"Unhandled native extra_body policy: {policy.extra_body}")

View file

@ -7,11 +7,7 @@ from typing import Any, Literal
import httpx
from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
from core.anthropic import iter_provider_stream_error_sse_events
from core.anthropic.native_messages_request import (
build_base_native_anthropic_request_body,
)
from core.anthropic.native_sse_block_policy import (
NativeSseBlockPolicyState,
transform_native_sse_block_event,
@ -31,6 +27,10 @@ from providers.rate_limit import GlobalRateLimiter
from providers.transports.http import maybe_await_aclose
from .http import model_list_json, raise_for_status_with_body
from .request_policy import (
NativeMessagesRequestPolicy,
build_native_messages_request_body,
)
from .stream import AnthropicMessagesStreamAdapter
StreamChunkMode = Literal["line", "event"]
@ -52,6 +52,7 @@ class AnthropicMessagesTransport(BaseProvider):
self._provider_name = provider_name
self._api_key = config.api_key
self._base_url = (config.base_url or default_base_url).rstrip("/")
self._request_policy = NativeMessagesRequestPolicy(provider_name=provider_name)
self._global_rate_limiter = GlobalRateLimiter.get_scoped_instance(
provider_name.lower(),
rate_limit=config.rate_limit,
@ -129,10 +130,10 @@ class AnthropicMessagesTransport(BaseProvider):
self, request: Any, *, thinking_enabled: bool
) -> dict:
"""Build a native Anthropic request body after thinking is resolved."""
return build_base_native_anthropic_request_body(
return build_native_messages_request_body(
request,
default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
thinking_enabled=thinking_enabled,
policy=self._request_policy,
)
async def _send_stream_request(self, body: dict) -> httpx.Response:

View file

@ -1,5 +1,10 @@
"""OpenAI-compatible chat transport family."""
from .request_policy import OpenAIChatRequestPolicy, build_openai_chat_request_body
from .transport import OpenAIChatTransport
__all__ = ["OpenAIChatTransport"]
__all__ = [
"OpenAIChatRequestPolicy",
"OpenAIChatTransport",
"build_openai_chat_request_body",
]

View file

@ -0,0 +1,105 @@
"""Request-body policy for OpenAI-compatible chat providers."""
from __future__ import annotations
from collections.abc import Callable, Iterable
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any, Literal
from loguru import logger
from core.anthropic import ReasoningReplayMode, build_base_request_body
from core.anthropic.conversion import OpenAIConversionError
from providers.exceptions import InvalidRequestError
MaxTokensField = Literal["max_tokens", "max_completion_tokens"]
OpenAIChatPostprocessor = Callable[[dict[str, Any], Any, bool], None]
@dataclass(frozen=True, slots=True)
class OpenAIChatRequestPolicy:
"""Provider policy for Anthropic-to-OpenAI chat request conversion."""
provider_name: str
include_extra_body: bool = False
max_tokens_field: MaxTokensField = "max_tokens"
strip_message_names: bool = False
unsupported_body_keys: frozenset[str] = field(default_factory=frozenset)
normalize_n_to_one: bool = False
def build_openai_chat_request_body(
request_data: Any,
*,
thinking_enabled: bool,
policy: OpenAIChatRequestPolicy,
postprocessors: Iterable[OpenAIChatPostprocessor] = (),
) -> dict[str, Any]:
"""Build an OpenAI-compatible chat request body from an Anthropic request."""
logger.debug(
"{}_REQUEST: conversion start model={} msgs={}",
policy.provider_name,
getattr(request_data, "model", "?"),
len(getattr(request_data, "messages", [])),
)
try:
body = build_base_request_body(
request_data,
reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
if thinking_enabled
else ReasoningReplayMode.DISABLED,
)
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)
_apply_common_openai_chat_policy(body, policy)
for postprocess in postprocessors:
postprocess(body, request_data, thinking_enabled)
logger.debug(
"{}_REQUEST: conversion done model={} msgs={} tools={}",
policy.provider_name,
body.get("model"),
len(body.get("messages", [])),
len(body.get("tools", [])),
)
return body
def _apply_common_openai_chat_policy(
body: dict[str, Any], policy: OpenAIChatRequestPolicy
) -> None:
if policy.strip_message_names:
_strip_message_names(body.get("messages"))
for key in policy.unsupported_body_keys:
body.pop(key, None)
if policy.max_tokens_field == "max_completion_tokens":
_normalize_max_completion_tokens(body)
if policy.normalize_n_to_one and body.get("n") is not None:
body["n"] = 1
def _strip_message_names(messages: Any) -> None:
if not isinstance(messages, list):
return
for message in messages:
if isinstance(message, dict):
message.pop("name", None)
def _normalize_max_completion_tokens(body: dict[str, Any]) -> None:
if "max_completion_tokens" in body:
body.pop("max_tokens", None)
return
if "max_tokens" in body and body["max_tokens"] is not None:
body["max_completion_tokens"] = body.pop("max_tokens")

View file

@ -6,11 +6,20 @@ from typing import Any
from providers.base import ProviderConfig
from providers.defaults import ZAI_DEFAULT_BASE
from providers.transports.anthropic_messages import AnthropicMessagesTransport
from .request import build_request_body
from providers.transports.anthropic_messages import (
AnthropicMessagesTransport,
NativeMessagesRequestPolicy,
build_native_messages_request_body,
)
_ANTHROPIC_VERSION = "2023-06-01"
_REQUEST_POLICY = NativeMessagesRequestPolicy(
provider_name="ZAI",
extra_body="reject",
reject_extra_body_message=(
"Z.ai native Messages API does not support extra_body on requests."
),
)
class ZaiProvider(AnthropicMessagesTransport):
@ -26,9 +35,10 @@ class ZaiProvider(AnthropicMessagesTransport):
def _build_request_body(
self, request: Any, thinking_enabled: bool | None = None
) -> dict:
return build_request_body(
return build_native_messages_request_body(
request,
thinking_enabled=self._is_thinking_enabled(request, thinking_enabled),
policy=_REQUEST_POLICY,
)
def _request_headers(self) -> dict[str, str]:

View file

@ -1,42 +0,0 @@
"""Native Anthropic Messages request builder for Z.ai."""
from __future__ import annotations
from typing import Any
from loguru import logger
from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
from core.anthropic.native_messages_request import (
build_base_native_anthropic_request_body,
)
from providers.exceptions import InvalidRequestError
def build_request_body(request_data: Any, *, thinking_enabled: bool) -> dict:
"""Build JSON for Z.ai Anthropic-compat ``POST …/messages``."""
logger.debug(
"ZAI_REQUEST: native build model={} msgs={}",
getattr(request_data, "model", "?"),
len(getattr(request_data, "messages", [])),
)
body = build_base_native_anthropic_request_body(
request_data,
default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
thinking_enabled=thinking_enabled,
)
extra = getattr(request_data, "extra_body", None)
if extra:
raise InvalidRequestError(
"Z.ai native Messages API does not support extra_body on requests."
)
body["stream"] = True
logger.debug(
"ZAI_REQUEST: build done model={} msgs={} tools={}",
body.get("model"),
len(body.get("messages", [])),
len(body.get("tools", [])),
)
return body

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "free-claude-code"
version = "2.3.19"
version = "2.3.20"
description = "Middleware between Claude Code CLI (Anthropic API) and NVIDIA NIM"
readme = "README.md"
requires-python = ">=3.14.0"

View file

@ -194,6 +194,42 @@ def test_provider_transports_live_under_transport_family_packages() -> None:
assert offenders == []
def test_provider_request_policy_lives_with_transport_families() -> None:
repo_root = Path(__file__).resolve().parents[2]
providers_root = repo_root / "providers"
deleted_request_modules = (
"providers.cerebras.request",
"providers.deepseek.request",
"providers.fireworks.request",
"providers.gemini.request",
"providers.groq.request",
"providers.kimi.request",
"providers.mistral.request",
"providers.nvidia_nim.request",
"providers.opencode.request",
"providers.open_router.request",
"providers.zai.request",
)
assert (
providers_root / "transports" / "openai_chat" / "request_policy.py"
).exists()
assert (
providers_root / "transports" / "anthropic_messages" / "request_policy.py"
).exists()
assert not sorted(
path.relative_to(repo_root).as_posix()
for path in providers_root.glob("*/request.py")
)
offenders = _imports_matching(
[providers_root, repo_root / "tests"],
forbidden_prefixes=deleted_request_modules,
)
assert offenders == []
def test_anthropic_stream_engine_owns_provider_stream_state() -> None:
repo_root = Path(__file__).resolve().parents[2]
anthropic_root = repo_root / "core" / "anthropic"

View file

@ -108,7 +108,9 @@ def test_build_request_body_global_disable_blocks_reasoning_mapping():
def test_build_request_body_remaps_max_tokens_preserves_message_name(cerebras_provider):
"""Cerebras does not strip message ``name``; ``max_tokens`` maps to completion field."""
with patch("providers.cerebras.request.build_base_request_body") as mock_convert:
with patch(
"providers.transports.openai_chat.request_policy.build_base_request_body"
) as mock_convert:
mock_convert.return_value = {
"model": "llama3.1-8b",
"messages": [{"role": "user", "name": "alice", "content": "hi"}],
@ -123,7 +125,9 @@ def test_build_request_body_remaps_max_tokens_preserves_message_name(cerebras_pr
def test_build_request_body_prefers_existing_max_completion_tokens(cerebras_provider):
with patch("providers.cerebras.request.build_base_request_body") as mock_convert:
with patch(
"providers.transports.openai_chat.request_policy.build_base_request_body"
) as mock_convert:
mock_convert.return_value = {
"model": "llama3.1-8b",
"messages": [{"role": "user", "content": "x"}],

View file

@ -7,7 +7,7 @@ import pytest
from providers.base import ProviderConfig
from providers.gemini import GEMINI_DEFAULT_BASE, GeminiProvider
from providers.gemini.request import GEMINI_SKIP_THOUGHT_SIGNATURE_VALIDATOR
from providers.gemini.quirks import GEMINI_SKIP_THOUGHT_SIGNATURE_VALIDATOR
class MockMessage:

View file

@ -107,7 +107,9 @@ def test_build_request_body_global_disable_blocks_reasoning_mapping():
def test_build_request_body_sanitizes_and_remaps_via_mock_converter(groq_provider):
with patch("providers.groq.request.build_base_request_body") as mock_convert:
with patch(
"providers.transports.openai_chat.request_policy.build_base_request_body"
) as mock_convert:
mock_convert.return_value = {
"model": "llama-3.3-70b-versatile",
"messages": [
@ -138,7 +140,9 @@ def test_build_request_body_sanitizes_and_remaps_via_mock_converter(groq_provide
def test_build_request_body_prefers_existing_max_completion_tokens(groq_provider):
with patch("providers.groq.request.build_base_request_body") as mock_convert:
with patch(
"providers.transports.openai_chat.request_policy.build_base_request_body"
) as mock_convert:
mock_convert.return_value = {
"model": "llama-3.3-70b-versatile",
"messages": [{"role": "user", "content": "x"}],

View file

@ -2,7 +2,7 @@
from copy import deepcopy
from providers.nvidia_nim.request import clone_body_without_reasoning_budget
from providers.nvidia_nim.retry import clone_body_without_reasoning_budget
def test_clone_body_without_reasoning_budget_strips_top_level_and_nested():

View file

@ -8,7 +8,7 @@ from httpx import Request, Response
from config.nim import NimSettings
from providers.defaults import NVIDIA_NIM_DEFAULT_BASE
from providers.nvidia_nim import NvidiaNimProvider
from providers.nvidia_nim.request import NIM_TOOL_ARGUMENT_ALIASES_KEY
from providers.nvidia_nim.tool_schema import NIM_TOOL_ARGUMENT_ALIASES_KEY
# Mock data classes

View file

@ -1,4 +1,4 @@
"""Tests for providers/nvidia_nim/request.py."""
"""Tests for NVIDIA NIM request policy helpers."""
from copy import deepcopy
from types import SimpleNamespace
@ -9,13 +9,19 @@ import pytest
from config.nim import NimSettings
from core.anthropic import set_if_not_none
from providers.nvidia_nim.request import (
NIM_TOOL_ARGUMENT_ALIASES_KEY,
from providers.nvidia_nim.request_options import (
_set_extra,
body_without_nim_tool_argument_aliases,
build_request_body,
)
from providers.nvidia_nim.request_options import (
build_nim_request_body as build_request_body,
)
from providers.nvidia_nim.retry import (
clone_body_without_chat_template,
clone_body_without_reasoning_content,
)
from providers.nvidia_nim.tool_schema import (
NIM_TOOL_ARGUMENT_ALIASES_KEY,
body_without_nim_tool_argument_aliases,
nim_tool_argument_aliases_from_body,
)

View file

@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
from core.anthropic.stream_contracts import (
assert_anthropic_stream_contract,
parse_sse_text,
@ -13,8 +14,8 @@ from core.anthropic.stream_contracts import (
thinking_content,
)
from providers.base import ProviderConfig
from providers.exceptions import InvalidRequestError
from providers.open_router import OpenRouterProvider
from providers.open_router.request import OPENROUTER_DEFAULT_MAX_TOKENS
class MockMessage:
@ -144,21 +145,18 @@ def test_build_request_body_is_native_anthropic(open_router_provider):
def test_openrouter_extra_body_rejects_overriding_reserved_fields() -> None:
from providers.exceptions import InvalidRequestError
from providers.open_router.request import build_request_body
r = MockRequest()
r.extra_body = {"model": "hijack"}
provider = OpenRouterProvider(ProviderConfig(api_key="test_openrouter_key"))
with pytest.raises(InvalidRequestError, match="model"):
build_request_body(r, thinking_enabled=True)
provider._build_request_body(r, thinking_enabled=True)
def test_openrouter_extra_body_allows_openrouter_only_keys() -> None:
from providers.open_router.request import build_request_body
r = MockRequest()
r.extra_body = {"transforms": ["no-web"], "plugins": []}
body = build_request_body(r, thinking_enabled=False)
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"] == []
@ -211,7 +209,7 @@ def test_build_request_body_default_max_tokens(open_router_provider):
body = open_router_provider._build_request_body(req)
assert body["max_tokens"] == OPENROUTER_DEFAULT_MAX_TOKENS
assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
def test_build_request_body_strips_unsigned_thinking_history(open_router_provider):

2
uv.lock generated
View file

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