Studio: accept system-role messages in Claude Code requests (#6006)

Normalize misplaced system-role messages in /v1/messages by hoisting their content into the top-level Anthropic system field, fixing the 422 that newer Claude Code clients trigger. Null and non-text system content is ignored rather than stringified.

Fixes #6001
This commit is contained in:
Lee Jackson 2026-06-05 13:02:54 +01:00 committed by GitHub
commit fe604fde20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 133 additions and 0 deletions

View file

@ -1431,6 +1431,47 @@ AnthropicContentBlock = Union[
]
def _anthropic_content_to_system_text(content: Any) -> str:
"""Convert misplaced system message content into Anthropic system text."""
if content is None: # null content must not become the literal "None"
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text")
if isinstance(text, str):
parts.append(text)
continue
if block is not None:
parts.append(str(block))
return "\n\n".join(part for part in parts if part)
return str(content)
def _merge_anthropic_system(system: Any, additions: list[str]) -> Any:
if not additions:
return system
addition_blocks = [
{"type": "text", "text": text} for text in additions if text.strip()
]
if not addition_blocks:
return system
if system is None:
return (
addition_blocks[0]["text"] if len(addition_blocks) == 1 else addition_blocks
)
if isinstance(system, str):
return "\n\n".join([system, *[block["text"] for block in addition_blocks]])
if isinstance(system, list):
return [*system, *addition_blocks]
return system
class AnthropicMessage(BaseModel):
role: Literal["user", "assistant"]
content: Union[str, list[AnthropicContentBlock]]
@ -1474,6 +1515,39 @@ class AnthropicMessagesRequest(BaseModel):
cancel_id: Optional[str] = None
model_config = {"extra": "allow"}
@model_validator(mode = "before")
@classmethod
def normalize_system_messages(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
messages = data.get("messages")
if not isinstance(messages, list):
return data
normalized_messages: list[Any] = []
system_additions: list[str] = []
changed = False
for message in messages:
if isinstance(message, dict) and message.get("role") == "system":
system_additions.append(
_anthropic_content_to_system_text(message.get("content", ""))
)
changed = True
continue
normalized_messages.append(message)
if not changed:
return data
normalized = dict(data)
normalized["messages"] = normalized_messages
normalized["system"] = _merge_anthropic_system(
normalized.get("system"), system_additions
)
return normalized
# ── Response models ────────────────────────────────────────────

View file

@ -78,6 +78,51 @@ class TestAnthropicModels:
)
assert req.system == "You are helpful."
def test_system_role_message_normalized_to_system_field(self):
req = AnthropicMessagesRequest(
max_tokens = 50,
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hi"},
],
)
assert req.system == "You are helpful."
assert len(req.messages) == 1
assert req.messages[0].role == "user"
def test_system_role_message_merges_with_existing_system_field(self):
req = AnthropicMessagesRequest(
max_tokens = 50,
system = "Base instructions.",
messages = [
{"role": "user", "content": "Hi"},
{"role": "system", "content": "Additional instructions."},
{"role": "assistant", "content": "Hello."},
],
)
assert req.system == "Base instructions.\n\nAdditional instructions."
assert [msg.role for msg in req.messages] == ["user", "assistant"]
def test_system_role_message_with_null_content_ignored(self):
req = AnthropicMessagesRequest(
max_tokens = 50,
system = "Base.",
messages = [
{"role": "system", "content": None},
{
"role": "system",
"content": [
None,
{"type": "text", "text": "Use short answers."},
],
},
{"role": "user", "content": "Hi"},
],
)
assert req.system == "Base.\n\nUse short answers."
assert "None" not in str(req.system)
assert [msg.role for msg in req.messages] == ["user"]
def test_tools_field_parses(self):
req = AnthropicMessagesRequest(
max_tokens = 100,
@ -158,6 +203,20 @@ class TestAnthropicMessagesToOpenAI:
assert result[0] == {"role": "system", "content": "Be brief."}
assert result[1] == {"role": "user", "content": "Hello"}
def test_top_level_system_request_translates_unchanged(self):
req = AnthropicMessagesRequest(
messages = [{"role": "user", "content": "Hello"}],
system = "Be brief.",
)
result = anthropic_messages_to_openai(
[m.model_dump() for m in req.messages],
req.system,
)
assert result == [
{"role": "system", "content": "Be brief."},
{"role": "user", "content": "Hello"},
]
def test_system_as_block_list(self):
system = [
{"type": "text", "text": "Be brief."},