fix(anthropic): detect-and-passthrough mid-conversation system messages (#28906)
This commit is contained in:
@@ -379,64 +379,6 @@ class AnthropicMessagesRequest(BaseModel):
|
||||
output_config: Optional[AnthropicOutputConfig] = None
|
||||
betas: Optional[list[str]] = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def move_mid_conversation_system_messages(cls, values: dict) -> dict:
|
||||
"""Fold mid-conversation ``role: "system"`` turns into the top-level
|
||||
``system`` field — some clients (e.g. Claude Code) emit them there."""
|
||||
messages = values.get("messages", [])
|
||||
if not messages:
|
||||
return values
|
||||
|
||||
clean_messages = []
|
||||
extracted_system_texts = []
|
||||
|
||||
for msg in messages:
|
||||
# ``mode="before"`` sees raw dicts (HTTP path) but also already-
|
||||
# constructed ``AnthropicMessage`` objects (programmatic path, e.g.
|
||||
# ``handle_count_tokens``), so normalize to a dict first.
|
||||
if isinstance(msg, BaseModel):
|
||||
msg = msg.model_dump()
|
||||
if msg.get("role") == "system":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and content.strip():
|
||||
extracted_system_texts.append(content.strip())
|
||||
elif isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
text = block.get("text", "").strip()
|
||||
if text:
|
||||
extracted_system_texts.append(text)
|
||||
else:
|
||||
clean_messages.append(msg)
|
||||
|
||||
if extracted_system_texts:
|
||||
existing_system = values.get("system")
|
||||
combined_system = []
|
||||
|
||||
if existing_system:
|
||||
if isinstance(existing_system, str):
|
||||
if existing_system.strip():
|
||||
combined_system.append(existing_system.strip())
|
||||
elif isinstance(existing_system, list):
|
||||
for block in existing_system:
|
||||
if isinstance(block, BaseModel):
|
||||
block = block.model_dump()
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
text = block.get("text", "").strip()
|
||||
if text:
|
||||
combined_system.append(text)
|
||||
|
||||
combined_system.extend(extracted_system_texts)
|
||||
|
||||
# Join into a string — ``system`` is ``str | list[AnthropicContentBlock]``,
|
||||
# so a ``list[str]`` would fail validation.
|
||||
if combined_system:
|
||||
values["system"] = "\n".join(combined_system)
|
||||
|
||||
values["messages"] = clean_messages
|
||||
return values
|
||||
|
||||
@field_validator("model")
|
||||
@classmethod
|
||||
def _validate_model(cls, v):
|
||||
|
||||
@@ -52,6 +52,7 @@ from sglang.srt.entrypoints.openai.protocol import (
|
||||
ToolChoice,
|
||||
ToolChoiceFuncName,
|
||||
)
|
||||
from sglang.srt.managers.template_detection import detect_inline_system_support
|
||||
from sglang.srt.observability.req_time_stats import monotonic_time
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -132,6 +133,26 @@ def _anthropic_usage_from_openai(
|
||||
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"
|
||||
@@ -169,6 +190,18 @@ class AnthropicServing:
|
||||
|
||||
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,
|
||||
@@ -356,19 +389,28 @@ class AnthropicServing:
|
||||
)
|
||||
return None
|
||||
|
||||
# Add system message if provided
|
||||
system_parts: list[str] = []
|
||||
if anthropic_request.system:
|
||||
if isinstance(anthropic_request.system, str):
|
||||
openai_messages.append(
|
||||
{"role": "system", "content": anthropic_request.system}
|
||||
)
|
||||
if anthropic_request.system.strip():
|
||||
system_parts.append(anthropic_request.system)
|
||||
else:
|
||||
system_parts = []
|
||||
for block in anthropic_request.system:
|
||||
if block.type == "text" and block.text:
|
||||
system_parts.append(block.text)
|
||||
system_text = "\n".join(system_parts)
|
||||
openai_messages.append({"role": "system", "content": system_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.
|
||||
@@ -388,6 +430,8 @@ class AnthropicServing:
|
||||
|
||||
# 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
|
||||
|
||||
@@ -24,6 +24,10 @@ import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional, Tuple
|
||||
|
||||
import jinja2
|
||||
import jinja2.ext
|
||||
import jinja2.sandbox
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -483,6 +487,37 @@ def detect_tool_call_parser(
|
||||
return match_rules(ctx, TOOL_CALL_PARSER_RULES, "tool-call parser")
|
||||
|
||||
|
||||
def detect_inline_system_support(chat_template: Optional[str]) -> bool:
|
||||
"""True if mid-conversation ``role: "system"`` renders inline; False if the
|
||||
template raises or silently drops it (then merge into the leading block).
|
||||
|
||||
The probe requires the second system's sentinel to appear in the output —
|
||||
not raising isn't enough, since some templates ignore non-leading system."""
|
||||
if not chat_template:
|
||||
return False
|
||||
sentinel = "__sglang_inline_system_sentinel__"
|
||||
try:
|
||||
env = jinja2.sandbox.ImmutableSandboxedEnvironment(
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
extensions=[jinja2.ext.loopcontrols],
|
||||
)
|
||||
rendered = env.from_string(chat_template).render(
|
||||
messages=[
|
||||
{"role": "system", "content": "t"},
|
||||
{"role": "user", "content": "t"},
|
||||
{"role": "system", "content": sentinel},
|
||||
{"role": "user", "content": "t"},
|
||||
],
|
||||
add_generation_prompt=False,
|
||||
)
|
||||
return sentinel in rendered
|
||||
except jinja2.TemplateError:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_auto_parser(
|
||||
server_args,
|
||||
attr: str,
|
||||
|
||||
Reference in New Issue
Block a user