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

1448 lines
53 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Adapted from vllm-mlx (https://github.com/vllm-project/vllm-mlx).
"""
Utility functions for text processing.
"""
import json
import re
from typing import Any, List
from .openai_models import Message
# Model families whose chat templates consume message.reasoning_content directly.
_NATIVE_REASONING_MODEL_TYPES = {"minimax_m3", "minimax_m3_vl"}
def uses_native_reasoning_content(
model_name: str | None = None,
*,
config_model_type: str | None = None,
engine_model_type: str | None = None,
preserve_thinking_default: bool | None = None,
) -> bool:
"""Return whether history should keep reasoning in message fields."""
if preserve_thinking_default is True:
return True
if config_model_type in _NATIVE_REASONING_MODEL_TYPES:
return True
if engine_model_type in _NATIVE_REASONING_MODEL_TYPES:
return True
lowered = (model_name or "").lower()
return "minimax" in lowered and "m3" in lowered
# =============================================================================
# Partial Mode Detection
# =============================================================================
def detect_and_strip_partial(messages: list[dict]) -> bool:
"""Check if the final assistant message has partial=True; strip the field from all messages.
Partial mode signals that the model should continue from the final assistant
message rather than starting a new turn. The ``partial`` key is not part of
the chat-template contract, so it is always removed before the messages are
passed to ``apply_chat_template``.
Args:
messages: List of message dicts (mutated in-place).
Returns:
True if the final message is an assistant message with ``partial=True``.
"""
is_partial = (
bool(messages)
and messages[-1].get("role") == "assistant"
and messages[-1].get("partial", False)
)
for msg in messages:
msg.pop("partial", None)
return is_partial
# =============================================================================
# Special Token Patterns
# =============================================================================
# Pattern to match special tokens that should be removed from output
SPECIAL_TOKENS_PATTERN = re.compile(
r"<\|im_end\|>|<\|im_start\|>|<\|endoftext\|>|"
r"<\|end\|>|<\|eot_id\|>|<\|start_header_id\|>|<\|end_header_id\|>|"
r"<\|image\|>|<\|audio\|>|" # Gemma 4 VLM special tokens
r"\[e~\[|\]~b\]|\]~!b\[|\]!p~\[|\]!d~\[|" # MiniMax M3 special tokens
r"</s>|<s>|<pad>|\[PAD\]|\[SEP\]|\[CLS\]|"
r"<eos>|<bos>|<end_of_turn>|<start_of_turn>" # Gemma special tokens (fixes #1087)
)
def clean_special_tokens(text: str) -> str:
"""Clean model output by removing only special tokens.
Preserves <think>...</think> blocks for downstream processing.
Args:
text: Raw model output
Returns:
Text with special tokens removed but think tags preserved
"""
if not text:
return text
return SPECIAL_TOKENS_PATTERN.sub("", text).strip()
def remove_special_tokens_preserve_whitespace(text: str) -> str:
"""Remove special tokens without trimming surrounding whitespace."""
if not text:
return text
return SPECIAL_TOKENS_PATTERN.sub("", text)
def clean_output_text(text: str) -> str:
"""Clean model output by removing special tokens and thinking blocks.
Args:
text: Raw model output
Returns:
Cleaned text with special tokens and <think> blocks removed
"""
if not text:
return text
text = SPECIAL_TOKENS_PATTERN.sub("", text)
from .thinking import extract_thinking
_, content = extract_thinking(text)
return content.strip()
# =============================================================================
# Text Content Extraction
# =============================================================================
def _extract_text_from_content_list(content: list) -> str:
"""Extract text parts from a content array, dropping non-text items.
Handles content arrays from both OpenAI and Anthropic formats.
Only items with type="text" are extracted; all others (tool_use,
image, image_url, thinking, refusal, etc.) are silently dropped.
"""
text_parts = []
for item in content:
# Convert Pydantic models to dict
if hasattr(item, "model_dump"):
item = item.model_dump()
elif hasattr(item, "dict"):
item = item.dict()
if isinstance(item, dict):
if item.get("type") == "text":
text_parts.append(item.get("text", ""))
elif isinstance(item, str):
# Direct string in content list
text_parts.append(item)
return "\n".join(text_parts) if text_parts else ""
def _extract_multimodal_content_list(content: list) -> list:
"""Extract text, image, and audio parts from a content array.
Keeps text, image_url, and input_audio items for VLM processing.
Other content types (tool_use, thinking, refusal, etc.) are dropped.
"""
parts = []
for item in content:
if hasattr(item, "model_dump"):
item = item.model_dump()
elif hasattr(item, "dict"):
item = item.dict()
if isinstance(item, dict):
item_type = item.get("type")
if item_type in ("text", "input_text"):
text = item.get("text") or item.get("content") or ""
parts.append({"type": "text", "text": text})
elif item_type == "image_url":
image_url_value = item.get("image_url")
url = None
if isinstance(image_url_value, str):
url = image_url_value
elif isinstance(image_url_value, dict):
url = image_url_value.get("url")
if url:
parts.append(
{
"type": "image_url",
"image_url": {"url": url},
}
)
elif item_type == "input_image":
image_url_value = item.get("image_url", item.get("input_image"))
url = None
if isinstance(image_url_value, str):
url = image_url_value
elif isinstance(image_url_value, dict):
url = image_url_value.get("url")
if url:
parts.append(
{
"type": "image_url",
"image_url": {"url": url},
}
)
elif item_type == "image":
# Anthropic format: convert to OpenAI image_url format
source = item.get("source", {})
if source.get("type") == "base64":
media_type = source.get("media_type", "image/jpeg")
data = source.get("data", "")
parts.append(
{
"type": "image_url",
"image_url": {
"url": f"data:{media_type};base64,{data}",
},
}
)
elif item_type == "input_audio":
# OpenAI audio format: pass through for engine-side decoding
input_audio = item.get("input_audio")
if input_audio and isinstance(input_audio, dict):
parts.append(
{
"type": "input_audio",
"input_audio": input_audio,
}
)
return parts
# Roles eligible for merging when consecutive.
# System and tool messages are excluded: system messages have distinct semantics
# (e.g., JSON schema instructions), and tool messages carry tool_call_id.
_MERGEABLE_ROLES = {"user", "assistant"}
_PRESERVE_BOUNDARY_KEY = "_preserve_role_boundary"
# Match `role == "tool"` / `role == 'tool'` in a chat template.
_TOOL_ROLE_CHECK_RE = re.compile(r"==\s*['\"]tool['\"]")
_MID_SYSTEM_USER_MARKER = "__OMLX_MID_SYSTEM_PROBE_USER__"
_MID_SYSTEM_MARKER = "__OMLX_MID_SYSTEM_PROBE_SYSTEM__"
_MID_SYSTEM_ASSISTANT_MARKER = "__OMLX_MID_SYSTEM_PROBE_ASSISTANT__"
_MID_SYSTEM_PROBE_TOOL = [
{
"type": "function",
"function": {
"name": "omlx_probe_tool",
"description": "oMLX chat-template probe tool.",
"parameters": {"type": "object", "properties": {}},
},
}
]
_MID_SYSTEM_PROBE_CACHE: dict[tuple[Any, ...], bool] = {}
def _chat_template_supports_tool_role(tokenizer: Any) -> bool:
"""Check whether the tokenizer's chat template renders tool messages natively.
mlx-lm / mlx-vlm only set ``has_tool_calling`` when their marker-based
``_infer_tool_parser`` recognises the chat template (qwen3_coder, json_tools,
gemma4, etc.). Templates that branch on ``role == "tool"`` and render
``tool_calls`` but don't match any known marker (Qwen3 VL variants, custom
fine-tunes) get flattened to ``role: "user"`` — making the model treat tool
output as user instructions and breaking multi-turn tool flows (#1290).
Strict superset of ``has_tool_calling``: if the tokenizer already flags
itself, return True immediately. Otherwise probe the chat_template string
for both a ``role == "tool"`` equality and the ``tool_calls`` variable —
both together keep false positives down (a stray ``"tool"`` literal in a
comment isn't enough).
"""
if getattr(tokenizer, "has_tool_calling", False):
return True
chat_template = getattr(tokenizer, "chat_template", None)
if not isinstance(chat_template, str):
return False
if not _TOOL_ROLE_CHECK_RE.search(chat_template):
return False
return "tool_calls" in chat_template
def _freeze_template_value(value: Any) -> Any:
"""Convert chat-template kwargs into a hashable cache-key value."""
if isinstance(value, dict):
return tuple(
sorted((str(k), _freeze_template_value(v)) for k, v in value.items())
)
if isinstance(value, (list, tuple)):
return tuple(_freeze_template_value(v) for v in value)
if isinstance(value, set):
return tuple(sorted(_freeze_template_value(v) for v in value))
try:
hash(value)
except TypeError:
return repr(value)
return value
def _mid_system_probe_cache_key(
tokenizer: Any,
*,
has_tools: bool,
chat_template_kwargs: dict[str, Any] | None,
placement: str,
is_partial: bool,
) -> tuple[Any, ...]:
chat_template = getattr(tokenizer, "chat_template", None)
if isinstance(chat_template, str):
template_fingerprint: Any = hash(chat_template)
else:
template_fingerprint = repr(chat_template)
return (
id(tokenizer),
template_fingerprint,
has_tools,
_freeze_template_value(chat_template_kwargs or {}),
placement,
is_partial,
)
def _apply_mid_system_probe_template(
tokenizer: Any,
probe_messages: list[dict],
*,
has_tools: bool,
chat_template_kwargs: dict[str, Any] | None,
is_partial: bool,
) -> str:
template_kwargs: dict[str, Any] = {
"tokenize": False,
"add_generation_prompt": not is_partial,
}
if is_partial:
template_kwargs["continue_final_message"] = True
if has_tools:
template_kwargs["tools"] = _MID_SYSTEM_PROBE_TOOL
if chat_template_kwargs:
template_kwargs.update(chat_template_kwargs)
try:
rendered = tokenizer.apply_chat_template(probe_messages, **template_kwargs)
except TypeError:
if chat_template_kwargs:
for key in chat_template_kwargs:
template_kwargs.pop(key, None)
template_kwargs.pop("tools", None)
template_kwargs.pop("enable_thinking", None)
rendered = tokenizer.apply_chat_template(probe_messages, **template_kwargs)
if isinstance(rendered, str):
return rendered
if isinstance(rendered, list):
return " ".join(str(token) for token in rendered)
return str(rendered)
def chat_template_preserves_mid_system(
tokenizer: Any | None,
*,
tools: list[dict] | None = None,
chat_template_kwargs: dict[str, Any] | None = None,
placement: str = "tail",
is_partial: bool = False,
) -> bool:
"""Return whether the chat template renders a mid-system message in-place.
This does not prove model-level semantics. It only verifies that the
current tokenizer template keeps the system content after the preceding
user turn instead of raising, dropping it, or moving it to the front.
"""
if tokenizer is None or not hasattr(tokenizer, "apply_chat_template"):
return False
if placement not in {"tail", "between"}:
return False
has_tools = bool(tools)
cache_key = _mid_system_probe_cache_key(
tokenizer,
has_tools=has_tools,
chat_template_kwargs=chat_template_kwargs,
placement=placement,
is_partial=is_partial,
)
cached = _MID_SYSTEM_PROBE_CACHE.get(cache_key)
if cached is not None:
return cached
probe_messages = [
{"role": "user", "content": _MID_SYSTEM_USER_MARKER},
{"role": "system", "content": _MID_SYSTEM_MARKER},
]
if placement == "between":
probe_messages.append(
{"role": "assistant", "content": _MID_SYSTEM_ASSISTANT_MARKER}
)
try:
rendered = _apply_mid_system_probe_template(
tokenizer,
probe_messages,
has_tools=has_tools,
chat_template_kwargs=chat_template_kwargs,
is_partial=is_partial,
)
except Exception:
_MID_SYSTEM_PROBE_CACHE[cache_key] = False
return False
user_idx = rendered.find(_MID_SYSTEM_USER_MARKER)
system_idx = rendered.find(_MID_SYSTEM_MARKER)
assistant_idx = rendered.find(_MID_SYSTEM_ASSISTANT_MARKER)
supported = user_idx >= 0 and system_idx > user_idx
if placement == "between":
supported = supported and assistant_idx > system_idx
_MID_SYSTEM_PROBE_CACHE[cache_key] = supported
return supported
def _system_content_as_text(content: Any) -> str:
if isinstance(content, list):
return _extract_text_from_content_list(content)
return content if isinstance(content, str) else str(content)
def _is_system_role(role: Any) -> bool:
return role in {"system", "developer"}
def _merge_consecutive_system_messages(messages: list[dict]) -> list[dict]:
"""Merge adjacent system messages in-place without moving their position."""
merged: list[dict] = []
i = 0
while i < len(messages):
msg = messages[i]
if not _is_system_role(msg.get("role")):
merged.append(msg)
i += 1
continue
parts: list[str] = []
while i < len(messages) and _is_system_role(messages[i].get("role")):
content = messages[i].get("content", "")
if content:
text = _system_content_as_text(content)
if text:
parts.append(text)
i += 1
if parts:
merged.append({"role": "system", "content": "\n\n".join(parts)})
return merged
def _mid_system_placement_kinds(messages: list[dict]) -> set[str] | None:
"""Classify supported cache-preserving mid-system placements.
Returns None when any non-leading system run has an unsupported position.
"""
placements: set[str] = set()
seen_non_system = False
i = 0
while i < len(messages):
role = messages[i].get("role")
if not _is_system_role(role):
seen_non_system = True
i += 1
continue
start = i
while i < len(messages) and _is_system_role(messages[i].get("role")):
i += 1
if not seen_non_system:
continue
prev_role = messages[start - 1].get("role") if start > 0 else None
next_role = messages[i].get("role") if i < len(messages) else None
if prev_role != "user":
return None
if next_role is None:
placements.add("tail")
elif next_role == "assistant":
placements.add("between")
else:
return None
return placements
def has_nonleading_system_message(messages: list[dict]) -> bool:
"""Return True when a system message appears after a non-system turn."""
seen_non_system = False
for msg in messages:
if _is_system_role(msg.get("role")):
if seen_non_system:
return True
else:
seen_non_system = True
return False
def _is_text_only_content_list(content: Any) -> bool:
if not isinstance(content, list):
return False
for part in content:
if not isinstance(part, dict):
return False
if part.get("type", "text") != "text":
return False
text = part.get("text", "")
if text is not None and not isinstance(text, str):
return False
return True
def _is_safe_user_note_target(msg: dict | None) -> bool:
if not msg or msg.get("role") != "user":
return False
if msg.get(_PRESERVE_BOUNDARY_KEY):
return False
if msg.get("tool_calls") or msg.get("tool_call_id") or msg.get("tool_responses"):
return False
content = msg.get("content", "")
return (
content is None
or isinstance(content, str)
or _is_text_only_content_list(content)
)
def _message_has_tool_calls(msg: dict | None) -> bool:
return bool(msg and msg.get("role") == "assistant" and msg.get("tool_calls"))
def _format_system_note(parts: list[str]) -> str:
return "[System note]\n" + "\n\n".join(parts) + "\n[/System note]"
def _merge_note_text(existing: str, note: str, *, placement: str) -> str:
if not existing:
return note
if placement == "prepend":
return f"{note}\n\n{existing}"
return f"{existing}\n\n{note}"
def _rewrite_user_content_with_note(
msg: dict,
note: str,
*,
placement: str,
) -> dict:
rewritten = dict(msg)
content = rewritten.get("content", "")
if isinstance(content, list):
parts = [dict(part) for part in content]
if not parts:
rewritten["content"] = [{"type": "text", "text": note}]
return rewritten
index = 0 if placement == "prepend" else len(parts) - 1
existing = parts[index].get("text") or ""
parts[index]["text"] = _merge_note_text(
existing,
note,
placement=placement,
)
rewritten["content"] = parts
return rewritten
existing = content if isinstance(content, str) else ""
rewritten["content"] = _merge_note_text(existing, note, placement=placement)
return rewritten
def _downgrade_mid_system_to_user_notes(messages: list[dict]) -> list[dict] | None:
"""Move unsupported non-leading system runs into adjacent safe user text.
This keeps the native chat template/tool rendering path intact while making
volatile tail notes cache-friendly. It deliberately refuses tool-call
boundaries and multimodal user content, where changing roles is too risky.
"""
rewritten: list[dict] = []
seen_non_system = False
i = 0
while i < len(messages):
msg = messages[i]
if not _is_system_role(msg.get("role")):
rewritten.append(msg)
seen_non_system = True
i += 1
continue
start = i
parts: list[str] = []
while i < len(messages) and _is_system_role(messages[i].get("role")):
content = messages[i].get("content", "")
if content:
text = _system_content_as_text(content)
if text:
parts.append(text)
i += 1
if not seen_non_system:
rewritten.extend(messages[start:i])
continue
if not parts:
continue
note = _format_system_note(parts)
next_msg = messages[i] if i < len(messages) else None
next_role = next_msg.get("role") if next_msg is not None else None
if _is_safe_user_note_target(rewritten[-1] if rewritten else None) and (
next_msg is None or next_role == "assistant"
):
rewritten[-1] = _rewrite_user_content_with_note(
rewritten[-1],
note,
placement="append",
)
continue
if _is_safe_user_note_target(next_msg):
if _message_has_tool_calls(rewritten[-1] if rewritten else None):
return None
rewritten.append(
_rewrite_user_content_with_note(
next_msg,
note,
placement="prepend",
)
)
seen_non_system = True
i += 1
continue
return None
return rewritten
def prepare_system_messages_for_template(
messages: list[dict],
tokenizer: Any | None,
*,
tools: list[dict] | None = None,
chat_template_kwargs: dict[str, Any] | None = None,
is_partial: bool = False,
merge_consecutive_roles: bool = True,
unsupported_mid_system_policy: str = "strict",
) -> list[dict]:
"""Preserve cache-friendly mid-system turns when the template supports them.
Unsupported placements or templates fall back to the historical behavior:
all system messages are consolidated at the front.
"""
messages = [dict(msg) for msg in messages]
if unsupported_mid_system_policy not in {"strict", "user_note_safe"}:
unsupported_mid_system_policy = "strict"
def strict_fallback() -> list[dict]:
prepared = _consolidate_system_messages(messages)
if merge_consecutive_roles:
prepared = _merge_consecutive_roles(prepared)
return prepared
def unsupported_fallback() -> list[dict]:
if unsupported_mid_system_policy == "user_note_safe":
prepared = _downgrade_mid_system_to_user_notes(messages)
if prepared is not None:
# _downgrade preserves leading system blocks as-is; merge
# consecutive system messages so strict templates (Qwen3.6+)
# that require a single leading system message don't fail.
prepared = _merge_consecutive_system_messages(prepared)
if merge_consecutive_roles:
prepared = _merge_consecutive_roles(prepared)
return prepared
return strict_fallback()
placements = _mid_system_placement_kinds(messages)
if not placements:
if placements is None:
return unsupported_fallback()
return _merge_consecutive_system_messages(messages)
if is_partial:
return strict_fallback()
can_preserve = all(
chat_template_preserves_mid_system(
tokenizer,
tools=tools,
chat_template_kwargs=chat_template_kwargs,
placement=placement,
is_partial=is_partial,
)
for placement in placements
)
if can_preserve:
return _merge_consecutive_system_messages(messages)
return unsupported_fallback()
def _drop_void_assistant_messages(messages: list[dict]) -> list[dict]:
"""Drop assistant messages that have no content and no tool_calls.
Strict chat templates (e.g., Devstral/Mistral) raise an error when an
assistant message has empty content and no tool_calls. These void messages
carry no information and can appear when a client echoes back a response
that had only tool calls which were not preserved in its history.
Messages with ``tool_responses`` (Gemma 4 format) or ``reasoning_content``
(Qwen 3.6+ native reasoning field) are never dropped even when content is
empty — they carry their own payload the template renders.
"""
return [
msg
for msg in messages
if not (
msg.get("role") == "assistant"
and not msg.get("content")
and not msg.get("tool_calls")
and not msg.get("tool_responses")
and not msg.get("reasoning_content")
)
]
def _consolidate_system_messages(messages: list[dict]) -> list[dict]:
"""Move all system messages to the front, merged into one.
Models with strict chat templates (e.g., Qwen3.5) require the system
message to appear first. Clients may send system or developer messages
mid-conversation, so we consolidate them defensively.
"""
system_parts: list[str] = []
non_system: list[dict] = []
for msg in messages:
if _is_system_role(msg.get("role")):
content = msg.get("content", "")
if content:
if isinstance(content, list):
text = _extract_text_from_content_list(content)
if text:
system_parts.append(text)
else:
system_parts.append(content)
else:
non_system.append(msg)
if not system_parts:
return messages
merged_system = {"role": "system", "content": "\n\n".join(system_parts)}
return [merged_system] + non_system
def _merge_consecutive_roles(messages: list[dict]) -> list[dict]:
"""Merge consecutive messages with the same mergeable role.
Models with strict chat templates (e.g., Gemma-3) enforce alternating
user/assistant roles and reject consecutive same-role messages.
OpenAI's API accepts these, so we merge them for compatibility.
Args:
messages: List of processed message dicts with 'role' and 'content'.
Returns:
New list with consecutive same-role messages merged using "\\n\\n".
"""
if not messages:
return messages
merged: list[dict] = [messages[0].copy()]
for msg in messages[1:]:
prev = merged[-1]
if (
msg["role"] == prev["role"]
and msg["role"] in _MERGEABLE_ROLES
and not prev.get(_PRESERVE_BOUNDARY_KEY)
and not msg.get(_PRESERVE_BOUNDARY_KEY)
):
prev_content = prev.get("content", "")
new_content = msg.get("content", "")
if prev_content and new_content:
prev_is_list = isinstance(prev_content, list)
new_is_list = isinstance(new_content, list)
if prev_is_list or new_is_list:
# Convert both to list form for safe concatenation
if not prev_is_list:
prev_content = [{"type": "text", "text": prev_content}]
if not new_is_list:
new_content = [{"type": "text", "text": new_content}]
prev["content"] = prev_content + new_content
else:
prev["content"] = prev_content + "\n\n" + new_content
elif new_content:
prev["content"] = new_content
else:
merged.append(msg.copy())
return merged
def _apply_reasoning_reconstruction(
role: str,
content: Any,
reasoning: str | None,
native: bool,
) -> tuple[Any, str | None]:
"""Reconstruct reasoning on a historical assistant message.
External clients echo reasoning back via the OpenAI ``reasoning_content``
field (or Anthropic ``thinking`` blocks). Chat templates fall into two
camps:
* ``native=True`` — template understands ``message.reasoning_content``
as a top-level field (Qwen 3.6+). Content stays clean and reasoning
travels separately.
* ``native=False`` — template only parses ``<think>...</think>`` embedded
in content. Reasoning is inlined into content as a fallback.
Returns ``(new_content, reasoning_out)`` where ``reasoning_out`` is the
string to attach as a ``reasoning_content`` field, or ``None`` to skip.
"""
if role != "assistant" or not reasoning:
if role != "assistant" or not native:
return content, None
text = content if isinstance(content, str) else ""
if isinstance(content, list):
text = _extract_text_from_content_list(content)
from .thinking import extract_thinking
inline_reasoning, inline_content = extract_thinking(text)
if inline_reasoning:
return inline_content, inline_reasoning
return content, None
text = content if isinstance(content, str) else ""
if isinstance(content, list):
text = _extract_text_from_content_list(content)
if native:
return text, reasoning
return f"<think>\n{reasoning}\n</think>\n\n{text}", None
def extract_text_content(
messages: List[Message],
max_tool_result_tokens: int | None = None,
tokenizer: Any | None = None,
native_reasoning_content: bool = False,
consolidate_system_messages: bool = True,
) -> List[dict]:
"""
Extract text content from OpenAI-format messages.
Handles:
- Simple text messages
- Content arrays (extracts text parts only)
- Tool call messages (assistant with tool_calls)
- Tool response messages (role="tool")
Args:
messages: List of Message objects
max_tool_result_tokens: Maximum token count for tool results.
tokenizer: Tokenizer instance for token counting and truncation.
native_reasoning_content: If True, pass ``reasoning_content`` through
as a message-level field (Qwen 3.6+ templates). If False, inline
``<think>...</think>`` into content as a fallback.
consolidate_system_messages: If True, preserve historical strict-template
behavior by moving system messages to the front. Server code can
set this to False and call ``prepare_system_messages_for_template``
after tools/template kwargs are known.
Returns:
List of {"role": str, "content": str}
"""
processed_messages = []
for msg in messages:
role = msg.role
content = msg.content
# Reconstruct reasoning for historical assistant messages. Native
# mode passes reasoning as a separate field; fallback inlines it as
# <think>...</think> in content.
reasoning = getattr(msg, "reasoning_content", None)
content, reasoning_out = _apply_reasoning_reconstruction(
role, content, reasoning, native_reasoning_content
)
# Normalize "developer" role to "system" (OpenAI API compatibility)
if role == "developer":
role = "system"
# Handle tool response messages (role="tool")
if role == "tool":
tool_call_id = getattr(msg, "tool_call_id", None) or ""
# Convert list content to string if needed
if isinstance(content, list):
tool_content = _extract_text_from_content_list(content)
else:
tool_content = content if content else ""
# Apply truncation if configured
if max_tool_result_tokens and tokenizer and tool_content:
from .anthropic_utils import truncate_tool_result
tool_content = truncate_tool_result(
tool_content, max_tool_result_tokens, tokenizer
)
# Preserve structured format for models with native tool calling
# so the chat template renders tool results in the model's native format
if _chat_template_supports_tool_role(tokenizer):
processed_messages.append(
{
"role": "tool",
"tool_call_id": tool_call_id,
"content": tool_content,
}
)
else:
processed_messages.append(
{
"role": "user", # mlx-lm expects user/assistant roles
"content": f"[Tool Result ({tool_call_id})]: {tool_content}",
_PRESERVE_BOUNDARY_KEY: True,
}
)
continue
# Handle assistant messages with tool_calls
if role == "assistant" and hasattr(msg, "tool_calls") and msg.tool_calls:
if isinstance(content, list):
content = _extract_text_from_content_list(content)
msg_dict = {"role": role, "content": content if content else ""}
if reasoning_out is not None:
msg_dict["reasoning_content"] = reasoning_out
if getattr(msg, "name", None):
msg_dict["name"] = msg.name
if getattr(msg, "partial", False):
msg_dict["partial"] = True
# Preserve structured tool_calls for models with native tool calling
# so the chat template renders them in the model's native format.
# Without this, models mimic text-formatted tool calls from history
# instead of generating their native parseable format.
if _chat_template_supports_tool_role(tokenizer):
tool_calls_list = []
for tc in msg.tool_calls:
if isinstance(tc, dict):
func = tc.get("function", {})
tool_calls_list.append(
{
"id": tc.get("id", ""),
"function": {
"name": func.get("name", ""),
"arguments": _try_parse_json(
func.get("arguments", "{}")
),
},
}
)
else:
args_str = (
getattr(tc.function, "arguments", "{}")
if hasattr(tc, "function")
else "{}"
)
tool_calls_list.append(
{
"id": getattr(tc, "id", ""),
"function": {
"name": (
getattr(tc.function, "name", "")
if hasattr(tc, "function")
else ""
),
"arguments": _try_parse_json(args_str),
},
}
)
msg_dict["tool_calls"] = tool_calls_list
else:
# Text fallback for models without native tool calling
tool_calls_text = []
for tc in msg.tool_calls:
if isinstance(tc, dict):
func = tc.get("function", {})
name = func.get("name", "unknown")
args = func.get("arguments", "{}")
tool_calls_text.append(f"[Calling tool: {name}({args})]")
text = msg_dict["content"]
if tool_calls_text:
text = (text + "\n" if text else "") + "\n".join(tool_calls_text)
msg_dict["content"] = text
msg_dict[_PRESERVE_BOUNDARY_KEY] = True
processed_messages.append(msg_dict)
continue
# Build optional extra fields from the source message
_extra: dict = {}
if getattr(msg, "name", None):
_extra["name"] = msg.name
if getattr(msg, "partial", False):
_extra["partial"] = True
if reasoning_out is not None:
_extra["reasoning_content"] = reasoning_out
# Handle None content
if content is None:
processed_messages.append({"role": role, "content": "", **_extra})
continue
if isinstance(content, str):
# Simple text message
processed_messages.append({"role": role, "content": content, **_extra})
elif isinstance(content, list):
# Content array - extract text parts only
combined_text = _extract_text_from_content_list(content)
processed_messages.append(
{"role": role, "content": combined_text, **_extra}
)
else:
# Unknown format, try to convert
processed_messages.append({"role": role, "content": str(content), **_extra})
processed_messages = _drop_void_assistant_messages(processed_messages)
if consolidate_system_messages:
processed_messages = _consolidate_system_messages(processed_messages)
return _merge_consecutive_roles(processed_messages)
def extract_multimodal_content(
messages: List[Message],
max_tool_result_tokens: int | None = None,
tokenizer: Any | None = None,
native_reasoning_content: bool = False,
consolidate_system_messages: bool = True,
) -> List[dict]:
"""
Extract content from messages, preserving image_url parts for VLM.
Same as extract_text_content but keeps image_url content parts
in their original list format for VLM processing.
Args:
messages: List of Message objects
max_tool_result_tokens: Maximum token count for tool results.
tokenizer: Tokenizer instance for token counting and truncation.
native_reasoning_content: If True, pass ``reasoning_content`` through
as a message-level field. See ``extract_text_content``.
consolidate_system_messages: See ``extract_text_content``.
Returns:
List of message dicts. Messages with images have content as list.
"""
processed_messages = []
for msg in messages:
role = msg.role
content = msg.content
# Reconstruct reasoning (see extract_text_content).
reasoning = getattr(msg, "reasoning_content", None)
content, reasoning_out = _apply_reasoning_reconstruction(
role, content, reasoning, native_reasoning_content
)
if role == "developer":
role = "system"
# Tool response messages - same as extract_text_content
if role == "tool":
tool_call_id = getattr(msg, "tool_call_id", None) or ""
# Convert list content to string if needed
if isinstance(content, list):
tool_content = _extract_text_from_content_list(content)
else:
tool_content = content if content else ""
if max_tool_result_tokens and tokenizer and tool_content:
from .anthropic_utils import truncate_tool_result
tool_content = truncate_tool_result(
tool_content, max_tool_result_tokens, tokenizer
)
if _chat_template_supports_tool_role(tokenizer):
processed_messages.append(
{
"role": "tool",
"tool_call_id": tool_call_id,
"content": tool_content,
}
)
else:
processed_messages.append(
{
"role": "user",
"content": f"[Tool Result ({tool_call_id})]: {tool_content}",
_PRESERVE_BOUNDARY_KEY: True,
}
)
continue
# Assistant with tool_calls - same as extract_text_content
if role == "assistant" and hasattr(msg, "tool_calls") and msg.tool_calls:
if isinstance(content, list):
content = _extract_text_from_content_list(content)
msg_dict = {"role": role, "content": content if content else ""}
if reasoning_out is not None:
msg_dict["reasoning_content"] = reasoning_out
if getattr(msg, "name", None):
msg_dict["name"] = msg.name
if getattr(msg, "partial", False):
msg_dict["partial"] = True
if _chat_template_supports_tool_role(tokenizer):
tool_calls_list = []
for tc in msg.tool_calls:
if isinstance(tc, dict):
func = tc.get("function", {})
tool_calls_list.append(
{
"id": tc.get("id", ""),
"function": {
"name": func.get("name", ""),
"arguments": _try_parse_json(
func.get("arguments", "{}")
),
},
}
)
else:
args_str = (
getattr(tc.function, "arguments", "{}")
if hasattr(tc, "function")
else "{}"
)
tool_calls_list.append(
{
"id": getattr(tc, "id", ""),
"function": {
"name": (
getattr(tc.function, "name", "")
if hasattr(tc, "function")
else ""
),
"arguments": _try_parse_json(args_str),
},
}
)
msg_dict["tool_calls"] = tool_calls_list
else:
tool_calls_text = []
for tc in msg.tool_calls:
if isinstance(tc, dict):
func = tc.get("function", {})
name = func.get("name", "unknown")
args = func.get("arguments", "{}")
tool_calls_text.append(f"[Calling tool: {name}({args})]")
text = msg_dict["content"]
if tool_calls_text:
text = (text + "\n" if text else "") + "\n".join(tool_calls_text)
msg_dict["content"] = text
msg_dict[_PRESERVE_BOUNDARY_KEY] = True
processed_messages.append(msg_dict)
continue
# Build optional extra fields from the source message
_extra: dict = {}
if getattr(msg, "name", None):
_extra["name"] = msg.name
if getattr(msg, "partial", False):
_extra["partial"] = True
if reasoning_out is not None:
_extra["reasoning_content"] = reasoning_out
if content is None:
processed_messages.append({"role": role, "content": "", **_extra})
continue
if isinstance(content, str):
processed_messages.append({"role": role, "content": content, **_extra})
elif isinstance(content, list):
# Preserve image_url and input_audio parts for VLM processing
multimodal_parts = _extract_multimodal_content_list(content)
multimodal_types = {"image_url", "input_audio"}
has_multimodal = any(
p.get("type") in multimodal_types for p in multimodal_parts
)
if has_multimodal:
# Keep as content list for VLM engine
processed_messages.append(
{"role": role, "content": multimodal_parts, **_extra}
)
else:
# Text-only, flatten to string
combined_text = _extract_text_from_content_list(content)
processed_messages.append(
{"role": role, "content": combined_text, **_extra}
)
else:
processed_messages.append({"role": role, "content": str(content), **_extra})
processed_messages = _drop_void_assistant_messages(processed_messages)
if consolidate_system_messages:
processed_messages = _consolidate_system_messages(processed_messages)
return processed_messages
# =============================================================================
# Harmony (gpt-oss) Message Extraction
# =============================================================================
def _try_parse_json(s: str):
"""
Try to parse a string as JSON. Returns parsed dict/list if valid JSON,
otherwise returns the original string.
This is needed because Harmony chat_template uses |tojson filter,
which would double-encode strings that are already JSON.
"""
if not isinstance(s, str):
return s
s = s.strip()
if not s:
return s
# Quick check: must start with { or [ to be JSON object/array
if not (s.startswith("{") or s.startswith("[")):
return s
try:
return json.loads(s)
except (json.JSONDecodeError, ValueError):
return s
def _wrap_truncated_for_harmony(truncated_text: str) -> dict:
"""Wrap truncated tool result in a dict for Harmony |tojson compatibility.
The Harmony chat_template applies |tojson to tool result content.
When truncation breaks valid JSON, the content becomes a string, and
|tojson would double-encode it (wrapping in quotes and escaping).
This function wraps the truncated text in a dict so |tojson produces
a clean JSON object instead.
Args:
truncated_text: Text with truncation notice appended.
Returns:
Dict with 'output' key containing the truncated content and
'truncated' key with a human-readable summary.
"""
match = re.search(
r'\n\n<truncated total_tokens="(\d+)" shown_tokens="(\d+)" />\s*$',
truncated_text,
)
if match:
return {
"output": truncated_text[: match.start()],
"truncated": f"Showing {match.group(2)} of {match.group(1)} tokens",
}
return {"output": truncated_text}
def extract_harmony_messages(
messages: list,
max_tool_result_tokens: int | None = None,
tokenizer: Any | None = None,
consolidate_system_messages: bool = True,
) -> List[dict]:
"""
Extract messages for Harmony (gpt-oss) models.
Unlike extract_text_content(), this function preserves:
- tool messages: role="tool" with tool_call_id (chat_template handles conversion)
- assistant tool_calls: tool_calls field intact (chat_template handles conversion)
The Harmony chat_template expects standard OpenAI format and converts:
- role="tool" → <|start|>functions.{name} to=assistant<|channel|>commentary...
- assistant.tool_calls → <|start|>assistant to=functions.{name}<|channel|>commentary...
IMPORTANT: The chat_template uses |tojson filter on:
- tool_call.arguments (line 299)
- message.content for tool results (line 322)
If these are already JSON strings, |tojson would double-encode them.
So we parse JSON strings to dicts before passing to the template.
Args:
messages: List of Message objects
max_tool_result_tokens: Maximum token count for tool results.
tokenizer: Tokenizer instance for token counting and truncation.
consolidate_system_messages: See ``extract_text_content``.
Returns:
List of message dicts with tool-related fields preserved
"""
processed_messages = []
# Normalize to plain dicts -- callers may pass Pydantic Message
# objects (OpenAI path) or plain dicts (Anthropic path).
raw: list[dict] = []
for msg in messages:
if hasattr(msg, "model_dump"):
raw.append(msg.model_dump())
elif isinstance(msg, dict):
raw.append(dict(msg))
else:
d: dict = {
"role": getattr(msg, "role", "user"),
"content": getattr(msg, "content", ""),
}
tool_call_id = getattr(msg, "tool_call_id", None)
if tool_call_id is not None:
d["tool_call_id"] = tool_call_id
tool_calls = getattr(msg, "tool_calls", None)
if tool_calls is not None:
d["tool_calls"] = tool_calls
raw.append(d)
for msg in raw:
role = msg.get("role", "user")
content = msg.get("content")
# Normalize "developer" role to "system" (OpenAI API compatibility)
if role == "developer":
role = "system"
# Tool response messages - preserve role and tool_call_id
# Parse content as JSON if possible (chat_template applies |tojson)
if role == "tool":
# Convert list content to string if needed
if isinstance(content, list):
tool_content = _extract_text_from_content_list(content)
else:
tool_content = content if content else ""
if max_tool_result_tokens and tokenizer and tool_content:
from .anthropic_utils import truncate_tool_result
# Parse JSON BEFORE truncation for better line-boundary cuts.
# Harmony chat_template applies |tojson to content, so content
# must be a dict (not a string) to avoid double-encoding.
parsed_json = _try_parse_json(tool_content)
if isinstance(parsed_json, (dict, list)):
# Valid JSON - pretty-print for line-boundary 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 |tojson
parsed_content = _wrap_truncated_for_harmony(truncated)
else:
# Not truncated - use parsed dict/list
parsed_content = parsed_json
else:
# Not JSON - truncate raw text, keep as string
parsed_content = truncate_tool_result(
tool_content, max_tool_result_tokens, tokenizer
)
else:
# No truncation configured - just parse JSON if possible
parsed_content = _try_parse_json(tool_content)
processed_messages.append(
{
"role": "tool",
"tool_call_id": msg.get("tool_call_id", "") or "",
"content": parsed_content,
}
)
continue
# Assistant messages - preserve tool_calls field
if role == "assistant":
msg_dict = {"role": role}
# Handle content (may be string or list)
if content is None:
msg_dict["content"] = ""
elif isinstance(content, str):
msg_dict["content"] = content
elif isinstance(content, list):
# Extract text parts from content array
msg_dict["content"] = _extract_text_from_content_list(content)
else:
msg_dict["content"] = str(content)
# Preserve tool_calls field for chat_template
# Parse arguments as JSON if possible (chat_template applies |tojson)
if msg.get("tool_calls"):
tool_calls_list = []
for tc in msg["tool_calls"]:
if isinstance(tc, dict):
args_str = tc.get("function", {}).get("arguments", "{}")
tool_calls_list.append(
{
"id": tc.get("id", ""),
"function": {
"name": tc.get("function", {}).get("name", ""),
"arguments": _try_parse_json(args_str),
},
}
)
else:
# Pydantic model
args_str = (
getattr(tc.function, "arguments", "{}")
if hasattr(tc, "function")
else "{}"
)
tool_calls_list.append(
{
"id": getattr(tc, "id", ""),
"function": {
"name": (
getattr(tc.function, "name", "")
if hasattr(tc, "function")
else ""
),
"arguments": _try_parse_json(args_str),
},
}
)
msg_dict["tool_calls"] = tool_calls_list
msg_dict[_PRESERVE_BOUNDARY_KEY] = True
processed_messages.append(msg_dict)
continue
# Other messages (user, system, developer)
if content is None:
processed_messages.append({"role": role, "content": ""})
elif isinstance(content, str):
processed_messages.append({"role": role, "content": content})
elif isinstance(content, list):
# Extract text parts from content array
processed_messages.append(
{"role": role, "content": _extract_text_from_content_list(content)}
)
else:
processed_messages.append({"role": role, "content": str(content)})
processed_messages = _drop_void_assistant_messages(processed_messages)
if consolidate_system_messages:
processed_messages = _consolidate_system_messages(processed_messages)
return _merge_consecutive_roles(processed_messages)