diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 9c3572bd3a..5a846e536e 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -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 ──────────────────────────────────────────── diff --git a/studio/backend/tests/test_anthropic_messages.py b/studio/backend/tests/test_anthropic_messages.py index 634d02ad6c..dfabf27b68 100644 --- a/studio/backend/tests/test_anthropic_messages.py +++ b/studio/backend/tests/test_anthropic_messages.py @@ -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."},