Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions src/exo/api/adapters/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
ClaudeContentBlockStartEvent,
ClaudeContentBlockStopEvent,
ClaudeImageBlock,
ClaudeInputContentBlock,
ClaudeInputJsonDelta,
ClaudeMessage,
ClaudeMessageDelta,
ClaudeMessageDeltaEvent,
ClaudeMessageDeltaUsage,
Expand Down Expand Up @@ -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)
Expand All @@ -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))
Expand Down
101 changes: 101 additions & 0 deletions src/exo/api/tests/test_claude_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
2 changes: 1 addition & 1 deletion src/exo/api/types/claude_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]


Expand Down