Files
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

1250 lines
47 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
Utility functions for Anthropic Messages API conversion.
Handles conversion between Anthropic API format and internal oMLX format.
"""
import base64
import json
import logging
import uuid
from typing import Any
from .anthropic_models import (
AnthropicMessage,
AnthropicTool,
AnthropicUsage,
ContentBlockText,
ContentBlockThinking,
ContentBlockToolUse,
MessagesRequest,
MessagesResponse,
SystemContent,
)
from .openai_models import ToolCall
_PRESERVE_ROLE_BOUNDARY = "_preserve_role_boundary"
logger = logging.getLogger(__name__)
def request_has_cache_control(request: MessagesRequest) -> bool:
"""True if any system / tool / message block carries ``cache_control``.
Anthropic's three input-side usage fields (``input_tokens``,
``cache_creation_input_tokens``, ``cache_read_input_tokens``) form a
*disjoint* partition of the prompt only when the client explicitly
marks a region with ``cache_control``. Without that signal the cache
fields must stay at 0 and ``input_tokens`` carries the whole prompt
count — independent of whether the oMLX engine happens to run
automatic prefix caching internally.
"""
sys = request.system
if isinstance(sys, list):
for blk in sys:
if getattr(blk, "cache_control", None):
return True
for tool in request.tools or []:
if getattr(tool, "cache_control", None):
return True
for msg in request.messages:
content = msg.content
if not isinstance(content, list):
continue
for blk in content:
if getattr(blk, "cache_control", None):
return True
return False
def _decode_document_block(block_dict: dict[str, Any]) -> str:
"""Decode an Anthropic document content block to text.
For text/plain documents, decodes base64 data and returns the text.
For other media types (e.g. PDF), returns a placeholder message since
oMLX does not provide document parsing.
"""
source = block_dict.get("source", {})
media_type = source.get("media_type", "")
data = source.get("data", "")
title = block_dict.get("title", "")
if media_type == "text/plain" and data:
try:
decoded = base64.b64decode(data).decode("utf-8")
label = f"[Document: {title}]\n" if title else ""
return f"{label}{decoded}"
except Exception:
return f"[Document: {title or 'untitled'} — failed to decode]"
label = title or "untitled"
return (
f"[Document: {label} ({media_type}) — "
f"oMLX does not provide PDF parsing. Send as text instead.]"
)
def _content_block_to_dict(block: Any) -> dict[str, Any] | None:
"""Normalize Anthropic content blocks to dicts."""
if hasattr(block, "model_dump"):
return block.model_dump()
if isinstance(block, dict):
return block
return None
def _append_anthropic_image_part(
image_parts: list[dict], block_dict: dict[str, Any]
) -> None:
"""Convert Anthropic image blocks to OpenAI-style image_url parts."""
source = block_dict.get("source", {})
if source.get("type") == "base64":
media_type = source.get("media_type", "image/jpeg")
data = source.get("data", "")
image_parts.append(
{
"type": "image_url",
"image_url": {
"url": f"data:{media_type};base64,{data}",
},
}
)
elif source.get("type") == "url":
image_parts.append(
{
"type": "image_url",
"image_url": {
"url": source.get("url", ""),
},
}
)
def _append_anthropic_audio_part(
audio_parts: list[dict], block_dict: dict[str, Any]
) -> None:
"""Pass through an input_audio block unchanged for the VLM engine."""
input_audio = block_dict.get("input_audio")
if input_audio and isinstance(input_audio, dict):
audio_parts.append(
{
"type": "input_audio",
"input_audio": input_audio,
}
)
def _extract_images_from_tool_result_content(
content: Any, image_parts: list[dict]
) -> None:
"""Extract image blocks from tool result content for VLM processing."""
if isinstance(content, list):
for item in content:
if isinstance(item, dict) and item.get("type") == "image":
_append_anthropic_image_part(image_parts, item)
elif isinstance(content, dict) and content.get("type") == "image":
_append_anthropic_image_part(image_parts, content)
def _build_message_from_parts(
role: str,
text_parts: list[str],
image_parts: list[dict],
audio_parts: list[dict] | None = None,
) -> dict[str, Any] | None:
"""Build a single internal message from accumulated text/image/audio parts."""
media_parts = list(image_parts)
if audio_parts:
media_parts.extend(audio_parts)
if media_parts:
content_parts = list(media_parts)
if text_parts:
content_parts.append(
{
"type": "text",
"text": "\n".join(text_parts),
}
)
return {"role": role, "content": content_parts}
if text_parts:
return {"role": role, "content": "\n".join(text_parts)}
return None
# =============================================================================
# Message Conversion: Anthropic -> Internal
# =============================================================================
def convert_anthropic_to_internal(
request: MessagesRequest,
max_tool_result_tokens: int | None = None,
tokenizer: Any | None = None,
preserve_images: bool = False,
native_reasoning_content: bool = False,
consolidate_system_messages: bool = True,
) -> list[dict[str, Any]]:
"""
Convert Anthropic Messages API format to internal format.
Handles:
- System message from separate 'system' field
- Content blocks to text
- Tool results and tool uses in message history
- Image blocks (when preserve_images=True for VLM)
Args:
request: Anthropic MessagesRequest
max_tool_result_tokens: Maximum token count for tool results.
tokenizer: Tokenizer instance for token counting and truncation.
preserve_images: If True, preserve image blocks as OpenAI image_url
format for VLM processing.
native_reasoning_content: If True, attach Anthropic ``thinking`` blocks
as a ``reasoning_content`` field on assistant messages (Qwen 3.6+
templates). If False, inline each block as ``<think>...</think>``
in the message content as a fallback.
consolidate_system_messages: If True, merge inline system messages into
the leading system block. Server code can set this to False and let
template capability probing decide whether mid-system messages can
be preserved.
Returns:
List of {"role": str, "content": str or list}
"""
from .utils import _chat_template_supports_tool_role
processed_messages: list[dict[str, Any]] = []
native_tool_calling = bool(
tokenizer and _chat_template_supports_tool_role(tokenizer)
)
# Normalize: extract any role="system" entries from messages[] and merge
# with the canonical request.system field (claude-code 2.1.154+ sends
# system content inline instead of using the separate field).
system_text, normalized_messages = _normalize_in_messages_system(
request,
consolidate_system_messages=consolidate_system_messages,
)
if system_text:
processed_messages.append({"role": "system", "content": system_text})
# Process messages
for msg in normalized_messages:
role = msg.role
content = msg.content
if isinstance(content, str):
# Simple text message
processed_messages.append({"role": role, "content": content})
elif isinstance(content, list):
if native_tool_calling:
if role == "assistant":
text_parts: list[str] = []
image_parts: list[dict] = []
audio_parts: list[dict] = []
tool_calls: list[dict] = []
thinking_parts: list[str] = []
for block in content:
block_dict = _content_block_to_dict(block)
if block_dict is None:
continue
block_type = block_dict.get("type", "")
if block_type == "text":
text_parts.append(block_dict.get("text", ""))
elif block_type == "image" and preserve_images:
_append_anthropic_image_part(image_parts, block_dict)
elif block_type == "input_audio" and preserve_images:
_append_anthropic_audio_part(audio_parts, block_dict)
elif block_type == "tool_use":
tool_input = block_dict.get("input", {})
if isinstance(tool_input, str):
try:
tool_input = json.loads(tool_input)
except (json.JSONDecodeError, ValueError):
pass
tool_calls.append(
{
"id": block_dict.get(
"id", f"call_{uuid.uuid4().hex[:8]}"
),
"function": {
"name": block_dict.get("name", ""),
"arguments": tool_input,
},
}
)
elif block_type == "thinking":
# Native mode: collect for reasoning_content field.
# Fallback: inline as <think>...</think> in source
# order (Anthropic emits thinking first, so appending
# preserves the natural ordering).
thinking_text = block_dict.get("thinking", "")
if thinking_text:
if native_reasoning_content:
thinking_parts.append(thinking_text)
else:
text_parts.append(
f"<think>\n{thinking_text}\n</think>"
)
elif block_type == "document":
text_parts.append(_decode_document_block(block_dict))
msg_dict = _build_message_from_parts(
role, text_parts, image_parts, audio_parts
) or {
"role": role,
"content": "",
}
if thinking_parts:
msg_dict["reasoning_content"] = "\n".join(thinking_parts)
if tool_calls:
msg_dict["tool_calls"] = tool_calls
msg_dict[_PRESERVE_ROLE_BOUNDARY] = True
processed_messages.append(msg_dict)
continue
if role == "user":
text_parts = []
image_parts = []
audio_parts = []
saw_tool_result = False
for block in content:
block_dict = _content_block_to_dict(block)
if block_dict is None:
continue
block_type = block_dict.get("type", "")
if block_type == "text":
text_parts.append(block_dict.get("text", ""))
elif block_type == "image" and preserve_images:
_append_anthropic_image_part(image_parts, block_dict)
elif block_type == "input_audio" and preserve_images:
_append_anthropic_audio_part(audio_parts, block_dict)
elif block_type == "tool_result":
msg_dict = _build_message_from_parts(
role, text_parts, image_parts, audio_parts
)
if msg_dict:
processed_messages.append(msg_dict)
text_parts = []
image_parts = []
audio_parts = []
saw_tool_result = True
processed_messages.append(
{
"role": "tool",
"tool_call_id": block_dict.get("tool_use_id", ""),
"content": _extract_tool_result_content(
block_dict.get("content", ""),
max_tokens=max_tool_result_tokens,
tokenizer=tokenizer,
),
}
)
if preserve_images:
_extract_images_from_tool_result_content(
block_dict.get("content", ""), image_parts
)
elif block_type == "thinking":
# User messages don't carry reasoning_content in the
# Qwen 3.6 template, so native mode simply drops these
# blocks. Fallback keeps the legacy <think> inline
# behaviour in source order.
thinking_text = block_dict.get("thinking", "")
if thinking_text and not native_reasoning_content:
text_parts.append(f"<think>\n{thinking_text}\n</think>")
elif block_type == "document":
text_parts.append(_decode_document_block(block_dict))
msg_dict = _build_message_from_parts(
role, text_parts, image_parts, audio_parts
)
if msg_dict:
processed_messages.append(msg_dict)
elif not saw_tool_result:
processed_messages.append({"role": role, "content": ""})
continue
# Content blocks list
text_parts: list[str] = []
image_parts: list[dict] = []
audio_parts: list[dict] = []
thinking_parts: list[str] = []
saw_tool_markup = False
for block in content:
block_dict = _content_block_to_dict(block)
if block_dict is None:
continue
block_type = block_dict.get("type", "")
if block_type == "text":
text_parts.append(block_dict.get("text", ""))
elif block_type == "image" and preserve_images:
_append_anthropic_image_part(image_parts, block_dict)
elif block_type == "input_audio" and preserve_images:
_append_anthropic_audio_part(audio_parts, block_dict)
elif block_type == "tool_use":
# Tool use in assistant message (model called a tool)
tool_name = block_dict.get("name", "")
tool_input = block_dict.get("input", {})
text_parts.append(
f"[Calling tool: {tool_name}({json.dumps(tool_input)})]"
)
saw_tool_markup = True
elif block_type == "tool_result":
# Tool result in user message (user providing tool output)
tool_use_id = block_dict.get("tool_use_id", "")
result_content = _extract_tool_result_content(
block_dict.get("content", ""),
max_tokens=max_tool_result_tokens,
tokenizer=tokenizer,
)
is_error = block_dict.get("is_error", False)
prefix = "[Tool Error" if is_error else "[Tool Result"
text_parts.append(f"{prefix} ({tool_use_id})]: {result_content}")
saw_tool_markup = True
if preserve_images:
_extract_images_from_tool_result_content(
block_dict.get("content", ""), image_parts
)
elif block_type == "thinking":
# Native mode: collect for reasoning_content (assistant only).
# Fallback: inline as <think>...</think> in source order.
thinking_text = block_dict.get("thinking", "")
if thinking_text:
if native_reasoning_content and role == "assistant":
thinking_parts.append(thinking_text)
elif not native_reasoning_content:
text_parts.append(f"<think>\n{thinking_text}\n</think>")
elif block_type == "document":
text_parts.append(_decode_document_block(block_dict))
msg_dict = _build_message_from_parts(
role, text_parts, image_parts, audio_parts
) or {
"role": role,
"content": "",
}
if thinking_parts:
msg_dict["reasoning_content"] = "\n".join(thinking_parts)
if saw_tool_markup:
msg_dict[_PRESERVE_ROLE_BOUNDARY] = True
processed_messages.append(msg_dict)
else:
# Unknown format
processed_messages.append({"role": role, "content": str(content)})
from .utils import _merge_consecutive_roles
return _merge_consecutive_roles(processed_messages)
def convert_anthropic_to_internal_harmony(
request: MessagesRequest,
max_tool_result_tokens: int | None = None,
tokenizer: Any | None = None,
consolidate_system_messages: bool = True,
) -> list[dict[str, Any]]:
"""
Convert Anthropic Messages API format to internal format for Harmony (gpt-oss) models.
Unlike convert_anthropic_to_internal(), this function preserves:
- tool_use blocks as assistant.tool_calls field
- tool_result blocks as role="tool" messages
The Harmony chat_template expects these fields to properly generate
the Harmony format tool calling syntax.
Args:
request: Anthropic MessagesRequest
Returns:
List of message dicts with tool-related fields preserved
"""
processed_messages: list[dict[str, Any]] = []
# Normalize: extract any role="system" entries from messages[] and merge
# with the canonical request.system field (claude-code 2.1.154+ sends
# system content inline instead of using the separate field).
system_text, normalized_messages = _normalize_in_messages_system(
request,
consolidate_system_messages=consolidate_system_messages,
)
if system_text:
processed_messages.append({"role": "system", "content": system_text})
# Process messages
for msg in normalized_messages:
role = msg.role
content = msg.content
if isinstance(content, str):
# Simple text message
processed_messages.append({"role": role, "content": content})
elif isinstance(content, list):
# Content blocks list - need to separate tool_use, tool_result, and text
text_parts: list[str] = []
tool_calls: list[dict] = []
tool_results: list[dict] = []
for block in content:
# Handle both Pydantic models and dicts
if hasattr(block, "model_dump"):
block_dict = block.model_dump()
elif isinstance(block, dict):
block_dict = block
else:
continue
block_type = block_dict.get("type", "")
if block_type == "text":
text_parts.append(block_dict.get("text", ""))
elif block_type == "tool_use":
# Tool use in assistant message - preserve as tool_calls
tool_id = block_dict.get("id", f"call_{uuid.uuid4().hex[:8]}")
tool_name = block_dict.get("name", "")
tool_input = block_dict.get("input", {})
# input should be dict for chat_template |tojson
if isinstance(tool_input, str):
try:
tool_input = json.loads(tool_input)
except (json.JSONDecodeError, ValueError):
pass
tool_calls.append(
{
"id": tool_id,
"function": {
"name": tool_name,
"arguments": tool_input, # dict, not string
},
}
)
elif block_type == "tool_result":
# Tool result - will be converted to role="tool" message
tool_use_id = block_dict.get("tool_use_id", "")
result_content = block_dict.get("content", "")
if isinstance(result_content, str):
# Try JSON parse BEFORE truncation so we can pretty-print
parsed_json = None
try:
parsed_json = json.loads(result_content)
except (json.JSONDecodeError, ValueError):
pass
if (
parsed_json is not None
and max_tool_result_tokens
and tokenizer
):
# Valid JSON - pretty-print for better line-based truncation
pretty = json.dumps(
parsed_json, indent=2, ensure_ascii=False
)
truncated = truncate_tool_result(
pretty, max_tool_result_tokens, tokenizer
)
if "<truncated " in truncated:
# Truncation broke JSON - wrap in dict for
# Harmony |tojson compatibility
from .utils import _wrap_truncated_for_harmony
result_content = _wrap_truncated_for_harmony(truncated)
else:
# Not truncated - pass as parsed object
result_content = parsed_json
elif parsed_json is not None:
# Valid JSON, no truncation configured - pass as parsed object
result_content = parsed_json
else:
# Not JSON - apply truncation to raw text
result_content = _extract_tool_result_content(
result_content,
max_tokens=max_tool_result_tokens,
tokenizer=tokenizer,
)
elif isinstance(result_content, list):
# Extract text from content blocks
extracted = _extract_tool_result_content(
result_content,
max_tokens=max_tool_result_tokens,
tokenizer=tokenizer,
)
# Only try json.loads if content was NOT truncated
if (
isinstance(extracted, str)
and "<truncated " not in extracted
):
try:
result_content = json.loads(extracted)
except (json.JSONDecodeError, ValueError):
result_content = extracted
elif isinstance(extracted, str) and "<truncated " in extracted:
# Check if pre-truncation content was JSON-like
content_part = extracted.split("\n\n<truncated")[0].strip()
if content_part and content_part[0] in "{[":
from .utils import _wrap_truncated_for_harmony
result_content = _wrap_truncated_for_harmony(extracted)
else:
result_content = extracted
else:
result_content = extracted
tool_results.append(
{
"tool_use_id": tool_use_id,
"content": result_content,
}
)
elif block_type == "thinking":
# Thinking blocks are ignored (reasoning content is not passed to model)
continue
elif block_type == "document":
text_parts.append(_decode_document_block(block_dict))
# Build message(s) based on what we found
if role == "assistant":
# Assistant message with potential tool_calls
msg_dict = {
"role": "assistant",
"content": "\n".join(text_parts) if text_parts else "",
}
if tool_calls:
msg_dict["tool_calls"] = tool_calls
processed_messages.append(msg_dict)
elif role == "user":
# User message - may contain tool_results
# First add any text content
if text_parts:
processed_messages.append(
{"role": "user", "content": "\n".join(text_parts)}
)
# Add each tool_result as a separate role="tool" message
for tr in tool_results:
processed_messages.append(
{
"role": "tool",
"tool_call_id": tr["tool_use_id"],
"content": tr["content"], # dict or string
}
)
else:
# Other roles
processed_messages.append(
{
"role": role,
"content": "\n".join(text_parts) if text_parts else "",
}
)
else:
# Unknown format
processed_messages.append({"role": role, "content": str(content)})
from .utils import _merge_consecutive_roles
return _merge_consecutive_roles(processed_messages)
# Prefix to filter out from system blocks (billing metadata that
# contains randomly changing values, breaking prefix cache).
_BILLING_HEADER_PREFIX = "x-anthropic-billing-header:"
def _extract_system_text(system: str | list[SystemContent]) -> str:
"""Extract text from system field."""
if isinstance(system, str):
return system
elif isinstance(system, list):
text_parts = []
for block in system:
if hasattr(block, "text"):
text = block.text
elif isinstance(block, dict) and block.get("type") == "text":
text = block.get("text", "")
else:
continue
# Skip billing header blocks (contain random values that break prefix cache)
if text.startswith(_BILLING_HEADER_PREFIX):
continue
text_parts.append(text)
return "\n".join(text_parts)
return ""
def _normalize_in_messages_system(
request: MessagesRequest,
*,
consolidate_system_messages: bool = True,
) -> tuple[str, list[AnthropicMessage]]:
"""Extract role="system" entries from messages[] and merge with request.system.
Claude Code 2.1.154+ began sending system content inline in the messages
array instead of (or in addition to) the canonical Anthropic ``system``
field. Returns the combined system text and the message list with system
entries removed, so downstream conversion sees the canonical shape.
"""
if not consolidate_system_messages:
base = _extract_system_text(request.system) if request.system else ""
return base, list(request.messages)
extracted_parts: list[str] = []
filtered_messages: list[AnthropicMessage] = []
for msg in request.messages:
if msg.role != "system":
filtered_messages.append(msg)
continue
content = msg.content
if isinstance(content, str):
if content:
extracted_parts.append(content)
elif isinstance(content, list):
for block in content:
block_dict = _content_block_to_dict(block)
if block_dict is None:
continue
if block_dict.get("type") == "text":
text = block_dict.get("text", "")
if text:
extracted_parts.append(text)
base = _extract_system_text(request.system) if request.system else ""
if extracted_parts:
extra = "\n".join(extracted_parts)
system_text = "\n\n".join(p for p in (base, extra) if p)
else:
system_text = base
return system_text, filtered_messages
def truncate_tool_result(
text: str,
max_tokens: int,
tokenizer: Any,
) -> str:
"""Truncate tool result text to fit within a token budget.
Strategy:
1. Encode the full text to count tokens.
2. If within budget, return as-is.
3. Decode tokens up to the budget to get an approximate character position.
4. Search backwards for the last newline to truncate at a line boundary.
5. Append a truncation notice as a separate XML tag.
Args:
text: The full tool result text.
max_tokens: Maximum number of tokens allowed.
tokenizer: Tokenizer with encode()/decode() methods.
Returns:
The (possibly truncated) text with notice appended.
"""
token_ids = tokenizer.encode(text)
total_tokens = len(token_ids)
if total_tokens <= max_tokens:
return text
# Decode tokens up to budget to get approximate char position
truncated_text = tokenizer.decode(token_ids[:max_tokens])
# Find last newline for line-boundary truncation
last_newline = truncated_text.rfind("\n")
if last_newline > 0 and last_newline > len(truncated_text) * 0.5:
# Only use line boundary if we don't lose more than 50% of content
truncated_text = truncated_text[:last_newline]
# Recount actual tokens after line-boundary adjustment
shown_tokens = len(tokenizer.encode(truncated_text))
logger.info(
f"Tool result truncated: {total_tokens} -> {shown_tokens} tokens "
f"({len(text)} -> {len(truncated_text)} chars)"
)
notice = (
f'\n\n<truncated total_tokens="{total_tokens}" '
f'shown_tokens="{shown_tokens}" />'
)
return truncated_text + notice
def _extract_tool_result_content(
content: Any,
max_tokens: int | None = None,
tokenizer: Any | None = None,
) -> str:
"""Extract text from tool result content.
Args:
content: Raw tool result content (str, list, or dict).
max_tokens: Maximum token count for the result. If exceeded, content is truncated.
tokenizer: Tokenizer instance for token counting and truncation.
Returns:
Extracted text, potentially truncated if max_tokens is set.
"""
if isinstance(content, str):
result_text = content
elif isinstance(content, list):
# List of content blocks
text_parts = []
for item in content:
if isinstance(item, dict):
if item.get("type") == "text":
text_parts.append(item.get("text", ""))
elif isinstance(item, str):
text_parts.append(item)
result_text = "\n".join(text_parts)
elif isinstance(content, dict):
if content.get("type") == "text":
result_text = content.get("text", "")
else:
result_text = json.dumps(content)
else:
result_text = str(content)
# Truncate by token count if configured
if max_tokens and tokenizer and result_text:
result_text = truncate_tool_result(result_text, max_tokens, tokenizer)
elif max_tokens is not None:
logger.debug(
f"Tool result skip truncation: max_tokens={max_tokens}, "
f"has_tokenizer={tokenizer is not None}, "
f"result_len={len(result_text) if result_text else 0}"
)
return result_text
# =============================================================================
# Tool Conversion: Anthropic -> Internal
# =============================================================================
# Anthropic server-side tools (executed on Anthropic's infrastructure) carry a
# versioned ``type`` like ``web_search_20250305`` and have no ``input_schema``.
# oMLX cannot fulfill these locally, so we drop them before forwarding to the
# model. See https://docs.anthropic.com for the canonical tool families.
SERVER_SIDE_TOOL_TYPE_PREFIXES = (
"web_search_",
"code_execution_",
"bash_",
"text_editor_",
"computer_",
)
def _is_server_side_tool(tool_dict: dict[str, Any]) -> bool:
"""Return True if the tool dict is an Anthropic server-side tool."""
tool_type = tool_dict.get("type")
if not isinstance(tool_type, str):
return False
return tool_type.startswith(SERVER_SIDE_TOOL_TYPE_PREFIXES)
def convert_anthropic_tools_to_internal(
tools: list[AnthropicTool] | None,
) -> list[dict[str, Any]] | None:
"""
Convert Anthropic tools to internal/OpenAI format.
Anthropic: {"name": "...", "description": "...", "input_schema": {...}}
Internal: {"type": "function", "function": {"name": "...", "description": "...", "parameters": {...}}}
Anthropic server-side tools (web_search, code_execution, bash, text_editor,
computer) cannot be executed by oMLX and are dropped with an INFO log.
Args:
tools: List of Anthropic tool definitions
Returns:
List of internal tool definitions, or None if no executable tools
"""
if not tools:
return None
internal_tools: list[dict[str, Any]] = []
dropped: list[str] = []
for tool in tools:
# Handle both Pydantic models and dicts
if hasattr(tool, "model_dump"):
tool_dict = tool.model_dump()
elif isinstance(tool, dict):
tool_dict = tool
else:
continue
if _is_server_side_tool(tool_dict):
dropped.append(f"{tool_dict.get('type')}:{tool_dict.get('name', '')}")
continue
internal_tools.append(
{
"type": "function",
"function": {
"name": tool_dict.get("name", ""),
"description": tool_dict.get("description", ""),
"parameters": tool_dict.get("input_schema") or {},
},
}
)
if dropped:
logger.info(
"Dropped %d Anthropic server-side tool(s) not executable by oMLX: %s",
len(dropped),
", ".join(dropped),
)
return internal_tools if internal_tools else None
# =============================================================================
# Response Conversion: Internal -> Anthropic
# =============================================================================
def convert_internal_to_anthropic_response(
text: str,
model: str,
prompt_tokens: int,
completion_tokens: int,
finish_reason: str | None,
tool_calls: list[ToolCall] | None = None,
thinking: str | None = None,
cached_tokens: int = 0,
request_uses_cache_control: bool = False,
) -> MessagesResponse:
"""
Convert internal output to Anthropic MessagesResponse.
When the request carries ``cache_control`` breakpoints (signalled by
``request_uses_cache_control``) the prompt count is split into
Anthropic's disjoint usage triple so that
``input_tokens + cache_creation_input_tokens + cache_read_input_tokens
== prompt_tokens``. Otherwise the response keeps the legacy shape
(``input_tokens = prompt_tokens``, both cache fields = 0) — even when
the engine's automatic prefix cache happened to hit, since Anthropic
only surfaces the cache triple when the client opted in.
Args:
text: Generated text content
model: Model name
prompt_tokens: Number of input tokens
completion_tokens: Number of output tokens
finish_reason: Internal finish reason ("stop", "length", "tool_calls")
tool_calls: List of internal ToolCall objects
thinking: Reasoning/thinking content from <think> blocks
cached_tokens: Prompt tokens served from the prefix cache
request_uses_cache_control: Whether the originating request carried
``cache_control`` on any system / tool / message block.
Returns:
Anthropic MessagesResponse
"""
content: list[ContentBlockText | ContentBlockToolUse | ContentBlockThinking] = []
# Add thinking content block before text if present.
# Anthropic's spec requires a non-empty cryptographic signature on
# thinking blocks; an empty string makes some SDK versions fall
# back to a text-block parser path and emit "Content block is not
# a text block". omlx cannot mint a real Anthropic signature, so
# we use a stable placeholder string. Clients that strictly verify
# the signature will still reject, but the common Claude Code SDK
# only checks that the field is present and non-empty.
if thinking and thinking.strip():
content.append(
ContentBlockThinking(
type="thinking",
thinking=thinking,
signature="omlx-reasoning",
)
)
# Add text content block if present and not empty
if text and text.strip():
content.append(ContentBlockText(type="text", text=text))
# Add tool_use blocks if present
if tool_calls:
for tc in tool_calls:
try:
# Parse arguments from JSON string
args = json.loads(tc.function.arguments)
except (json.JSONDecodeError, AttributeError):
args = {}
content.append(
ContentBlockToolUse(
type="tool_use",
id=tc.id,
name=tc.function.name,
input=args,
)
)
# Ensure at least one content block
if not content:
content.append(ContentBlockText(type="text", text=""))
# Map finish reason to stop reason
stop_reason = map_finish_reason_to_stop_reason(finish_reason, bool(tool_calls))
# Anthropic's three input-side fields are a disjoint partition of the
# prompt and only carry non-zero values when the request opted into
# caching via cache_control. Without that signal the cache fields stay
# at 0 regardless of any internal prefix-cache hits in the engine.
if request_uses_cache_control:
cache_read = max(0, min(cached_tokens, prompt_tokens))
cache_creation = prompt_tokens - cache_read
input_display = 0
else:
cache_read = 0
cache_creation = 0
input_display = prompt_tokens
return MessagesResponse(
id=f"msg_{uuid.uuid4().hex[:24]}",
type="message",
role="assistant",
model=model,
content=content,
stop_reason=stop_reason,
usage=AnthropicUsage(
input_tokens=input_display,
output_tokens=completion_tokens,
cache_creation_input_tokens=cache_creation,
cache_read_input_tokens=cache_read,
),
)
def map_finish_reason_to_stop_reason(
finish_reason: str | None, has_tool_calls: bool
) -> str | None:
"""
Map internal finish_reason to Anthropic stop_reason.
Internal: "stop", "length", "tool_calls"
Anthropic: "end_turn", "max_tokens", "stop_sequence", "tool_use"
Args:
finish_reason: Internal finish reason
has_tool_calls: Whether the response contains tool calls
Returns:
Anthropic stop_reason
"""
if has_tool_calls:
return "tool_use"
if finish_reason is None:
return None
mapping = {
"stop": "end_turn",
"length": "max_tokens",
"tool_calls": "tool_use",
}
return mapping.get(finish_reason, "end_turn")
# =============================================================================
# SSE Event Formatting
# =============================================================================
def format_sse_event(event_type: str, data: dict[str, Any]) -> str:
"""
Format an SSE event for Anthropic streaming.
Anthropic uses: "event: {type}\\ndata: {json}\\n\\n"
(Different from OpenAI which just uses "data: {json}\\n\\n")
Args:
event_type: Event type (message_start, content_block_delta, etc.)
data: Event data to serialize as JSON
Returns:
Formatted SSE event string
"""
return f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
def create_message_start_event(
message_id: str, model: str, input_tokens: int = 0
) -> str:
"""Create message_start SSE event."""
return format_sse_event(
"message_start",
{
"type": "message_start",
"message": {
"id": message_id,
"type": "message",
"role": "assistant",
"model": model,
"content": [],
"stop_reason": None,
"stop_sequence": None,
"usage": {"input_tokens": input_tokens, "output_tokens": 0},
},
},
)
def create_content_block_start_event(index: int, block_type: str, **kwargs) -> str:
"""Create content_block_start SSE event."""
if block_type == "text":
content_block = {"type": "text", "text": ""}
elif block_type == "tool_use":
content_block = {
"type": "tool_use",
"id": kwargs.get("id", ""),
"name": kwargs.get("name", ""),
"input": {},
}
elif block_type == "thinking":
# Anthropic spec requires a signature field on thinking blocks
# (see convert_internal_to_anthropic_response for the rationale
# behind the placeholder string).
content_block = {
"type": "thinking",
"thinking": "",
"signature": "omlx-reasoning",
}
else:
content_block = {"type": block_type}
return format_sse_event(
"content_block_start",
{
"type": "content_block_start",
"index": index,
"content_block": content_block,
},
)
def create_thinking_delta_event(index: int, thinking: str) -> str:
"""Create content_block_delta SSE event for thinking content."""
return format_sse_event(
"content_block_delta",
{
"type": "content_block_delta",
"index": index,
"delta": {"type": "thinking_delta", "thinking": thinking},
},
)
def create_text_delta_event(index: int, text: str) -> str:
"""Create content_block_delta SSE event for text."""
return format_sse_event(
"content_block_delta",
{
"type": "content_block_delta",
"index": index,
"delta": {"type": "text_delta", "text": text},
},
)
def create_input_json_delta_event(index: int, partial_json: str) -> str:
"""Create content_block_delta SSE event for tool input JSON."""
return format_sse_event(
"content_block_delta",
{
"type": "content_block_delta",
"index": index,
"delta": {"type": "input_json_delta", "partial_json": partial_json},
},
)
def create_content_block_stop_event(index: int) -> str:
"""Create content_block_stop SSE event."""
return format_sse_event(
"content_block_stop",
{
"type": "content_block_stop",
"index": index,
},
)
def create_message_delta_event(
stop_reason: str | None,
output_tokens: int,
stop_sequence: str | None = None,
input_tokens: int | None = None,
cached_tokens: int = 0,
request_uses_cache_control: bool = False,
) -> str:
"""Create message_delta SSE event.
When ``request_uses_cache_control`` is True and ``input_tokens`` is
given, the count is split into Anthropic's disjoint triple (input
stays 0, creation and read carry the remainder). Without that signal
the cache fields are omitted entirely — Anthropic only surfaces them
when the client opted in via a ``cache_control`` breakpoint, even if
the engine's automatic prefix cache happened to hit.
"""
usage: dict[str, int] = {"output_tokens": output_tokens}
if request_uses_cache_control and input_tokens is not None:
cache_read = max(0, min(cached_tokens, input_tokens))
usage["input_tokens"] = 0
usage["cache_creation_input_tokens"] = input_tokens - cache_read
usage["cache_read_input_tokens"] = cache_read
elif input_tokens is not None:
usage["input_tokens"] = input_tokens
return format_sse_event(
"message_delta",
{
"type": "message_delta",
"delta": {"stop_reason": stop_reason, "stop_sequence": stop_sequence},
"usage": usage,
},
)
def create_message_stop_event() -> str:
"""Create message_stop SSE event."""
return format_sse_event("message_stop", {"type": "message_stop"})
def create_ping_event() -> str:
"""Create ping SSE event."""
return format_sse_event("ping", {"type": "ping"})
def create_error_event(error_type: str, message: str) -> str:
"""Create error SSE event."""
return format_sse_event(
"error",
{
"type": "error",
"error": {"type": error_type, "message": message},
},
)