Fix Anthropic Messages API compatibility (#25876)
Co-authored-by: Jairo David Campaña Rosero <jairocampana10001@gmail.com> Co-authored-by: Karan Bansal <3264937+karanb192@users.noreply.github.com> Co-authored-by: eason <85663565+mango766@users.noreply.github.com> Co-authored-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Co-authored-by: qingchanghan <17794466+qingchanghan@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Ajay Anubolu <124525760+AjAnubolu@users.noreply.github.com> Co-authored-by: Ravitez Dondeti <13931987+dondetir@users.noreply.github.com> Co-authored-by: Ratish P <114130421+Ratish1@users.noreply.github.com> Co-authored-by: Xiaoshuai Zhang <15795935+jetd1@users.noreply.github.com> Co-authored-by: Ricardo-M-L <69202550+Ricardo-M-L@users.noreply.github.com> Co-authored-by: Xinyuan Tong <xinyuan.tong@radixark.ai>
This commit is contained in:
co-authored by
Jairo David Campaña Rosero
Karan Bansal
eason
Yufeng He
qingchanghan
Claude Opus 4.7
Ajay Anubolu
Ravitez Dondeti
Ratish P
Xiaoshuai Zhang
Ricardo-M-L
Xinyuan Tong
parent
caf59759ea
commit
b3270264e4
@@ -1,71 +1,140 @@
|
|||||||
"""Pydantic models for Anthropic Messages API protocol"""
|
"""Pydantic models for Anthropic Messages API protocol.
|
||||||
|
|
||||||
|
Mirrors the shape of the official Anthropic Python SDK
|
||||||
|
(``anthropic-sdk-python``): ``ContentBlock``, ``Tool``, ``MessageStreamEvent``
|
||||||
|
and ``ContentBlockDelta`` are discriminated unions over the ``type`` field,
|
||||||
|
so each variant carries only the fields it actually uses.
|
||||||
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Literal, Optional
|
from typing import Annotated, Any, Literal, Optional, Union
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
Discriminator,
|
||||||
|
Field,
|
||||||
|
NonNegativeInt,
|
||||||
|
Tag,
|
||||||
|
field_validator,
|
||||||
|
model_validator,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AnthropicError(BaseModel):
|
class AnthropicError(BaseModel):
|
||||||
"""Error structure for Anthropic API"""
|
"""Error structure for Anthropic API."""
|
||||||
|
|
||||||
type: str
|
type: str
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
class AnthropicErrorResponse(BaseModel):
|
class AnthropicErrorResponse(BaseModel):
|
||||||
"""Error response structure for Anthropic API"""
|
"""Error response structure for Anthropic API."""
|
||||||
|
|
||||||
type: Literal["error"] = "error"
|
type: Literal["error"] = "error"
|
||||||
error: AnthropicError
|
error: AnthropicError
|
||||||
|
|
||||||
|
|
||||||
class AnthropicUsage(BaseModel):
|
class AnthropicUsage(BaseModel):
|
||||||
"""Token usage information"""
|
"""Token usage information.
|
||||||
|
|
||||||
input_tokens: int
|
``input_tokens``/``output_tokens`` are ``Optional`` because Anthropic's
|
||||||
output_tokens: int
|
streaming ``message_delta`` event omits ``input_tokens`` (the spec
|
||||||
cache_creation_input_tokens: Optional[int] = None
|
requires it only on ``message_start``). Non-streaming responses set both.
|
||||||
cache_read_input_tokens: Optional[int] = None
|
"""
|
||||||
|
|
||||||
|
input_tokens: Optional[NonNegativeInt] = None
|
||||||
|
output_tokens: Optional[NonNegativeInt] = None
|
||||||
|
cache_creation_input_tokens: Optional[NonNegativeInt] = None
|
||||||
|
cache_read_input_tokens: Optional[NonNegativeInt] = None
|
||||||
|
|
||||||
|
|
||||||
class AnthropicContentBlock(BaseModel):
|
# ---------- Content blocks (discriminated by ``type``) ----------
|
||||||
"""Content block in message"""
|
|
||||||
|
|
||||||
type: Literal[
|
|
||||||
"text",
|
class TextBlock(BaseModel):
|
||||||
"image",
|
type: Literal["text"] = "text"
|
||||||
"tool_use",
|
text: str
|
||||||
"tool_result",
|
|
||||||
"tool_reference",
|
|
||||||
"thinking",
|
class ImageBlock(BaseModel):
|
||||||
"redacted_thinking",
|
type: Literal["image"] = "image"
|
||||||
]
|
# Kept loosely typed for compat with both base64 and URL sources; the
|
||||||
text: Optional[str] = None
|
# serving layer normalises to OpenAI ``image_url`` parts.
|
||||||
# For image content
|
source: Optional[Union[dict[str, Any], str]] = None
|
||||||
source: Optional[dict[str, Any]] = None
|
|
||||||
# For tool use/result
|
|
||||||
id: Optional[str] = None
|
class ToolUseBlock(BaseModel):
|
||||||
|
type: Literal["tool_use"] = "tool_use"
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
input: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ToolResultBlock(BaseModel):
|
||||||
|
type: Literal["tool_result"] = "tool_result"
|
||||||
tool_use_id: Optional[str] = None
|
tool_use_id: Optional[str] = None
|
||||||
name: Optional[str] = None
|
# Some legacy payloads use ``id`` instead of ``tool_use_id``.
|
||||||
input: Optional[dict[str, Any]] = None
|
id: Optional[str] = None
|
||||||
content: Optional[str | list[dict[str, Any]]] = None
|
content: Optional[Union[str, list["AnthropicContentBlock"]]] = None
|
||||||
is_error: Optional[bool] = None
|
is_error: Optional[bool] = None
|
||||||
# For thinking content
|
|
||||||
thinking: Optional[str] = None
|
|
||||||
|
class ToolReferenceBlock(BaseModel):
|
||||||
|
"""sglang extension: references a deferred-loaded tool by name."""
|
||||||
|
|
||||||
|
type: Literal["tool_reference"] = "tool_reference"
|
||||||
|
name: Optional[str] = None
|
||||||
|
# Anthropic-style payloads sometimes use ``tool_name``; accept both.
|
||||||
|
tool_name: Optional[str] = None
|
||||||
|
id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResultBlock(BaseModel):
|
||||||
|
type: Literal["search_result"] = "search_result"
|
||||||
|
# ``source`` here is a URL/identifier string (unlike ImageBlock.source).
|
||||||
|
source: Optional[Union[str, dict[str, Any]]] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
content: Optional[list[dict[str, Any]]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ThinkingBlock(BaseModel):
|
||||||
|
type: Literal["thinking"] = "thinking"
|
||||||
|
thinking: str
|
||||||
signature: Optional[str] = None
|
signature: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RedactedThinkingBlock(BaseModel):
|
||||||
|
type: Literal["redacted_thinking"] = "redacted_thinking"
|
||||||
|
data: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
AnthropicContentBlock = Annotated[
|
||||||
|
Union[
|
||||||
|
TextBlock,
|
||||||
|
ImageBlock,
|
||||||
|
ToolUseBlock,
|
||||||
|
ToolResultBlock,
|
||||||
|
ToolReferenceBlock,
|
||||||
|
SearchResultBlock,
|
||||||
|
ThinkingBlock,
|
||||||
|
RedactedThinkingBlock,
|
||||||
|
],
|
||||||
|
Field(discriminator="type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class AnthropicMessage(BaseModel):
|
class AnthropicMessage(BaseModel):
|
||||||
"""Message structure"""
|
|
||||||
|
|
||||||
role: Literal["user", "assistant"]
|
role: Literal["user", "assistant"]
|
||||||
content: str | list[AnthropicContentBlock]
|
content: Union[str, list[AnthropicContentBlock]]
|
||||||
|
|
||||||
|
|
||||||
class AnthropicTool(BaseModel):
|
# ---------- Tools (discriminated by ``type`` family) ----------
|
||||||
"""Tool definition"""
|
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicCustomTool(BaseModel):
|
||||||
|
"""Custom tool defined by the API user — requires ``input_schema``."""
|
||||||
|
|
||||||
|
type: Optional[Literal["custom"]] = None # absent or explicit "custom"
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
input_schema: dict[str, Any]
|
input_schema: dict[str, Any]
|
||||||
@@ -73,7 +142,7 @@ class AnthropicTool(BaseModel):
|
|||||||
|
|
||||||
@field_validator("input_schema")
|
@field_validator("input_schema")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_input_schema(cls, v):
|
def _ensure_object_schema(cls, v):
|
||||||
if not isinstance(v, dict):
|
if not isinstance(v, dict):
|
||||||
raise ValueError("input_schema must be a dictionary")
|
raise ValueError("input_schema must be a dictionary")
|
||||||
if "type" not in v:
|
if "type" not in v:
|
||||||
@@ -81,31 +150,216 @@ class AnthropicTool(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicWebSearchTool(BaseModel):
|
||||||
|
"""Anthropic ``web_search_*`` server tool family.
|
||||||
|
|
||||||
|
No client-side ``input_schema`` — Anthropic provides the backing
|
||||||
|
search implementation. Tag format is ``web_search_YYYYMMDD``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
type: str = Field(pattern=r"^web_search_\d{8}$")
|
||||||
|
name: Literal["web_search"] = "web_search"
|
||||||
|
description: Optional[str] = None
|
||||||
|
defer_loading: Optional[bool] = None
|
||||||
|
max_uses: Optional[int] = None
|
||||||
|
allowed_domains: Optional[list[str]] = None
|
||||||
|
blocked_domains: Optional[list[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicComputerTool(BaseModel):
|
||||||
|
"""Anthropic ``computer_*`` server tool family."""
|
||||||
|
|
||||||
|
type: str = Field(pattern=r"^computer_\d{8}$")
|
||||||
|
name: Literal["computer"] = "computer"
|
||||||
|
description: Optional[str] = None
|
||||||
|
defer_loading: Optional[bool] = None
|
||||||
|
display_width_px: Optional[int] = None
|
||||||
|
display_height_px: Optional[int] = None
|
||||||
|
display_number: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicBashTool(BaseModel):
|
||||||
|
"""Anthropic ``bash_*`` server tool family."""
|
||||||
|
|
||||||
|
type: str = Field(pattern=r"^bash_\d{8}$")
|
||||||
|
name: Literal["bash"] = "bash"
|
||||||
|
description: Optional[str] = None
|
||||||
|
defer_loading: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicTextEditorTool(BaseModel):
|
||||||
|
"""Anthropic ``text_editor_*`` server tool family."""
|
||||||
|
|
||||||
|
type: str = Field(pattern=r"^text_editor_\d{8}$")
|
||||||
|
name: Literal["str_replace_editor", "str_replace_based_edit_tool"]
|
||||||
|
description: Optional[str] = None
|
||||||
|
defer_loading: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_discriminator(v) -> str:
|
||||||
|
"""Pick the right tool variant from a dict or model instance.
|
||||||
|
|
||||||
|
Pydantic discriminators don't accept ``None`` as a tag, and custom
|
||||||
|
tools allow ``type`` to be absent. Map missing/``custom`` to
|
||||||
|
``"custom"`` and prefix-match server-tool families.
|
||||||
|
"""
|
||||||
|
if isinstance(v, dict):
|
||||||
|
t = v.get("type")
|
||||||
|
else:
|
||||||
|
t = getattr(v, "type", None)
|
||||||
|
if not t or t == "custom":
|
||||||
|
return "custom"
|
||||||
|
if t.startswith("web_search_"):
|
||||||
|
return "web_search"
|
||||||
|
if t.startswith("computer_"):
|
||||||
|
return "computer"
|
||||||
|
if t.startswith("bash_"):
|
||||||
|
return "bash"
|
||||||
|
if t.startswith("text_editor_"):
|
||||||
|
return "text_editor"
|
||||||
|
return "custom"
|
||||||
|
|
||||||
|
|
||||||
|
AnthropicTool = Annotated[
|
||||||
|
Union[
|
||||||
|
Annotated[AnthropicCustomTool, Tag("custom")],
|
||||||
|
Annotated[AnthropicWebSearchTool, Tag("web_search")],
|
||||||
|
Annotated[AnthropicComputerTool, Tag("computer")],
|
||||||
|
Annotated[AnthropicBashTool, Tag("bash")],
|
||||||
|
Annotated[AnthropicTextEditorTool, Tag("text_editor")],
|
||||||
|
],
|
||||||
|
Discriminator(_tool_discriminator),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def is_server_tool(tool) -> bool:
|
||||||
|
"""Return True for Anthropic built-in server-side tools."""
|
||||||
|
return isinstance(
|
||||||
|
tool,
|
||||||
|
(
|
||||||
|
AnthropicWebSearchTool,
|
||||||
|
AnthropicComputerTool,
|
||||||
|
AnthropicBashTool,
|
||||||
|
AnthropicTextEditorTool,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AnthropicToolChoice(BaseModel):
|
class AnthropicToolChoice(BaseModel):
|
||||||
"""Tool Choice definition"""
|
"""Tool choice strategy."""
|
||||||
|
|
||||||
type: Literal["auto", "any", "tool", "none"]
|
type: Literal["auto", "any", "tool", "none"]
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicThinkingParam(BaseModel):
|
||||||
|
"""Anthropic extended-thinking control on the request.
|
||||||
|
|
||||||
|
Mirrors the Anthropic SDK's ``ThinkingConfigParam`` discriminated
|
||||||
|
union of three variants — see ``anthropic-sdk-python``'s
|
||||||
|
``thinking_config_{enabled,disabled,adaptive}_param.py``:
|
||||||
|
|
||||||
|
* ``enabled`` requires ``budget_tokens`` (≥1024) and accepts
|
||||||
|
``display``.
|
||||||
|
* ``disabled`` accepts no other fields.
|
||||||
|
* ``adaptive`` (Claude 4.7) accepts ``display`` but not
|
||||||
|
``budget_tokens``.
|
||||||
|
|
||||||
|
The serving layer treats ``adaptive`` identically to ``enabled``
|
||||||
|
because the local OpenAI-compatible backend has no auto-throttle
|
||||||
|
equivalent. ``budget_tokens`` is accepted on ``enabled`` for SDK
|
||||||
|
compatibility but the backend has no hard-cap knob to honor it; the
|
||||||
|
serving layer logs a WARNING so operators see that the requested
|
||||||
|
budget is not enforced. ``display="omitted"`` is accepted but
|
||||||
|
similarly cannot suppress reasoning mid-stream and is logged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
type: Literal["enabled", "disabled", "adaptive"]
|
||||||
|
budget_tokens: Optional[int] = None
|
||||||
|
display: Optional[Literal["summarized", "omitted"]] = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_thinking_shape(self):
|
||||||
|
# Cross-field rules mirror the SDK's three discriminated variants.
|
||||||
|
if self.type == "enabled":
|
||||||
|
if self.budget_tokens is None:
|
||||||
|
raise ValueError(
|
||||||
|
"thinking.budget_tokens is required when "
|
||||||
|
"thinking.type is 'enabled'"
|
||||||
|
)
|
||||||
|
if self.budget_tokens < 1024:
|
||||||
|
raise ValueError(
|
||||||
|
"thinking.budget_tokens must be >= 1024 "
|
||||||
|
"(got {})".format(self.budget_tokens)
|
||||||
|
)
|
||||||
|
elif self.type == "disabled":
|
||||||
|
if self.budget_tokens is not None:
|
||||||
|
raise ValueError(
|
||||||
|
"thinking.budget_tokens is not allowed when "
|
||||||
|
"thinking.type is 'disabled'"
|
||||||
|
)
|
||||||
|
if self.display is not None:
|
||||||
|
raise ValueError(
|
||||||
|
"thinking.display is not allowed when "
|
||||||
|
"thinking.type is 'disabled'"
|
||||||
|
)
|
||||||
|
elif self.type == "adaptive":
|
||||||
|
if self.budget_tokens is not None:
|
||||||
|
raise ValueError(
|
||||||
|
"thinking.budget_tokens is not allowed when "
|
||||||
|
"thinking.type is 'adaptive'"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicTaskBudget(BaseModel):
|
||||||
|
"""Claude 4.7 ``output_config.task_budget`` — soft hint, not a hard cap.
|
||||||
|
|
||||||
|
Mirrors ``BetaTokenTaskBudgetParam`` in the Anthropic SDK: ``total``
|
||||||
|
and ``type`` are required; ``remaining`` is the client-tracked
|
||||||
|
countdown used for compaction. The hard cap on generation is still
|
||||||
|
``max_tokens``; we never enforce ``task_budget`` ourselves.
|
||||||
|
"""
|
||||||
|
|
||||||
|
type: Literal["tokens"]
|
||||||
|
total: int = Field(gt=0)
|
||||||
|
remaining: Optional[int] = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicOutputConfig(BaseModel):
|
||||||
|
"""Claude 4.7 ``output_config`` block.
|
||||||
|
|
||||||
|
``effort`` maps to the OpenAI ``reasoning_effort`` knob (``xhigh`` →
|
||||||
|
``max`` because the OpenAI Literal does not include ``xhigh``).
|
||||||
|
``task_budget`` is propagated as a custom-param hint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
effort: Optional[Literal["low", "medium", "high", "xhigh", "max"]] = None
|
||||||
|
task_budget: Optional[AnthropicTaskBudget] = None
|
||||||
|
|
||||||
|
|
||||||
class AnthropicCountTokensRequest(BaseModel):
|
class AnthropicCountTokensRequest(BaseModel):
|
||||||
"""Anthropic Count Tokens API request"""
|
"""Anthropic count_tokens API request."""
|
||||||
|
|
||||||
model: str
|
model: str
|
||||||
messages: list[AnthropicMessage]
|
messages: list[AnthropicMessage]
|
||||||
system: Optional[str | list[AnthropicContentBlock]] = None
|
system: Optional[Union[str, list[AnthropicContentBlock]]] = None
|
||||||
|
thinking: Optional[AnthropicThinkingParam] = None
|
||||||
tool_choice: Optional[AnthropicToolChoice] = None
|
tool_choice: Optional[AnthropicToolChoice] = None
|
||||||
tools: Optional[list[AnthropicTool]] = None
|
tools: Optional[list[AnthropicTool]] = None
|
||||||
|
# Claude 4.7 / SDK-compatibility fields. Accepted but no-op on count.
|
||||||
|
output_config: Optional[AnthropicOutputConfig] = None
|
||||||
|
betas: Optional[list[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class AnthropicCountTokensResponse(BaseModel):
|
class AnthropicCountTokensResponse(BaseModel):
|
||||||
"""Anthropic Count Tokens API response"""
|
"""Anthropic count_tokens API response."""
|
||||||
|
|
||||||
input_tokens: int
|
input_tokens: int
|
||||||
|
|
||||||
|
|
||||||
class AnthropicMessagesRequest(BaseModel):
|
class AnthropicMessagesRequest(BaseModel):
|
||||||
"""Anthropic Messages API request"""
|
"""Anthropic Messages API request."""
|
||||||
|
|
||||||
model: str
|
model: str
|
||||||
messages: list[AnthropicMessage]
|
messages: list[AnthropicMessage]
|
||||||
@@ -113,65 +367,139 @@ class AnthropicMessagesRequest(BaseModel):
|
|||||||
metadata: Optional[dict[str, Any]] = None
|
metadata: Optional[dict[str, Any]] = None
|
||||||
stop_sequences: Optional[list[str]] = None
|
stop_sequences: Optional[list[str]] = None
|
||||||
stream: Optional[bool] = False
|
stream: Optional[bool] = False
|
||||||
system: Optional[str | list[AnthropicContentBlock]] = None
|
system: Optional[Union[str, list[AnthropicContentBlock]]] = None
|
||||||
temperature: Optional[float] = None
|
temperature: Optional[float] = None
|
||||||
|
thinking: Optional[AnthropicThinkingParam] = None
|
||||||
tool_choice: Optional[AnthropicToolChoice] = None
|
tool_choice: Optional[AnthropicToolChoice] = None
|
||||||
tools: Optional[list[AnthropicTool]] = None
|
tools: Optional[list[AnthropicTool]] = None
|
||||||
top_k: Optional[int] = None
|
top_k: Optional[int] = None
|
||||||
top_p: Optional[float] = None
|
top_p: Optional[float] = None
|
||||||
|
# Claude 4.7 fields. The Anthropic SDK / Claude Code attach these even
|
||||||
|
# when targeting non-Anthropic backends, so the schema must accept them.
|
||||||
|
output_config: Optional[AnthropicOutputConfig] = None
|
||||||
|
betas: Optional[list[str]] = None
|
||||||
|
|
||||||
@field_validator("model")
|
@field_validator("model")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_model(cls, v):
|
def _validate_model(cls, v):
|
||||||
if not v:
|
if not v:
|
||||||
raise ValueError("Model is required")
|
raise ValueError("Model is required")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator("max_tokens")
|
@field_validator("max_tokens")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_max_tokens(cls, v):
|
def _validate_max_tokens(cls, v):
|
||||||
if v <= 0:
|
if v <= 0:
|
||||||
raise ValueError("max_tokens must be positive")
|
raise ValueError("max_tokens must be positive")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
class AnthropicDelta(BaseModel):
|
# ---------- Stream deltas ----------
|
||||||
"""Delta for streaming responses"""
|
# Content-block deltas (discriminated by ``type``) vs message-end delta
|
||||||
|
# (separate model; the wire format does not put ``type`` inside its payload).
|
||||||
|
|
||||||
type: Optional[Literal["text_delta", "input_json_delta"]] = None
|
|
||||||
text: Optional[str] = None
|
|
||||||
partial_json: Optional[str] = None
|
|
||||||
|
|
||||||
# Message delta fields
|
class TextDelta(BaseModel):
|
||||||
|
type: Literal["text_delta"] = "text_delta"
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class InputJsonDelta(BaseModel):
|
||||||
|
type: Literal["input_json_delta"] = "input_json_delta"
|
||||||
|
partial_json: str
|
||||||
|
|
||||||
|
|
||||||
|
class ThinkingDelta(BaseModel):
|
||||||
|
type: Literal["thinking_delta"] = "thinking_delta"
|
||||||
|
thinking: str
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureDelta(BaseModel):
|
||||||
|
type: Literal["signature_delta"] = "signature_delta"
|
||||||
|
signature: str
|
||||||
|
|
||||||
|
|
||||||
|
AnthropicContentDelta = Annotated[
|
||||||
|
Union[TextDelta, InputJsonDelta, ThinkingDelta, SignatureDelta],
|
||||||
|
Field(discriminator="type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicMessageEndDelta(BaseModel):
|
||||||
|
"""Delta carried on ``message_delta`` events.
|
||||||
|
|
||||||
|
Anthropic's protocol does NOT put a ``type`` field inside this delta
|
||||||
|
payload — the SSE ``event:`` header already says ``message_delta``.
|
||||||
|
Stop reason and stop sequence are the only fields.
|
||||||
|
"""
|
||||||
|
|
||||||
stop_reason: Optional[
|
stop_reason: Optional[
|
||||||
Literal["end_turn", "max_tokens", "stop_sequence", "tool_use"]
|
Literal["end_turn", "max_tokens", "stop_sequence", "tool_use"]
|
||||||
] = None
|
] = None
|
||||||
stop_sequence: Optional[str] = None
|
stop_sequence: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class AnthropicStreamEvent(BaseModel):
|
# ---------- Stream events (discriminated by ``type``) ----------
|
||||||
"""Streaming event"""
|
|
||||||
|
|
||||||
type: Literal[
|
|
||||||
"message_start",
|
class MessageStartEvent(BaseModel):
|
||||||
"message_delta",
|
type: Literal["message_start"] = "message_start"
|
||||||
"message_stop",
|
message: "AnthropicMessagesResponse"
|
||||||
"content_block_start",
|
|
||||||
"content_block_delta",
|
|
||||||
"content_block_stop",
|
class MessageDeltaEvent(BaseModel):
|
||||||
"ping",
|
type: Literal["message_delta"] = "message_delta"
|
||||||
"error",
|
delta: AnthropicMessageEndDelta
|
||||||
|
usage: AnthropicUsage
|
||||||
|
|
||||||
|
|
||||||
|
class MessageStopEvent(BaseModel):
|
||||||
|
type: Literal["message_stop"] = "message_stop"
|
||||||
|
|
||||||
|
|
||||||
|
class ContentBlockStartEvent(BaseModel):
|
||||||
|
type: Literal["content_block_start"] = "content_block_start"
|
||||||
|
index: int
|
||||||
|
content_block: AnthropicContentBlock
|
||||||
|
|
||||||
|
|
||||||
|
class ContentBlockDeltaEvent(BaseModel):
|
||||||
|
type: Literal["content_block_delta"] = "content_block_delta"
|
||||||
|
index: int
|
||||||
|
delta: AnthropicContentDelta
|
||||||
|
|
||||||
|
|
||||||
|
class ContentBlockStopEvent(BaseModel):
|
||||||
|
type: Literal["content_block_stop"] = "content_block_stop"
|
||||||
|
index: int
|
||||||
|
|
||||||
|
|
||||||
|
class PingEvent(BaseModel):
|
||||||
|
type: Literal["ping"] = "ping"
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorEvent(BaseModel):
|
||||||
|
type: Literal["error"] = "error"
|
||||||
|
error: AnthropicError
|
||||||
|
|
||||||
|
|
||||||
|
AnthropicStreamEvent = Annotated[
|
||||||
|
Union[
|
||||||
|
MessageStartEvent,
|
||||||
|
MessageDeltaEvent,
|
||||||
|
MessageStopEvent,
|
||||||
|
ContentBlockStartEvent,
|
||||||
|
ContentBlockDeltaEvent,
|
||||||
|
ContentBlockStopEvent,
|
||||||
|
PingEvent,
|
||||||
|
ErrorEvent,
|
||||||
|
],
|
||||||
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
message: Optional["AnthropicMessagesResponse"] = None
|
|
||||||
delta: Optional[AnthropicDelta] = None
|
|
||||||
content_block: Optional[AnthropicContentBlock] = None
|
|
||||||
index: Optional[int] = None
|
|
||||||
error: Optional[AnthropicError] = None
|
|
||||||
usage: Optional[AnthropicUsage] = None
|
|
||||||
|
|
||||||
|
|
||||||
class AnthropicMessagesResponse(BaseModel):
|
class AnthropicMessagesResponse(BaseModel):
|
||||||
"""Anthropic Messages API response"""
|
"""Anthropic Messages API response."""
|
||||||
|
|
||||||
id: str = Field(default_factory=lambda: f"msg_{uuid.uuid4().hex}")
|
id: str = Field(default_factory=lambda: f"msg_{uuid.uuid4().hex}")
|
||||||
type: Literal["message"] = "message"
|
type: Literal["message"] = "message"
|
||||||
@@ -183,3 +511,8 @@ class AnthropicMessagesResponse(BaseModel):
|
|||||||
] = None
|
] = None
|
||||||
stop_sequence: Optional[str] = None
|
stop_sequence: Optional[str] = None
|
||||||
usage: Optional[AnthropicUsage] = None
|
usage: Optional[AnthropicUsage] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Resolve forward references for nested types.
|
||||||
|
ToolResultBlock.model_rebuild()
|
||||||
|
MessageStartEvent.model_rebuild()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -425,13 +425,77 @@ from sglang.srt.entrypoints.v1_loads import router as v1_loads_router
|
|||||||
app.include_router(v1_loads_router)
|
app.include_router(v1_loads_router)
|
||||||
|
|
||||||
|
|
||||||
|
def _anthropic_validation_message(raw_errors) -> str:
|
||||||
|
"""Render Pydantic-style errors for an Anthropic /v1/messages route.
|
||||||
|
|
||||||
|
Builds a short ``loc: msg`` digest that names the offending fields without
|
||||||
|
leaking file paths or Python internals (the default ``str(exc)`` includes
|
||||||
|
the dispatcher's ``File "/.../http_server.py"`` line).
|
||||||
|
"""
|
||||||
|
parts: list[str] = []
|
||||||
|
for err in raw_errors or []:
|
||||||
|
loc = err.get("loc") or ()
|
||||||
|
if loc:
|
||||||
|
loc_str = ".".join(str(p) for p in loc if p not in ("body",))
|
||||||
|
else:
|
||||||
|
loc_str = ""
|
||||||
|
msg = (err.get("msg") or "").strip()
|
||||||
|
if loc_str and msg:
|
||||||
|
parts.append(f"{loc_str}: {msg}")
|
||||||
|
elif msg:
|
||||||
|
parts.append(msg)
|
||||||
|
text = "; ".join(parts) or "Invalid request"
|
||||||
|
if len(text) > 500:
|
||||||
|
text = text[:500] + "…"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _anthropic_error_response(*, status_code: int, error_type: str, message: str):
|
||||||
|
"""Anthropic-format error envelope: {"type":"error","error":{"type":...,"message":...}}."""
|
||||||
|
return ORJSONResponse(
|
||||||
|
status_code=status_code,
|
||||||
|
content={
|
||||||
|
"type": "error",
|
||||||
|
"error": {"type": error_type, "message": message},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(HTTPException)
|
@app.exception_handler(HTTPException)
|
||||||
async def validation_exception_handler(request: Request, exc: HTTPException):
|
async def validation_exception_handler(request: Request, exc: HTTPException):
|
||||||
"""Enrich HTTP exception with status code and other details.
|
"""Enrich HTTP exception with status code and other details.
|
||||||
|
|
||||||
For /v1/responses, emit OpenAI-style nested error envelope:
|
For /v1/responses, emit OpenAI-style nested error envelope:
|
||||||
{"error": {"message": "...", "type": "...", "param": null, "code": <status>}}
|
{"error": {"message": "...", "type": "...", "param": null, "code": <status>}}
|
||||||
|
For /v1/messages, emit Anthropic-style envelope so SDK clients can parse it.
|
||||||
"""
|
"""
|
||||||
|
if request.url.path.startswith("/v1/messages"):
|
||||||
|
# Map HTTP status to Anthropic error.type; fall back to api_error.
|
||||||
|
anthropic_type = {
|
||||||
|
400: "invalid_request_error",
|
||||||
|
401: "authentication_error",
|
||||||
|
403: "permission_error",
|
||||||
|
404: "not_found_error",
|
||||||
|
413: "request_too_large",
|
||||||
|
422: "invalid_request_error",
|
||||||
|
429: "rate_limit_error",
|
||||||
|
500: "api_error",
|
||||||
|
502: "api_error",
|
||||||
|
503: "overloaded_error",
|
||||||
|
504: "api_error",
|
||||||
|
}.get(exc.status_code, "api_error")
|
||||||
|
# 5xx must never echo upstream detail (may contain stack/PII).
|
||||||
|
message = (
|
||||||
|
"Internal server error"
|
||||||
|
if exc.status_code >= 500
|
||||||
|
else (str(exc.detail) if exc.detail else "Request failed")
|
||||||
|
)
|
||||||
|
return _anthropic_error_response(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
error_type=anthropic_type,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
# adjust fmt for responses api
|
# adjust fmt for responses api
|
||||||
if request.url.path.startswith("/v1/responses"):
|
if request.url.path.startswith("/v1/responses"):
|
||||||
nested_error = {
|
nested_error = {
|
||||||
@@ -458,8 +522,18 @@ async def validation_exception_handler(request: Request, exc: HTTPException):
|
|||||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||||
"""Override FastAPI's default 422 validation error with 400.
|
"""Override FastAPI's default 422 validation error with 400.
|
||||||
|
|
||||||
For /v1/responses, emit OpenAI-style nested error envelope; for other endpoints keep legacy format.
|
For /v1/messages, emit Anthropic-style envelope and scrub the message so
|
||||||
|
file paths or Python internals from the default ``str(exc)`` representation
|
||||||
|
never reach the client. For /v1/responses, keep OpenAI-style. Otherwise
|
||||||
|
use the legacy ErrorResponse shape.
|
||||||
"""
|
"""
|
||||||
|
if request.url.path.startswith("/v1/messages"):
|
||||||
|
return _anthropic_error_response(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST.value,
|
||||||
|
error_type="invalid_request_error",
|
||||||
|
message=_anthropic_validation_message(exc.errors()),
|
||||||
|
)
|
||||||
|
|
||||||
exc_str = str(exc)
|
exc_str = str(exc)
|
||||||
errors_str = str(exc.errors())
|
errors_str = str(exc.errors())
|
||||||
|
|
||||||
@@ -1067,9 +1141,11 @@ async def dump_expert_distribution_record_async():
|
|||||||
@auth_level(AuthLevel.ADMIN_OPTIONAL)
|
@auth_level(AuthLevel.ADMIN_OPTIONAL)
|
||||||
async def update_weights_from_disk(obj: UpdateWeightFromDiskReqInput, request: Request):
|
async def update_weights_from_disk(obj: UpdateWeightFromDiskReqInput, request: Request):
|
||||||
"""Update the weights from disk inplace without re-launching the server."""
|
"""Update the weights from disk inplace without re-launching the server."""
|
||||||
success, message, num_paused_requests = (
|
(
|
||||||
await _global_state.tokenizer_manager.update_weights_from_disk(obj, request)
|
success,
|
||||||
)
|
message,
|
||||||
|
num_paused_requests,
|
||||||
|
) = await _global_state.tokenizer_manager.update_weights_from_disk(obj, request)
|
||||||
|
|
||||||
content = {
|
content = {
|
||||||
"success": success,
|
"success": success,
|
||||||
@@ -1093,11 +1169,12 @@ async def update_weights_from_disk(obj: UpdateWeightFromDiskReqInput, request: R
|
|||||||
async def init_weights_send_group_for_remote_instance(
|
async def init_weights_send_group_for_remote_instance(
|
||||||
obj: InitWeightsSendGroupForRemoteInstanceReqInput, request: Request
|
obj: InitWeightsSendGroupForRemoteInstanceReqInput, request: Request
|
||||||
):
|
):
|
||||||
success, message = (
|
(
|
||||||
await _global_state.tokenizer_manager.init_weights_send_group_for_remote_instance(
|
success,
|
||||||
|
message,
|
||||||
|
) = await _global_state.tokenizer_manager.init_weights_send_group_for_remote_instance(
|
||||||
obj, request
|
obj, request
|
||||||
)
|
)
|
||||||
)
|
|
||||||
content = {"success": success, "message": message}
|
content = {"success": success, "message": message}
|
||||||
if success:
|
if success:
|
||||||
return ORJSONResponse(content, status_code=200)
|
return ORJSONResponse(content, status_code=200)
|
||||||
@@ -1110,11 +1187,12 @@ async def init_weights_send_group_for_remote_instance(
|
|||||||
async def send_weights_to_remote_instance(
|
async def send_weights_to_remote_instance(
|
||||||
obj: SendWeightsToRemoteInstanceReqInput, request: Request
|
obj: SendWeightsToRemoteInstanceReqInput, request: Request
|
||||||
):
|
):
|
||||||
success, message = (
|
(
|
||||||
await _global_state.tokenizer_manager.send_weights_to_remote_instance(
|
success,
|
||||||
|
message,
|
||||||
|
) = await _global_state.tokenizer_manager.send_weights_to_remote_instance(
|
||||||
obj, request
|
obj, request
|
||||||
)
|
)
|
||||||
)
|
|
||||||
content = {"success": success, "message": message}
|
content = {"success": success, "message": message}
|
||||||
if success:
|
if success:
|
||||||
return ORJSONResponse(content, status_code=200)
|
return ORJSONResponse(content, status_code=200)
|
||||||
@@ -1182,9 +1260,10 @@ async def destroy_weights_update_group(
|
|||||||
obj: DestroyWeightsUpdateGroupReqInput, request: Request
|
obj: DestroyWeightsUpdateGroupReqInput, request: Request
|
||||||
):
|
):
|
||||||
"""Destroy the parameter update group."""
|
"""Destroy the parameter update group."""
|
||||||
success, message = (
|
(
|
||||||
await _global_state.tokenizer_manager.destroy_weights_update_group(obj, request)
|
success,
|
||||||
)
|
message,
|
||||||
|
) = await _global_state.tokenizer_manager.destroy_weights_update_group(obj, request)
|
||||||
content = {"success": success, "message": message}
|
content = {"success": success, "message": message}
|
||||||
return ORJSONResponse(
|
return ORJSONResponse(
|
||||||
content, status_code=200 if success else HTTPStatus.BAD_REQUEST
|
content, status_code=200 if success else HTTPStatus.BAD_REQUEST
|
||||||
@@ -1219,11 +1298,12 @@ async def update_weights_from_distributed(
|
|||||||
obj: UpdateWeightsFromDistributedReqInput, request: Request
|
obj: UpdateWeightsFromDistributedReqInput, request: Request
|
||||||
):
|
):
|
||||||
"""Update model parameter from distributed online."""
|
"""Update model parameter from distributed online."""
|
||||||
success, message = (
|
(
|
||||||
await _global_state.tokenizer_manager.update_weights_from_distributed(
|
success,
|
||||||
|
message,
|
||||||
|
) = await _global_state.tokenizer_manager.update_weights_from_distributed(
|
||||||
obj, request
|
obj, request
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
content = {"success": success, "message": message}
|
content = {"success": success, "message": message}
|
||||||
if success:
|
if success:
|
||||||
|
|||||||
@@ -1564,6 +1564,111 @@ class OpenAIServingChat(OpenAIServingBase):
|
|||||||
):
|
):
|
||||||
request.skip_special_tokens = False
|
request.skip_special_tokens = False
|
||||||
|
|
||||||
|
def wrap_reasoning_history(self, reasoning_text: str) -> str:
|
||||||
|
"""Wrap prior-turn reasoning in the detector's own start/end tokens.
|
||||||
|
|
||||||
|
Pulling the delimiters from the detector keeps adapters in lockstep
|
||||||
|
with any future parser that ships non-``<think>`` markers — Mistral's
|
||||||
|
``[THINK]``, Gemma4's ``think_start_self_label = "thought\\n"``, etc.
|
||||||
|
Falling back to a plain string is unsafe: it would let prior
|
||||||
|
thinking text reach a non-reasoning model as ordinary assistant
|
||||||
|
content, so the caller must surface this state, not paper over it.
|
||||||
|
"""
|
||||||
|
if self._reasoning_detector is None:
|
||||||
|
raise ValueError(
|
||||||
|
"Cannot rewrap thinking history: no reasoning detector is "
|
||||||
|
"configured for this model"
|
||||||
|
)
|
||||||
|
d = self._reasoning_detector
|
||||||
|
return (
|
||||||
|
f"{d.think_start_token}{d.think_start_self_label}"
|
||||||
|
f"{reasoning_text}\n{d.think_end_token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _reasoning_default_mode(self) -> Optional[str]:
|
||||||
|
if self._reasoning_detector is None:
|
||||||
|
return None
|
||||||
|
return self._reasoning_detector.reasoning_default
|
||||||
|
|
||||||
|
def _get_reasoning_toggle_param(self) -> Optional[str]:
|
||||||
|
"""Resolve the chat-template kwarg that toggles reasoning, if any."""
|
||||||
|
config = self.template_manager.reasoning_config
|
||||||
|
if config is not None:
|
||||||
|
return config.toggle_param
|
||||||
|
|
||||||
|
mode = self._reasoning_default_mode()
|
||||||
|
if mode in ("thinking", "enable_thinking"):
|
||||||
|
return mode
|
||||||
|
if mode in ("explicit_thinking", "explicit_enable_thinking"):
|
||||||
|
return mode.replace("explicit_", "")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def apply_reasoning_enabled(
|
||||||
|
self, request: ChatCompletionRequest, enabled: bool
|
||||||
|
) -> None:
|
||||||
|
"""Force the request into the requested reasoning-on/off mode.
|
||||||
|
|
||||||
|
Mirrors the read-side logic in ``_get_reasoning_from_request``;
|
||||||
|
the two must stay in sync. Always-on models cannot be disabled,
|
||||||
|
so explicit ``enabled=False`` raises rather than silently leaving
|
||||||
|
reasoning on.
|
||||||
|
"""
|
||||||
|
if not self.reasoning_parser:
|
||||||
|
if enabled:
|
||||||
|
raise ValueError(
|
||||||
|
"Anthropic thinking is not supported for models without "
|
||||||
|
"a reasoning parser"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.reasoning_parser == "hunyuan":
|
||||||
|
request.reasoning_effort = "medium" if enabled else "no_think"
|
||||||
|
return
|
||||||
|
|
||||||
|
config = self.template_manager.reasoning_config
|
||||||
|
is_mistral = (config is not None and config.special_case == "mistral") or (
|
||||||
|
config is None and self._reasoning_default_mode() == "mistral"
|
||||||
|
)
|
||||||
|
if is_mistral:
|
||||||
|
request.reasoning_effort = "medium" if enabled else "none"
|
||||||
|
return
|
||||||
|
|
||||||
|
is_always_on = (config is not None and config.special_case == "always") or (
|
||||||
|
config is None and self._reasoning_default_mode() == "always"
|
||||||
|
)
|
||||||
|
if is_always_on:
|
||||||
|
if not enabled:
|
||||||
|
raise ValueError(
|
||||||
|
f"Reasoning parser '{self.reasoning_parser}' is always-on "
|
||||||
|
f"and cannot be disabled via Anthropic thinking"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
toggle_param = self._get_reasoning_toggle_param()
|
||||||
|
# The read side (``_get_reasoning_from_request``) returns False
|
||||||
|
# whenever ``config.toggle_param is None`` OR
|
||||||
|
# ``config.default_enabled is None``. The write side must mirror
|
||||||
|
# both conditions: if ``default_enabled`` is unset we cannot
|
||||||
|
# actually honor an ``enabled=True`` request even when the toggle
|
||||||
|
# name itself is resolvable, so writing the kwarg would set up the
|
||||||
|
# template to emit reasoning tokens while the parser ignores them
|
||||||
|
# (literal ``<think>`` markers leak into the assistant text).
|
||||||
|
config = self.template_manager.reasoning_config
|
||||||
|
read_side_supported = toggle_param is not None and (
|
||||||
|
config is None or config.default_enabled is not None
|
||||||
|
)
|
||||||
|
if not read_side_supported:
|
||||||
|
if not enabled:
|
||||||
|
return
|
||||||
|
raise ValueError(
|
||||||
|
f"Anthropic thinking is not supported for reasoning parser "
|
||||||
|
f"'{self.reasoning_parser}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
chat_template_kwargs = dict(request.chat_template_kwargs or {})
|
||||||
|
chat_template_kwargs[toggle_param] = enabled
|
||||||
|
request.chat_template_kwargs = chat_template_kwargs
|
||||||
|
|
||||||
def _get_reasoning_from_request(self, request: ChatCompletionRequest) -> bool:
|
def _get_reasoning_from_request(self, request: ChatCompletionRequest) -> bool:
|
||||||
"""Determine whether reasoning mode should be enabled for this request.
|
"""Determine whether reasoning mode should be enabled for this request.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user