diff --git a/src/exo/api/adapters/claude.py b/src/exo/api/adapters/claude.py index a54de764c6..eea385cff1 100644 --- a/src/exo/api/adapters/claude.py +++ b/src/exo/api/adapters/claude.py @@ -13,7 +13,9 @@ ClaudeContentBlockStartEvent, ClaudeContentBlockStopEvent, ClaudeImageBlock, + ClaudeInputContentBlock, ClaudeInputJsonDelta, + ClaudeMessage, ClaudeMessageDelta, ClaudeMessageDeltaEvent, ClaudeMessageDeltaUsage, @@ -91,6 +93,14 @@ def _strip_volatile_headers(text: str) -> str: return _VOLATILE_HEADER_RE.sub("", text) +def _message_text_content(content: str | list[ClaudeInputContentBlock]) -> str: + if isinstance(content, str): + return content + return "".join( + block.text for block in content if isinstance(block, ClaudeTextBlock) + ) + + async def handle_image_block(block: ClaudeImageBlock) -> Base64Image | None: if block.source.type == "base64" and block.source.data: return Base64Image(block.source.data) @@ -108,25 +118,37 @@ async def handle_image_block(block: ClaudeImageBlock) -> Base64Image | None: async def claude_request_to_text_generation( request: ClaudeMessagesRequest, ) -> TextGenerationTaskParams: - # Handle system message + # Handle the system prompt: the top-level `system` field, plus any + # system-role entries that Anthropic-format clients (notably Claude Code) + # send inside `messages` even though the official API keeps them separate. instructions: str | None = None chat_template_messages: list[dict[str, ChatTemplateValue]] = [] images: list[Base64Image] = [] - if request.system: + system_texts: list[str] = [] + if request.system is not None: if isinstance(request.system, str): - instructions = request.system + system_texts.append(request.system) else: - instructions = "".join(block.text for block in request.system) + system_texts.append("".join(block.text for block in request.system)) - instructions = _strip_volatile_headers(instructions) + conversation_messages: list[ClaudeMessage] = [] + for msg in request.messages: + if msg.role == "system": + system_texts.append(_message_text_content(msg.content)) + else: + conversation_messages.append(msg) + + combined_system_text = "\n\n".join(text for text in system_texts if text) + if combined_system_text: + instructions = _strip_volatile_headers(combined_system_text) chat_template_messages.append( {"role": "system", "content": InputMessageContent(instructions)} ) # Convert messages to input input_messages: list[InputMessage] = [] - for msg in request.messages: + for msg in conversation_messages: if isinstance(msg.content, str): input_messages.append( InputMessage(role=msg.role, content=InputMessageContent(msg.content)) diff --git a/src/exo/api/tests/test_claude_api.py b/src/exo/api/tests/test_claude_api.py index f31e057ab5..1a37d5d4d5 100644 --- a/src/exo/api/tests/test_claude_api.py +++ b/src/exo/api/tests/test_claude_api.py @@ -180,3 +180,104 @@ def test_request_requires_messages(self): "max_tokens": 100, } ) + + +class TestSystemRoleMessages: + """Tests for system-role entries sent inside `messages` (issue #2193). + + The official Anthropic API keeps the system prompt in the top-level + `system` field, but real-world clients (notably Claude Code pointed at + exo via ANTHROPIC_BASE_URL) also send system-role messages inside + `messages`; those must be accepted and folded into the system prompt. + """ + + def test_system_role_message_passes_validation(self): + request = ClaudeMessagesRequest.model_validate( + { + "model": "claude-3-opus", + "max_tokens": 32, + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "system", "content": "be brief"}, + ], + } + ) + + assert request.messages[1].role == "system" + + async def test_system_role_message_becomes_instructions(self): + request = ClaudeMessagesRequest( + model=ModelId("claude-3-opus"), + max_tokens=100, + messages=[ + ClaudeMessage(role="system", content="You are a pirate."), + ClaudeMessage(role="user", content="Hello"), + ], + ) + params = await claude_request_to_text_generation(request) + + assert params.instructions == "You are a pirate." + assert isinstance(params.input, list) + assert [message.role for message in params.input] == ["user"] + + async def test_system_role_messages_merge_with_top_level_system(self): + request = ClaudeMessagesRequest( + model=ModelId("claude-3-opus"), + max_tokens=100, + system="Top-level system prompt.", + messages=[ + ClaudeMessage(role="system", content="In-band system prompt."), + ClaudeMessage(role="user", content="Hello"), + ], + ) + params = await claude_request_to_text_generation(request) + + assert ( + params.instructions == "Top-level system prompt.\n\nIn-band system prompt." + ) + assert isinstance(params.input, list) + assert [message.role for message in params.input] == ["user"] + + async def test_system_role_message_with_text_blocks(self): + request = ClaudeMessagesRequest( + model=ModelId("claude-3-opus"), + max_tokens=100, + messages=[ + ClaudeMessage( + role="system", + content=[ + ClaudeTextBlock(text="Be brief."), + ClaudeTextBlock(text=" Be kind."), + ], + ), + ClaudeMessage(role="user", content="Hello"), + ], + ) + params = await claude_request_to_text_generation(request) + + assert params.instructions == "Be brief. Be kind." + + async def test_conversation_order_preserved_without_system_entries(self): + request = ClaudeMessagesRequest( + model=ModelId("claude-3-opus"), + max_tokens=100, + messages=[ + ClaudeMessage(role="user", content="One"), + ClaudeMessage(role="system", content="Rules"), + ClaudeMessage(role="assistant", content="Two"), + ClaudeMessage(role="user", content="Three"), + ], + ) + params = await claude_request_to_text_generation(request) + + assert isinstance(params.input, list) + assert [message.role for message in params.input] == [ + "user", + "assistant", + "user", + ] + assert [str(message.content) for message in params.input] == [ + "One", + "Two", + "Three", + ] diff --git a/src/exo/api/types/claude_api.py b/src/exo/api/types/claude_api.py index 645da46398..01acea1cdb 100644 --- a/src/exo/api/types/claude_api.py +++ b/src/exo/api/types/claude_api.py @@ -19,7 +19,7 @@ class ClaudeToolDefinition(BaseModel, frozen=True): # Type aliases -ClaudeRole = Literal["user", "assistant"] +ClaudeRole = Literal["user", "assistant", "system"] ClaudeStopReason = Literal["end_turn", "max_tokens", "stop_sequence", "tool_use"]