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:
parent
9806e36aa4
commit
fe604fde20
2 changed files with 133 additions and 0 deletions
|
|
@ -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 ────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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."},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue