mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-07-09 16:00:45 +00:00
Refactor provider request policy ownership (#929)
This commit is contained in:
parent
cdeb1aa9e2
commit
43b3e5e330
44 changed files with 1080 additions and 1140 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
27
core/anthropic/request_serialization.py
Normal file
27
core/anthropic/request_serialization.py
Normal 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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
109
providers/nvidia_nim/request_options.py
Normal file
109
providers/nvidia_nim/request_options.py
Normal 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
|
||||
71
providers/nvidia_nim/retry.py
Normal file
71
providers/nvidia_nim/retry.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
121
providers/transports/anthropic_messages/request_policy.py
Normal file
121
providers/transports/anthropic_messages/request_policy.py
Normal 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}")
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
105
providers/transports/openai_chat/request_policy.py
Normal file
105
providers/transports/openai_chat/request_policy.py
Normal 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")
|
||||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"}],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"}],
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
2
uv.lock
generated
|
|
@ -561,7 +561,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "free-claude-code"
|
||||
version = "2.3.19"
|
||||
version = "2.3.20"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue