Files
wehub-resource-sync 94057c3d3e
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:38:16 +08:00

1453 lines
59 KiB
Python

"""Handler for Anthropic Messages API requests.
Converts Anthropic requests to OpenAI ChatCompletion format, delegates to
OpenAIServingChat for processing, and converts responses back to Anthropic format.
"""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from typing import TYPE_CHECKING, Any, AsyncGenerator, Optional, Union
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, ValidationError
from sglang.srt.entrypoints.anthropic.protocol import (
AnthropicContentBlock,
AnthropicCountTokensRequest,
AnthropicCountTokensResponse,
AnthropicError,
AnthropicErrorResponse,
AnthropicMessageEndDelta,
AnthropicMessagesRequest,
AnthropicMessagesResponse,
AnthropicStreamEvent,
AnthropicUsage,
ContentBlockDeltaEvent,
ContentBlockStartEvent,
ContentBlockStopEvent,
ErrorEvent,
InputJsonDelta,
MessageDeltaEvent,
MessageStartEvent,
MessageStopEvent,
SignatureDelta,
TextBlock,
TextDelta,
ThinkingBlock,
ThinkingDelta,
ToolUseBlock,
is_server_tool,
)
from sglang.srt.entrypoints.openai.protocol import (
ChatCompletionRequest,
ChatCompletionResponse,
ChatCompletionStreamResponse,
StreamOptions,
Tool,
ToolChoice,
ToolChoiceFuncName,
)
from sglang.srt.observability.req_time_stats import monotonic_time
from sglang.srt.parser.template_detection import detect_inline_system_support
if TYPE_CHECKING:
from sglang.srt.entrypoints.openai.serving_chat import OpenAIServingChat
logger = logging.getLogger(__name__)
# Map OpenAI finish reasons to Anthropic stop reasons. Only the four
# values in ``AnthropicMessagesResponse.stop_reason``'s Literal are valid
# on the wire; ``content_filter`` and ``abort`` have no perfect mapping
# so they fall through to the ``end_turn`` default with a WARNING at the
# call site so operators don't lose the safety/abort signal in logs.
STOP_REASON_MAP = {
"stop": "end_turn",
"length": "max_tokens",
"tool_calls": "tool_use",
}
ERROR_TYPE_MAP = {
400: "invalid_request_error",
401: "authentication_error",
403: "permission_error",
404: "not_found_error",
408: "request_timeout_error",
429: "rate_limit_error",
500: "api_error",
502: "api_error",
503: "overloaded_error",
504: "api_error",
}
def _cached_prompt_tokens(usage) -> int:
prompt_tokens_details = getattr(usage, "prompt_tokens_details", None)
return getattr(prompt_tokens_details, "cached_tokens", 0) or 0
def _anthropic_input_tokens(usage) -> int:
prompt = getattr(usage, "prompt_tokens", 0) or 0
cached = _cached_prompt_tokens(usage)
if cached > prompt:
# Upstream telemetry bug: cached cannot exceed the prompt it caches.
# Clamping silently here would hide the discrepancy from billing
# dashboards, so make it visible at WARNING level.
logger.warning(
"Cached tokens (%d) exceed prompt tokens (%d); clamping "
"input_tokens to 0. This usually indicates an upstream "
"telemetry bug.",
cached,
prompt,
)
return max(prompt - cached, 0)
def _anthropic_usage_from_openai(
usage,
*,
include_input: bool,
include_output: bool,
force_zero_output: bool = False,
) -> AnthropicUsage:
if usage is None:
return AnthropicUsage(
input_tokens=0 if include_input else None,
output_tokens=0 if include_output else None,
)
usage_fields: dict[str, int] = {}
cached_tokens = _cached_prompt_tokens(usage)
if include_input:
usage_fields["input_tokens"] = _anthropic_input_tokens(usage)
if cached_tokens:
usage_fields["cache_read_input_tokens"] = cached_tokens
if include_output:
usage_fields["output_tokens"] = (
0 if force_zero_output else (getattr(usage, "completion_tokens", 0) or 0)
)
return AnthropicUsage(**usage_fields)
def _extract_system_text(
content: Union[str, list[AnthropicContentBlock]],
) -> Optional[str]:
"""Flatten a system message's content to a trimmed string, or ``None``."""
if isinstance(content, str):
return content.strip() or None
texts = []
for block in content:
if isinstance(block, BaseModel) and getattr(block, "type", None) == "text":
text = getattr(block, "text", "")
elif isinstance(block, dict) and block.get("type") == "text":
text = block.get("text", "")
else:
continue
text = (text or "").strip()
if text:
texts.append(text)
return "\n".join(texts) if texts else None
def _wrap_sse_event(data: str, event_type: str) -> str:
"""Format an Anthropic SSE event with event type and data lines."""
return f"event: {event_type}\ndata: {data}\n\n"
def _scrub_error_message(message: str, status_code: int) -> str:
"""Return a safe outward-facing error message.
5xx is always generic — never echo upstream ``str(e)`` payloads, which
may contain stack frames, file paths, or PII. 4xx keeps the original
message (truncated and with obvious traceback lines stripped) so
callers see the real validation failure.
"""
if status_code >= 500:
return "Internal server error"
if not message:
return "Request failed"
safe_lines = [
ln
for ln in message.splitlines()
if not ln.startswith("Traceback") and 'File "/' not in ln
]
cleaned = "\n".join(safe_lines).strip()
if len(cleaned) > 500:
cleaned = cleaned[:500] + "…"
return cleaned or "Request failed"
class AnthropicServing:
"""Handler for Anthropic Messages API requests.
Acts as a translation layer between Anthropic's Messages API and SGLang's
OpenAI-compatible chat completion infrastructure.
"""
def __init__(self, openai_serving_chat: OpenAIServingChat):
self.openai_serving_chat = openai_serving_chat
self._merge_inline_system = not detect_inline_system_support(
self._chat_template()
)
def _chat_template(self) -> Optional[str]:
tokenizer_manager = getattr(self.openai_serving_chat, "tokenizer_manager", None)
if tokenizer_manager is None:
return None
tokenizer = getattr(tokenizer_manager, "tokenizer", None)
if tokenizer is None:
return None
return getattr(tokenizer, "chat_template", None)
async def handle_messages(
self,
request: AnthropicMessagesRequest,
raw_request: Request,
) -> Union[JSONResponse, StreamingResponse]:
"""Main entry point for /v1/messages endpoint."""
try:
chat_request = self._convert_to_chat_completion_request(request)
except asyncio.CancelledError:
raise
except Exception as e:
logger.exception("Error converting Anthropic request: %s", e)
return self._error_response(
status_code=400,
error_type="invalid_request_error",
message=str(e),
)
if request.stream:
return await self._handle_streaming(chat_request, request, raw_request)
else:
return await self._handle_non_streaming(chat_request, request, raw_request)
def _convert_to_chat_completion_request(
self, anthropic_request: AnthropicMessagesRequest
) -> ChatCompletionRequest:
"""Convert an Anthropic Messages request to an OpenAI ChatCompletion request."""
openai_messages = []
def _convert_anthropic_image_source_to_openai_part(
source: Any,
) -> Optional[dict]:
# Source may arrive as a Pydantic model (typed ImageBlock.source)
# or as a raw dict when parsed from a nested tool_result payload.
if isinstance(source, BaseModel):
source = source.model_dump(exclude_none=True)
if not isinstance(source, dict):
return None
source_type = source.get("type")
if source_type == "base64":
media_type = source.get("media_type", "image/png")
data = source.get("data", "")
if not data:
return None
return {
"type": "image_url",
"image_url": {
"url": f"data:{media_type};base64,{data}",
},
}
url = source.get("url")
if url:
return {
"type": "image_url",
"image_url": {
"url": url,
},
}
return None
def _text_from_search_result(item: dict[str, Any]) -> str:
search_parts = []
title = item.get("title")
if title:
search_parts.append(f"Title: {title}")
source = item.get("source")
if isinstance(source, dict):
source_text = source.get("url") or source.get("text")
if source_text:
search_parts.append(f"Source: {source_text}")
elif source:
search_parts.append(f"Source: {source}")
content = item.get("content")
content_parts = []
if isinstance(content, str):
content_parts.append(content)
elif isinstance(content, list):
for part in content:
if not isinstance(part, dict):
continue
if part.get("type") == "text" and part.get("text"):
content_parts.append(part["text"])
if content_parts:
search_parts.append("Content: " + "\n".join(content_parts))
return "\n".join(search_parts)
def _convert_tool_result_content(
content: Any,
) -> tuple[Union[str, list[dict]], str]:
if isinstance(content, list):
tool_content_parts = []
tool_text_parts = []
for raw_item in content:
# Items may be typed Pydantic blocks (after request
# validation) or raw dicts (from legacy callers). Coerce
# to dict so the existing key-based logic still works.
if isinstance(raw_item, BaseModel):
item = raw_item.model_dump(exclude_none=True)
elif isinstance(raw_item, dict):
item = raw_item
else:
continue
item_type = item.get("type")
if item_type == "text":
text = item.get("text", "")
if text:
tool_text_parts.append(text)
tool_content_parts.append({"type": "text", "text": text})
elif item_type == "image":
image_part = _convert_anthropic_image_source_to_openai_part(
item.get("source")
)
if image_part is not None:
tool_content_parts.append(image_part)
elif item_type == "tool_reference":
# Anthropic uses `tool_name`; the SGLang chat template
# matches on `name`. Translate at the boundary.
ref_name = item.get("tool_name") or item.get("name")
if ref_name:
tool_content_parts.append(
{"type": "tool_reference", "name": ref_name}
)
elif item_type == "search_result":
search_text = _text_from_search_result(item)
if search_text:
tool_text_parts.append(search_text)
tool_content_parts.append(
{"type": "text", "text": search_text}
)
tool_text = "\n".join(tool_text_parts)
if (
len(tool_content_parts) == 1
and tool_content_parts[0]["type"] == "text"
):
return tool_content_parts[0]["text"], tool_text
if tool_content_parts:
return tool_content_parts, tool_text
return "", tool_text
tool_text = str(content) if content else ""
return tool_text, tool_text
def _convert_assistant_thinking_blocks(
blocks: list[AnthropicContentBlock],
) -> Optional[str]:
"""Re-wrap prior-turn thinking blocks in the parser's own tokens.
``redacted_thinking`` carries encrypted bytes that no local
parser can interpret, so we raise rather than silently drop it.
On non-reasoning models (no detector configured) the rewrap is
best-effort: we log a warning and drop the thinking text so a
history echo doesn't 400 the whole request — the prior thinking
is opaque context the model didn't need anyway.
"""
if any(block.type == "redacted_thinking" for block in blocks):
raise ValueError("Anthropic redacted_thinking history is not supported")
thinking_parts = [
block.thinking
for block in blocks
if block.type == "thinking" and block.thinking
]
if not thinking_parts:
return None
try:
return self.openai_serving_chat.wrap_reasoning_history(
"\n".join(thinking_parts)
)
except ValueError as e:
logger.warning(
"Dropping prior-turn thinking history (%d blocks): %s",
len(thinking_parts),
e,
)
return None
system_parts: list[str] = []
if anthropic_request.system:
if isinstance(anthropic_request.system, str):
if anthropic_request.system.strip():
system_parts.append(anthropic_request.system)
else:
for block in anthropic_request.system:
if block.type == "text" and block.text:
system_parts.append(block.text)
if self._merge_inline_system:
for msg in anthropic_request.messages:
if msg.role != "system":
continue
text = _extract_system_text(msg.content)
if text:
system_parts.append(text)
if system_parts:
openai_messages.append(
{"role": "system", "content": "\n".join(system_parts)}
)
def _emit_user_message(parts: list[dict]) -> None:
"""Append accumulated parts as a user message, then clear them.
Used to flush content collected BEFORE a tool_result so the
wire order stays user(pre) → tool → user(post). Without this
flush, text/image parts that appeared before a tool_result
block would be moved AFTER the tool message at end of loop.
"""
if not parts:
return
if len(parts) == 1 and parts[0]["type"] == "text":
openai_messages.append({"role": "user", "content": parts[0]["text"]})
else:
openai_messages.append({"role": "user", "content": list(parts)})
parts.clear()
# Convert messages
for msg in anthropic_request.messages:
if msg.role == "system" and self._merge_inline_system:
continue
if isinstance(msg.content, str):
openai_messages.append({"role": msg.role, "content": msg.content})
continue
# Complex content with blocks
openai_msg = {"role": msg.role}
content_parts: list[dict] = []
tool_calls: list[dict] = []
if msg.role == "assistant":
reasoning_history = _convert_assistant_thinking_blocks(msg.content)
if reasoning_history is not None:
content_parts.append({"type": "text", "text": reasoning_history})
for block in msg.content:
# ``thinking``/``redacted_thinking`` blocks are surfaced via
# the reasoning-history reconstruction above; skip them here
# to avoid double-injecting their text into the prompt.
if block.type in ("thinking", "redacted_thinking"):
continue
# ``is not None`` (not truthy) so an empty-string text block
# still produces a placeholder text part — without it, an
# assistant turn whose only content is "" vanishes and
# subsequent user→user pairs trip strict chat templates.
if block.type == "text" and block.text is not None:
content_parts.append({"type": "text", "text": block.text})
elif block.type == "image" and block.source:
image_part = _convert_anthropic_image_source_to_openai_part(
block.source
)
if image_part is not None:
content_parts.append(image_part)
elif block.type == "search_result":
search_text = _text_from_search_result(block.model_dump())
if search_text:
content_parts.append({"type": "text", "text": search_text})
elif block.type == "tool_use":
tool_call = {
"id": block.id or f"call_{uuid.uuid4().hex}",
"type": "function",
"function": {
"name": block.name or "",
"arguments": json.dumps(block.input or {}),
},
}
tool_calls.append(tool_call)
elif block.type == "tool_result":
tool_content, tool_text = _convert_tool_result_content(
block.content
)
# Use tool_use_id (per spec) with fallback to id
tool_call_id = block.tool_use_id or block.id or ""
# Tool results from user become separate tool messages.
# Flush any pending text/image first so the wire order
# is preserved (a tool_result that arrived AFTER a text
# block must come AFTER that text in OpenAI form too).
if msg.role == "user":
_emit_user_message(content_parts)
openai_messages.append(
{
"role": "tool",
"tool_call_id": tool_call_id,
"content": tool_content,
}
)
else:
content_parts.append(
{
"type": "text",
"text": f"Tool result: {tool_text}",
}
)
# Attach tool calls to assistant messages
if tool_calls:
openai_msg["tool_calls"] = tool_calls
# Attach content
if content_parts:
if len(content_parts) == 1 and content_parts[0]["type"] == "text":
openai_msg["content"] = content_parts[0]["text"]
else:
openai_msg["content"] = content_parts
openai_messages.append(openai_msg)
elif tool_calls:
openai_messages.append(openai_msg)
elif msg.role == "user":
# User turn that was entirely tool_results — the tool
# messages were already emitted above, nothing left.
continue
else:
# Assistant turn with no content and no tool_calls: emit
# an empty-string placeholder so strict templates still
# see a valid role-alternation sequence.
openai_msg["content"] = ""
openai_messages.append(openai_msg)
# Build ChatCompletionRequest
request_data = {
"messages": openai_messages,
"model": anthropic_request.model,
"max_tokens": anthropic_request.max_tokens,
"stream": anthropic_request.stream or False,
}
if anthropic_request.temperature is not None:
request_data["temperature"] = anthropic_request.temperature
if anthropic_request.top_p is not None:
request_data["top_p"] = anthropic_request.top_p
if anthropic_request.top_k is not None:
request_data["top_k"] = anthropic_request.top_k
if anthropic_request.stop_sequences is not None:
request_data["stop"] = anthropic_request.stop_sequences
# Enable usage in stream so we can report it
if anthropic_request.stream:
request_data["stream_options"] = StreamOptions(
include_usage=True,
continuous_usage_stats=True,
)
chat_request = ChatCompletionRequest(**request_data)
if anthropic_request.thinking is not None:
# The protocol layer already enforces SDK shape:
# enabled -> budget_tokens required (>=1024), display optional
# disabled -> neither budget_tokens nor display allowed
# adaptive -> budget_tokens forbidden, display optional
# So by the time we get here ``budget_tokens`` can only be
# set on ``enabled``. The local backend has no equivalent
# hard-cap knob, so we log a WARNING instead of rejecting —
# the Anthropic SDK would have accepted the request and we
# mirror that. Operators see the unenforced budget in logs.
if anthropic_request.thinking.budget_tokens is not None:
logger.warning(
"Anthropic thinking.budget_tokens=%d is accepted for "
"SDK compatibility but the local backend has no "
"equivalent hard-cap knob — the budget is not enforced",
anthropic_request.thinking.budget_tokens,
)
# Claude 4.7's ``adaptive`` is treated identically to ``enabled``
# because the local backend has no auto-throttle equivalent.
# Anything other than ``disabled`` enables reasoning.
enabled = anthropic_request.thinking.type != "disabled"
if anthropic_request.thinking.display == "omitted":
# Anthropic 4.7 spec: keep reasoning ON but hide reasoning
# text from the client. The OpenAI streaming pipeline has
# no equivalent suppress knob — log so operators can see
# the request, then proceed with normal reasoning emission.
logger.warning(
"Anthropic thinking.display='omitted' is accepted for "
"SDK compatibility but reasoning text will still be "
"emitted to the client"
)
self.openai_serving_chat.apply_reasoning_enabled(chat_request, enabled)
# Claude 4.7 ``output_config``: map ``effort`` onto the OpenAI
# ``reasoning_effort`` knob. ``xhigh`` collapses to ``max`` because
# the OpenAI Literal does not include the Anthropic-only ``xhigh``.
# ``task_budget`` is a soft hint forwarded as a custom param so the
# model can see it without it becoming a hard cap (``max_tokens``
# is still the hard cap).
if anthropic_request.output_config is not None:
oc = anthropic_request.output_config
if oc.effort is not None:
chat_request.reasoning_effort = (
"max" if oc.effort == "xhigh" else oc.effort
)
if oc.task_budget is not None:
# Custom params are silently ignored by backends that
# don't recognise them; logging it makes the propagation
# visible.
logger.info(
"Anthropic output_config.task_budget hint: %d %s",
oc.task_budget.total,
oc.task_budget.type,
)
# ``betas`` is the Anthropic SDK's opt-in feature list (e.g.
# ``["thinking-2025-08-04"]``). The local backend has no
# equivalent beta system; accept-and-log so requests don't 400.
if anthropic_request.betas:
logger.info(
"Anthropic request opted into betas %s — no-op locally",
anthropic_request.betas,
)
# Convert tools. Deferred tools stay in the list with defer_loading=True;
# the chat template hides them from the initial <tools> block and renders
# them on demand when a tool_reference block names them.
if anthropic_request.tools:
converted_tools = []
for tool in anthropic_request.tools:
if is_server_tool(tool):
# Anthropic server-side tools (web_search_*, computer_*,
# bash_*, text_editor_*) have no client-side input_schema
# because Anthropic provides the implementation. We can't
# forward them to the OpenAI tools array (which requires a
# schema), so skip with a visible log.
logger.info(
"Skipping built-in Anthropic server tool %r (type=%r): "
"no native support in the OpenAI-compatible backend",
tool.name,
tool.type,
)
continue
# Custom tools always have a validated input_schema
# (enforced at Pydantic parse time).
converted_tools.append(
Tool(
type="function",
defer_loading=tool.defer_loading,
function={
"name": tool.name,
"description": tool.description or "",
"parameters": tool.input_schema,
},
)
)
if converted_tools:
chat_request.tools = converted_tools
# Convert tool choice. ``any``/``tool`` express a hard requirement
# ("the model MUST call a tool"); if every requested tool was a
# server-side Anthropic built-in that we just skipped, there is
# no tool the model could call. Silently downgrading to "no tool"
# would deceive the caller, so raise an explicit 400.
if anthropic_request.tool_choice is not None:
tc_type = anthropic_request.tool_choice.type
if tc_type == "none":
chat_request.tool_choice = "none"
elif chat_request.tools:
if tc_type == "auto":
chat_request.tool_choice = "auto"
elif tc_type == "any":
chat_request.tool_choice = "required"
elif tc_type == "tool":
tool_name = anthropic_request.tool_choice.name
# ``Tool.function`` is a ``Function`` Pydantic model, not
# a dict — access by attribute. A dict ``.get`` would
# AttributeError and surface as a 500 instead of the
# intended 400 / happy path.
if not any(
t.function.name == tool_name for t in chat_request.tools
):
raise ValueError(
f"tool_choice references tool {tool_name!r} but it "
f"is not in the forwarded tools list "
f"(server-side Anthropic tools cannot be selected)"
)
chat_request.tool_choice = ToolChoice(
type="function",
function=ToolChoiceFuncName(name=tool_name),
)
elif tc_type in ("any", "tool"):
raise ValueError(
f"tool_choice={tc_type!r} requires at least one custom "
f"tool; all supplied tools were server-side Anthropic "
f"built-ins which the OpenAI-compatible backend cannot "
f"invoke"
)
elif chat_request.tools:
chat_request.tool_choice = "auto"
return chat_request
async def _handle_non_streaming(
self,
chat_request: ChatCompletionRequest,
anthropic_request: AnthropicMessagesRequest,
raw_request: Request,
) -> JSONResponse:
"""Handle non-streaming Anthropic request by delegating to OpenAI handler."""
# ``monotonic_time`` is ``time.perf_counter`` under the hood; the
# downstream stats layer subtracts other ``perf_counter`` samples
# from this, so they must come from the same clock.
received_time = monotonic_time()
# Validate
error_msg = self.openai_serving_chat._validate_request(chat_request)
if error_msg:
return self._error_response(
status_code=400,
error_type="invalid_request_error",
message=error_msg,
)
try:
# Convert to internal request
adapted_request, processed_request = (
self.openai_serving_chat._convert_to_internal_request(
chat_request, raw_request
)
)
adapted_request.received_time = received_time
# Get response from OpenAI handler
response = await self.openai_serving_chat._handle_non_streaming_request(
adapted_request, processed_request, raw_request
)
except asyncio.CancelledError:
raise
except Exception as e:
logger.exception("Error processing Anthropic request: %s", e)
return self._error_response(
status_code=500,
error_type="api_error",
message="Internal server error",
exception_name=type(e).__name__,
)
# Check for error responses from OpenAI handler
if not isinstance(response, ChatCompletionResponse):
# It's an error response (ORJSONResponse)
return self._convert_openai_error_response(response)
# Convert to Anthropic response
anthropic_response = self._convert_response(response)
return JSONResponse(content=anthropic_response.model_dump(exclude_none=True))
async def _handle_streaming(
self,
chat_request: ChatCompletionRequest,
anthropic_request: AnthropicMessagesRequest,
raw_request: Request,
) -> Union[StreamingResponse, JSONResponse]:
"""Handle streaming Anthropic request."""
received_time = monotonic_time()
# Validate
error_msg = self.openai_serving_chat._validate_request(chat_request)
if error_msg:
return self._error_response(
status_code=400,
error_type="invalid_request_error",
message=error_msg,
)
try:
adapted_request, processed_request = (
self.openai_serving_chat._convert_to_internal_request(
chat_request, raw_request
)
)
adapted_request.received_time = received_time
except asyncio.CancelledError:
raise
except Exception as e:
logger.exception("Error converting streaming request: %s", e)
return self._error_response(
status_code=500,
error_type="api_error",
message="Internal server error",
exception_name=type(e).__name__,
)
return StreamingResponse(
self._generate_anthropic_stream(
adapted_request,
processed_request,
anthropic_request,
raw_request,
),
media_type="text/event-stream",
background=self.openai_serving_chat.tokenizer_manager.create_abort_task(
adapted_request
),
)
async def _generate_anthropic_stream(
self,
adapted_request,
processed_request: ChatCompletionRequest,
anthropic_request: AnthropicMessagesRequest,
raw_request: Request,
) -> AsyncGenerator[str, None]:
"""Convert OpenAI chat stream to Anthropic event stream."""
openai_stream = self.openai_serving_chat._generate_chat_stream(
adapted_request, processed_request, raw_request
)
content_block_index = 0
content_block_open = False
content_block_type: Optional[str] = None
captured_thinking_signature: str = ""
finish_reason: Optional[str] = None
final_usage: Optional[AnthropicUsage] = None
message_started = False
had_content_delta = False
message_id = f"msg_{uuid.uuid4().hex}"
model = anthropic_request.model
def _message_start_event(usage) -> MessageStartEvent:
return MessageStartEvent(
message=AnthropicMessagesResponse(
id=message_id,
content=[],
model=model,
usage=_anthropic_usage_from_openai(
usage,
include_input=True,
include_output=True,
force_zero_output=True,
),
),
)
def _emit(event: AnthropicStreamEvent) -> str:
return _wrap_sse_event(
event.model_dump_json(exclude_none=True),
event.type,
)
def _close_content_block_events() -> list[AnthropicStreamEvent]:
nonlocal content_block_index, content_block_open
nonlocal content_block_type, captured_thinking_signature
events: list[AnthropicStreamEvent] = []
if not content_block_open:
return events
# Only emit signature_delta when a real signature is available.
# Anthropic's spec treats absence as "unsigned thinking"; an
# empty-string signature would fail downstream verifiers.
if content_block_type == "thinking" and captured_thinking_signature:
events.append(
ContentBlockDeltaEvent(
index=content_block_index,
delta=SignatureDelta(
signature=captured_thinking_signature,
),
)
)
events.append(ContentBlockStopEvent(index=content_block_index))
content_block_open = False
content_block_type = None
content_block_index += 1
captured_thinking_signature = ""
return events
def _ensure_content_block_events(
block_type: str,
content_block: AnthropicContentBlock,
force_new: bool = False,
) -> list[AnthropicStreamEvent]:
"""Open a content_block, closing the prior one if needed.
``force_new=True`` closes an existing block even when its type
matches — required when a stream emits two consecutive
``tool_use`` blocks: each tool needs its own
``content_block_start``/``stop`` pair and its own
``content_block_index``, otherwise the second tool's
``input_json_delta`` chunks would append to the first tool's
JSON arguments and corrupt both tool calls.
"""
nonlocal content_block_open, content_block_type
events: list[AnthropicStreamEvent] = []
if content_block_open and (force_new or content_block_type != block_type):
events.extend(_close_content_block_events())
if not content_block_open:
events.append(
ContentBlockStartEvent(
index=content_block_index,
content_block=content_block,
)
)
content_block_open = True
content_block_type = block_type
return events
def _ensure_message_started(usage) -> list[str]:
"""Emit message_start exactly once. Returns SSE frames to yield."""
nonlocal message_started
if message_started:
return []
message_started = True
return [_emit(_message_start_event(usage))]
def _build_error_event(error_type: str, message: str) -> ErrorEvent:
return ErrorEvent(
error=AnthropicError(type=error_type, message=message),
)
def _flush_on_error(error_type: str, message: str) -> list[str]:
"""Build a self-contained terminal SSE sequence on error.
Guarantees that whatever events we emit on the failure path
leave the wire in a valid state: message_start (if not yet
sent), close any open content block, then ErrorEvent and
MessageStopEvent. Strict SDK clients reject streams whose
content_block_start has no matching content_block_stop, so
the close step is mandatory even on the error path.
"""
frames: list[str] = []
frames.extend(_ensure_message_started(None))
for event in _close_content_block_events():
frames.append(_emit(event))
frames.append(_emit(_build_error_event(error_type, message)))
frames.append(_emit(MessageStopEvent()))
return frames
def _parse_upstream_error(data_str: str) -> Optional[tuple[str, str]]:
"""Detect an OpenAI handler streaming-error envelope.
``OpenAIServingChat.create_streaming_error_response`` emits
``data: {"error": {"object":"error","message":"...",
"type":"BadRequestError","code":400}}``; the regular
ChatCompletionStreamResponse validator rejects it. Pull the
type/message out so the Anthropic client sees the real
failure instead of a generic 'Stream processing error'.
"""
try:
payload = json.loads(data_str)
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(payload, dict):
return None
err = payload.get("error")
if not isinstance(err, dict):
return None
upstream_message = err.get("message") or "Upstream error"
code = err.get("code")
error_type = (
ERROR_TYPE_MAP.get(code, "api_error")
if isinstance(code, int)
else "api_error"
)
return error_type, str(upstream_message)
# Pre-first-chunk errors from the OpenAI generator (e.g. tokenization
# failure that raises ValueError before any chunk is yielded) would
# otherwise abort the StreamingResponse with no envelope at all and
# the client would see a half-open SSE / TCP close. Catch them here
# and emit a clean Anthropic error sequence instead.
try:
stream_iter = openai_stream.__aiter__()
except Exception as e:
logger.exception("Failed to open OpenAI stream: %s", e)
for frame in _flush_on_error("api_error", "Internal server error"):
yield frame
return
while True:
try:
sse_line = await stream_iter.__anext__()
except StopAsyncIteration:
break
except asyncio.CancelledError:
raise
except ValueError as e:
# _generate_chat_stream re-raises ValueError when its own
# ``stream_started`` flag is still False — surface as a
# proper Anthropic error event rather than aborting the
# StreamingResponse generator.
logger.warning("OpenAI stream raised before first chunk: %s", e)
for frame in _flush_on_error(
"invalid_request_error", str(e) or "Request failed"
):
yield frame
return
except Exception as e:
logger.exception("OpenAI stream raised mid-flight: %s", e)
for frame in _flush_on_error("api_error", "Internal server error"):
yield frame
return
if not sse_line.startswith("data: "):
continue
data_str = sse_line[6:].strip()
if data_str == "[DONE]":
for frame in _ensure_message_started(None):
yield frame
# No content AND no finish_reason: the backend dropped the
# stream silently. Surface as api_error so clients see the
# failure instead of a fake empty success. If finish_reason
# IS set we trust the backend's signal — a legitimate empty
# completion (max_tokens=1 stop, content filter, etc.)
# deserves a normal message_delta/message_stop pair, not
# an error that triggers SDK retry loops.
if not had_content_delta and finish_reason is None:
logger.warning(
"Stream produced no content and no finish_reason "
"before [DONE]; emitting api_error event"
)
yield _emit(
_build_error_event("api_error", "Backend produced no content")
)
yield _emit(MessageStopEvent())
continue
# Close any open content block
for event in _close_content_block_events():
yield _emit(event)
# Emit message_delta with stop_reason and usage
effective_finish = finish_reason or "stop"
if effective_finish not in STOP_REASON_MAP:
logger.warning(
"Unmapped streaming finish_reason %r; defaulting "
"to end_turn",
effective_finish,
)
stop_reason = STOP_REASON_MAP.get(effective_finish, "end_turn")
yield _emit(
MessageDeltaEvent(
delta=AnthropicMessageEndDelta(stop_reason=stop_reason),
usage=final_usage or AnthropicUsage(output_tokens=0),
)
)
yield _emit(MessageStopEvent())
continue
# Parse the OpenAI chunk
try:
chunk = ChatCompletionStreamResponse.model_validate_json(data_str)
except (ValidationError, json.JSONDecodeError, UnicodeDecodeError) as e:
# First check whether this is the OpenAI handler's
# streaming error envelope (validator rejects it because
# it lacks id/choices/created/model). Forwarding the real
# type/message keeps the failure debuggable instead of
# collapsing every backend error into "Stream processing
# error".
upstream = _parse_upstream_error(data_str)
if upstream is not None:
error_type, error_message = upstream
logger.warning(
"Forwarding upstream stream error (%s): %s",
error_type,
error_message,
)
for frame in _flush_on_error(error_type, error_message):
yield frame
return
logger.warning(
"Failed to parse Anthropic stream chunk (%s): %s",
type(e).__name__,
data_str[:200],
)
for frame in _flush_on_error("api_error", "Stream processing error"):
yield frame
return
if chunk.usage is not None:
final_usage = _anthropic_usage_from_openai(
chunk.usage,
include_input=False,
include_output=True,
)
# Usage-only chunk (empty choices with usage info)
if not chunk.choices and chunk.usage:
continue
if not chunk.choices:
continue
choice = chunk.choices[0]
# Capture finish_reason on this chunk but DO NOT short-circuit:
# some OpenAI-compatible backends pack the final content token
# (or last tool-args fragment) into the same chunk as
# finish_reason. Skipping delta processing would silently drop
# that payload — sometimes the whole completion if it was a
# one-token reply. Fall through to the delta handlers below.
if choice.finish_reason is not None:
finish_reason = choice.finish_reason
delta = choice.delta
# Defer message_start until the first chunk carrying real prompt
# usage or content. OpenAI streams emit a role-only chunk before
# usage is available; emitting message_start there would ship
# input_tokens=0 to the client.
has_delta_payload = bool(
delta.reasoning_content
or delta.tool_calls
or (delta.content is not None and delta.content != "")
or chunk.usage
)
# The finish_reason chunk should also flip message_started so a
# zero-content completion (the path that previously fired the
# 'Backend produced no content' error) emits the standard
# message_start before [DONE] closes the stream.
if (
has_delta_payload or choice.finish_reason is not None
) and not message_started:
yield _emit(_message_start_event(chunk.usage))
message_started = True
if (
not has_delta_payload
and delta.role == "assistant"
and (delta.content is None or delta.content == "")
):
continue
# Handle reasoning content deltas
if delta.reasoning_content:
for event in _ensure_content_block_events(
"thinking",
ThinkingBlock(thinking=""),
):
yield _emit(event)
yield _emit(
ContentBlockDeltaEvent(
index=content_block_index,
delta=ThinkingDelta(thinking=delta.reasoning_content),
)
)
had_content_delta = True
# Handle tool call deltas
if delta.tool_calls:
for tc in delta.tool_calls:
tc_id = tc.id
tc_func = tc.function
# New tool call: always close the previous block (even if
# it was also tool_use — each tool needs its own index)
# and start a fresh one.
if tc_func and tc_func.name:
for event in _ensure_content_block_events(
"tool_use",
ToolUseBlock(
id=tc_id or f"toolu_{uuid.uuid4().hex}",
name=tc_func.name,
input={},
),
force_new=True,
):
yield _emit(event)
# A zero-argument tool call may never emit an
# input_json_delta; the tool_use start block itself is
# still meaningful content because it carries id/name.
had_content_delta = True
if tc_func.arguments:
yield _emit(
ContentBlockDeltaEvent(
index=content_block_index,
delta=InputJsonDelta(
partial_json=tc_func.arguments,
),
)
)
had_content_delta = True
elif tc_func and tc_func.arguments:
# Continuing arguments for current tool call
if content_block_type != "tool_use":
logger.warning(
"Dropping tool_call argument delta with no "
"open tool_use block: %r",
(tc_func.arguments or "")[:100],
)
continue
yield _emit(
ContentBlockDeltaEvent(
index=content_block_index,
delta=InputJsonDelta(
partial_json=tc_func.arguments,
),
)
)
had_content_delta = True
# Handle text content deltas
if delta.content is not None and delta.content != "":
for event in _ensure_content_block_events(
"text",
TextBlock(text=""),
):
yield _emit(event)
yield _emit(
ContentBlockDeltaEvent(
index=content_block_index,
delta=TextDelta(text=delta.content),
)
)
had_content_delta = True
def _convert_response(
self, response: ChatCompletionResponse
) -> AnthropicMessagesResponse:
"""Convert an OpenAI ChatCompletionResponse to an Anthropic Messages response."""
if not response.choices:
return AnthropicMessagesResponse(
content=[TextBlock(text="")],
model=response.model,
stop_reason="end_turn",
usage=AnthropicUsage(input_tokens=0, output_tokens=0),
)
choice = response.choices[0]
content: list[AnthropicContentBlock] = []
# Add reasoning content as a thinking block. signature is omitted
# entirely when the backend doesn't provide one — empty strings
# would fail downstream Anthropic signature verifiers.
if choice.message.reasoning_content:
content.append(ThinkingBlock(thinking=choice.message.reasoning_content))
# Add text content
if choice.message.content:
content.append(TextBlock(text=choice.message.content))
# Add tool calls
if choice.message.tool_calls:
for tool_call in choice.message.tool_calls:
raw_args = tool_call.function.arguments
try:
tool_input = json.loads(raw_args)
except (json.JSONDecodeError, TypeError):
# Surface invalid tool arguments so an empty-dict
# tool call is never indistinguishable from a real
# one when something downstream goes wrong.
logger.warning(
"Tool %r emitted invalid JSON arguments: %r — "
"defaulting to empty input",
tool_call.function.name,
(raw_args or "")[:200],
)
tool_input = {}
content.append(
ToolUseBlock(
id=tool_call.id,
name=tool_call.function.name,
input=tool_input,
)
)
# Map stop reason
finish_reason = choice.finish_reason or "stop"
if finish_reason not in STOP_REASON_MAP:
logger.warning(
"Unmapped OpenAI finish_reason %r; defaulting to end_turn",
finish_reason,
)
stop_reason = STOP_REASON_MAP.get(finish_reason, "end_turn")
# Anthropic requires ``content`` to contain at least one block.
# Empty string completions (max_tokens=1 stop, content filter, etc.)
# would otherwise ship ``content=[]`` and break strict SDK parsers.
if not content:
content.append(TextBlock(text=""))
return AnthropicMessagesResponse(
id=f"msg_{uuid.uuid4().hex}",
content=content,
model=response.model,
stop_reason=stop_reason,
usage=_anthropic_usage_from_openai(
response.usage,
include_input=True,
include_output=True,
),
)
def _convert_openai_error_response(self, response) -> JSONResponse:
"""Forward an upstream OpenAI-handler error as an Anthropic error.
The original error message is preserved for 4xx (after light
sanitization) so callers see the real validation failure. For 5xx
we always return a generic ``"Internal server error"`` to avoid
leaking ``str(e)`` payloads that the OpenAI handler builds from
raw exceptions (paths, tracebacks, prompt fragments, etc.).
"""
status_code = getattr(response, "status_code", 500)
body = getattr(response, "body", b"") or b""
error_type = ERROR_TYPE_MAP.get(status_code, "api_error")
upstream_message: Optional[str] = None
try:
payload = json.loads(body.decode("utf-8")) if body else None
except (json.JSONDecodeError, UnicodeDecodeError):
# Non-JSON body (HTML gateway error, plain text, ...). Use a
# bounded slice of the raw body so the client still has a
# useful hint instead of a generic placeholder.
try:
upstream_message = body.decode("utf-8", errors="replace")[:500]
except Exception:
upstream_message = None
else:
if isinstance(payload, dict):
error_payload = payload.get("error", payload)
if isinstance(error_payload, dict):
upstream_message = error_payload.get("message") or payload.get(
"message"
)
# Honor the upstream error.type only for 4xx; 5xx is
# normalized below.
if status_code < 500:
upstream_type = error_payload.get("type")
if isinstance(upstream_type, str) and upstream_type:
error_type = upstream_type
elif isinstance(error_payload, str):
upstream_message = error_payload
elif isinstance(payload.get("message"), str):
upstream_message = payload["message"]
message = _scrub_error_message(upstream_message or "", status_code)
return self._error_response(
status_code=status_code,
error_type=error_type,
message=message,
)
def _error_response(
self,
status_code: int,
error_type: str,
message: str,
exception_name: Optional[str] = None,
) -> JSONResponse:
"""Create an Anthropic-format error response.
``error.type`` is restricted to Anthropic's documented enum so strict
SDK clients (anthropic-sdk-python / -typescript) keep parsing the
response into their typed error classes. ``exception_name`` — when
provided — is logged at WARNING level so operators can still grep
server-side, but it never reaches the wire.
"""
if exception_name:
logger.warning(
"Anthropic error response %s (exception=%s): %s",
error_type,
exception_name,
message,
)
error_resp = AnthropicErrorResponse(
error=AnthropicError(type=error_type, message=message)
)
return JSONResponse(
status_code=status_code,
content=error_resp.model_dump(),
)
async def handle_count_tokens(
self,
request: AnthropicCountTokensRequest,
raw_request: Request,
) -> JSONResponse:
"""Handle /v1/messages/count_tokens endpoint.
Converts the request to a ChatCompletionRequest, applies the chat
template via the OpenAI handler to tokenize, and returns the count.
"""
try:
# Build a minimal AnthropicMessagesRequest so we can reuse conversion
messages_request = AnthropicMessagesRequest(
model=request.model,
messages=request.messages,
max_tokens=1, # dummy, not used for counting
system=request.system,
thinking=request.thinking,
tools=request.tools,
tool_choice=request.tool_choice,
)
chat_request = self._convert_to_chat_completion_request(messages_request)
except asyncio.CancelledError:
raise
except Exception as e:
logger.exception("Error converting count_tokens request: %s", e)
return self._error_response(
status_code=400,
error_type="invalid_request_error",
message=str(e),
)
try:
is_multimodal = (
self.openai_serving_chat.tokenizer_manager.model_config.is_multimodal
)
processed = self.openai_serving_chat._process_messages(
chat_request, is_multimodal
)
if isinstance(processed.prompt_ids, list):
input_tokens = len(processed.prompt_ids)
else:
# prompt_ids is a string (multimodal case) — tokenize it
tokenizer = self.openai_serving_chat.tokenizer_manager.tokenizer
input_tokens = len(tokenizer.encode(processed.prompt_ids))
return JSONResponse(
content=AnthropicCountTokensResponse(
input_tokens=input_tokens
).model_dump()
)
except asyncio.CancelledError:
raise
except Exception as e:
logger.exception("Error counting tokens: %s", e)
return self._error_response(
status_code=500,
error_type="api_error",
message="Internal server error",
exception_name=type(e).__name__,
)