Skip to content

Commit fe604fd

Browse files
authored
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
1 parent 9806e36 commit fe604fd

2 files changed

Lines changed: 133 additions & 0 deletions

File tree

studio/backend/models/inference.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1431,6 +1431,47 @@ class AnthropicToolResultBlock(BaseModel):
14311431
]
14321432

14331433

1434+
def _anthropic_content_to_system_text(content: Any) -> str:
1435+
"""Convert misplaced system message content into Anthropic system text."""
1436+
if content is None: # null content must not become the literal "None"
1437+
return ""
1438+
if isinstance(content, str):
1439+
return content
1440+
if isinstance(content, list):
1441+
parts: list[str] = []
1442+
for block in content:
1443+
if isinstance(block, dict) and block.get("type") == "text":
1444+
text = block.get("text")
1445+
if isinstance(text, str):
1446+
parts.append(text)
1447+
continue
1448+
if block is not None:
1449+
parts.append(str(block))
1450+
return "\n\n".join(part for part in parts if part)
1451+
return str(content)
1452+
1453+
1454+
def _merge_anthropic_system(system: Any, additions: list[str]) -> Any:
1455+
if not additions:
1456+
return system
1457+
1458+
addition_blocks = [
1459+
{"type": "text", "text": text} for text in additions if text.strip()
1460+
]
1461+
if not addition_blocks:
1462+
return system
1463+
1464+
if system is None:
1465+
return (
1466+
addition_blocks[0]["text"] if len(addition_blocks) == 1 else addition_blocks
1467+
)
1468+
if isinstance(system, str):
1469+
return "\n\n".join([system, *[block["text"] for block in addition_blocks]])
1470+
if isinstance(system, list):
1471+
return [*system, *addition_blocks]
1472+
return system
1473+
1474+
14341475
class AnthropicMessage(BaseModel):
14351476
role: Literal["user", "assistant"]
14361477
content: Union[str, list[AnthropicContentBlock]]
@@ -1474,6 +1515,39 @@ class AnthropicMessagesRequest(BaseModel):
14741515
cancel_id: Optional[str] = None
14751516
model_config = {"extra": "allow"}
14761517

1518+
@model_validator(mode = "before")
1519+
@classmethod
1520+
def normalize_system_messages(cls, data: Any) -> Any:
1521+
if not isinstance(data, dict):
1522+
return data
1523+
1524+
messages = data.get("messages")
1525+
if not isinstance(messages, list):
1526+
return data
1527+
1528+
normalized_messages: list[Any] = []
1529+
system_additions: list[str] = []
1530+
changed = False
1531+
1532+
for message in messages:
1533+
if isinstance(message, dict) and message.get("role") == "system":
1534+
system_additions.append(
1535+
_anthropic_content_to_system_text(message.get("content", ""))
1536+
)
1537+
changed = True
1538+
continue
1539+
normalized_messages.append(message)
1540+
1541+
if not changed:
1542+
return data
1543+
1544+
normalized = dict(data)
1545+
normalized["messages"] = normalized_messages
1546+
normalized["system"] = _merge_anthropic_system(
1547+
normalized.get("system"), system_additions
1548+
)
1549+
return normalized
1550+
14771551

14781552
# ── Response models ────────────────────────────────────────────
14791553

studio/backend/tests/test_anthropic_messages.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,51 @@ def test_system_as_string(self):
7878
)
7979
assert req.system == "You are helpful."
8080

81+
def test_system_role_message_normalized_to_system_field(self):
82+
req = AnthropicMessagesRequest(
83+
max_tokens = 50,
84+
messages = [
85+
{"role": "system", "content": "You are helpful."},
86+
{"role": "user", "content": "Hi"},
87+
],
88+
)
89+
assert req.system == "You are helpful."
90+
assert len(req.messages) == 1
91+
assert req.messages[0].role == "user"
92+
93+
def test_system_role_message_merges_with_existing_system_field(self):
94+
req = AnthropicMessagesRequest(
95+
max_tokens = 50,
96+
system = "Base instructions.",
97+
messages = [
98+
{"role": "user", "content": "Hi"},
99+
{"role": "system", "content": "Additional instructions."},
100+
{"role": "assistant", "content": "Hello."},
101+
],
102+
)
103+
assert req.system == "Base instructions.\n\nAdditional instructions."
104+
assert [msg.role for msg in req.messages] == ["user", "assistant"]
105+
106+
def test_system_role_message_with_null_content_ignored(self):
107+
req = AnthropicMessagesRequest(
108+
max_tokens = 50,
109+
system = "Base.",
110+
messages = [
111+
{"role": "system", "content": None},
112+
{
113+
"role": "system",
114+
"content": [
115+
None,
116+
{"type": "text", "text": "Use short answers."},
117+
],
118+
},
119+
{"role": "user", "content": "Hi"},
120+
],
121+
)
122+
assert req.system == "Base.\n\nUse short answers."
123+
assert "None" not in str(req.system)
124+
assert [msg.role for msg in req.messages] == ["user"]
125+
81126
def test_tools_field_parses(self):
82127
req = AnthropicMessagesRequest(
83128
max_tokens = 100,
@@ -158,6 +203,20 @@ def test_system_string_prepended(self):
158203
assert result[0] == {"role": "system", "content": "Be brief."}
159204
assert result[1] == {"role": "user", "content": "Hello"}
160205

206+
def test_top_level_system_request_translates_unchanged(self):
207+
req = AnthropicMessagesRequest(
208+
messages = [{"role": "user", "content": "Hello"}],
209+
system = "Be brief.",
210+
)
211+
result = anthropic_messages_to_openai(
212+
[m.model_dump() for m in req.messages],
213+
req.system,
214+
)
215+
assert result == [
216+
{"role": "system", "content": "Be brief."},
217+
{"role": "user", "content": "Hello"},
218+
]
219+
161220
def test_system_as_block_list(self):
162221
system = [
163222
{"type": "text", "text": "Be brief."},

0 commit comments

Comments
 (0)