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
65 changes: 63 additions & 2 deletions kiro/converters_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
to the unified format used by converters_core.py.
"""

from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple

from loguru import logger

Expand Down Expand Up @@ -75,6 +75,50 @@ def convert_anthropic_content_to_text(content: Any) -> str:
return str(content) if content else ""


def extract_inline_system_messages(
messages: List[AnthropicMessage],
) -> Tuple[List[AnthropicMessage], str]:
"""
Separates inline system-role messages from the conversation.

The Anthropic API carries the system prompt in a dedicated top-level
``system`` field, but some clients (Claude Code 2.1.x and its IDE
extensions) also inline ``{"role": "system"}`` entries into the messages
array to deliver runtime context such as ``<system-reminder>`` blocks.

Those entries are not part of the user/assistant turn structure that Kiro
expects, so they are peeled off here and merged into the system prompt.
This mirrors convert_openai_messages_to_unified(), which already extracts
system messages the same way, keeping both API surfaces consistent.

Args:
messages: List of Anthropic messages, possibly containing system roles

Returns:
Tuple of:
- List of messages with system-role entries removed
- Combined text of the extracted system messages ("" if none)
"""
conversation_messages = []
system_parts = []

for msg in messages:
if msg.role == "system":
text = convert_anthropic_content_to_text(msg.content)
if text:
system_parts.append(text)
else:
conversation_messages.append(msg)

if system_parts:
logger.debug(
f"Extracted {len(system_parts)} inline system message(s) from messages array "
f"and merged into system prompt"
)

return conversation_messages, "\n".join(system_parts)


def extract_system_prompt(system: Any) -> str:
"""
Extracts system prompt text from Anthropic system field.
Expand Down Expand Up @@ -450,8 +494,18 @@ def anthropic_to_kiro(
Raises:
ValueError: If there are no messages to send
"""
# Peel off any inline system-role messages before building the conversation
# (Claude Code 2.1.x inlines <system-reminder> context this way)
conversation_messages, inline_system_prompt = extract_inline_system_messages(request.messages)

# Convert messages to unified format
unified_messages = convert_anthropic_messages(request.messages)
unified_messages = convert_anthropic_messages(conversation_messages)

# If the request contained nothing but system messages, keep a minimal user
# turn so the payload stays valid (Kiro requires a current user message)
if not unified_messages:
logger.debug("All messages were system-role; inserting placeholder user message")
unified_messages = [UnifiedMessage(role="user", content="(empty placeholder)")]

# Convert tools to unified format
unified_tools = convert_anthropic_tools(request.tools)
Expand All @@ -460,6 +514,13 @@ def anthropic_to_kiro(
# It can be a string or list of content blocks (for prompt caching)
system_prompt = extract_system_prompt(request.system)

# Append inline system content after the top-level system prompt, preserving
# the order the client sent it in
if inline_system_prompt:
system_prompt = (
f"{system_prompt}\n{inline_system_prompt}" if system_prompt else inline_system_prompt
)

# Get model ID for Kiro API (normalizes + resolves hidden models)
# Pass-through principle: we normalize and send to Kiro, Kiro decides if valid
model_id = get_model_id_for_kiro(request.model, HIDDEN_MODELS)
Expand Down
8 changes: 6 additions & 2 deletions kiro/models_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,15 @@ class AnthropicMessage(BaseModel):
Message in Anthropic format.

Attributes:
role: Message role (user or assistant)
role: Message role. Normally "user" or "assistant", but accepted as a
free-form string: clients (e.g. Claude Code) inject mid-conversation
"system" messages, and other roles such as "developer" appear in the
wild. Unknown roles are normalized to "user" downstream by
normalize_message_roles() before the payload reaches Kiro.
content: Message content (string or list of content blocks)
"""

role: Literal["user", "assistant"]
role: str
content: Union[str, List[ContentBlock]]

model_config = {"extra": "allow"}
Expand Down
217 changes: 217 additions & 0 deletions tests/unit/test_converters_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
extract_images_from_tool_results,
extract_tool_uses_from_anthropic_content,
convert_anthropic_messages,
extract_inline_system_messages,
convert_anthropic_tools,
anthropic_to_kiro,
extract_thinking_config_from_anthropic,
Expand Down Expand Up @@ -1884,3 +1885,219 @@ def test_extracts_and_passes_thinking_config(self):
print(f"Checking for <max_thinking_length>6000</max_thinking_length>...")
assert "<max_thinking_length>6000</max_thinking_length>" in content
assert "<thinking_mode>enabled</thinking_mode>" in content


# ==================================================================================================
# Tests for extract_inline_system_messages
# ==================================================================================================

class TestExtractInlineSystemMessages:
"""
Tests for extract_inline_system_messages function.

Regression coverage for issues #190, #219, #226, #255: Claude Code 2.1.x
inlines role="system" messages into the messages array.
"""

def test_extracts_single_system_message(self):
"""
What it does: Verifies a system message is peeled off and its text returned.
Purpose: Inline system content must leave the conversation and become system prompt.
"""
print("Setup: user + system + user messages...")
messages = [
AnthropicMessage(role="user", content="First"),
AnthropicMessage(role="system", content="<system-reminder>Context</system-reminder>"),
AnthropicMessage(role="user", content="Second"),
]

print("Action: Extracting inline system messages...")
conversation, system_text = extract_inline_system_messages(messages)

print(f"Comparing conversation length: Expected 2, Got {len(conversation)}")
assert len(conversation) == 2
assert all(m.role == "user" for m in conversation)
print("Checking extracted system text...")
assert system_text == "<system-reminder>Context</system-reminder>"

def test_extracts_system_message_with_content_blocks(self):
"""
What it does: Verifies extraction from list-of-blocks content.
Purpose: Claude Code sends system content as blocks with cache_control.
"""
print("Setup: system message with content blocks...")
messages = [
AnthropicMessage(role="user", content="Hi"),
AnthropicMessage(
role="system",
content=[{"type": "text", "text": "Agent types: claude", "cache_control": {"type": "ephemeral"}}],
),
]

print("Action: Extracting inline system messages...")
conversation, system_text = extract_inline_system_messages(messages)

print(f"Comparing conversation length: Expected 1, Got {len(conversation)}")
assert len(conversation) == 1
print("Checking text extracted, cache_control ignored...")
assert system_text == "Agent types: claude"

def test_joins_multiple_system_messages_in_order(self):
"""
What it does: Verifies multiple system messages are joined in send order.
Purpose: Ordering must be preserved so context reads coherently.
"""
print("Setup: two system messages...")
messages = [
AnthropicMessage(role="system", content="First rule"),
AnthropicMessage(role="user", content="Question"),
AnthropicMessage(role="system", content="Second rule"),
]

print("Action: Extracting inline system messages...")
conversation, system_text = extract_inline_system_messages(messages)

print(f"Comparing conversation length: Expected 1, Got {len(conversation)}")
assert len(conversation) == 1
print(f"Comparing joined text: Got {system_text!r}")
assert system_text == "First rule\nSecond rule"

def test_no_system_messages_returns_unchanged(self):
"""
What it does: Verifies a normal conversation passes through untouched.
Purpose: Ensure the common path is not altered.
"""
print("Setup: normal user/assistant conversation...")
messages = [
AnthropicMessage(role="user", content="Hi"),
AnthropicMessage(role="assistant", content="Hello"),
]

print("Action: Extracting inline system messages...")
conversation, system_text = extract_inline_system_messages(messages)

print(f"Comparing conversation length: Expected 2, Got {len(conversation)}")
assert len(conversation) == 2
print(f"Comparing system text: Expected '', Got {system_text!r}")
assert system_text == ""

def test_skips_empty_system_message(self):
"""
What it does: Verifies an empty system message adds no stray separator.
Purpose: Avoid injecting blank lines into the system prompt.
"""
print("Setup: system message with empty content...")
messages = [
AnthropicMessage(role="user", content="Hi"),
AnthropicMessage(role="system", content=""),
]

print("Action: Extracting inline system messages...")
conversation, system_text = extract_inline_system_messages(messages)

print(f"Comparing system text: Expected '', Got {system_text!r}")
assert system_text == ""
assert len(conversation) == 1


class TestInlineSystemMessagesEndToEnd:
"""Integration tests for inline system messages through anthropic_to_kiro."""

def test_system_role_accepted_by_model(self):
"""
What it does: Verifies AnthropicMessage accepts role="system".
Purpose: The 422 in issues #190/#219/#226/#255 happened at Pydantic validation.
"""
print("Creating a message with role='system'...")
msg = AnthropicMessage(role="system", content="Runtime context")

print(f"Comparing role: Expected 'system', Got {msg.role}")
assert msg.role == "system"

def test_inline_system_merged_into_system_prompt(self):
"""
What it does: Verifies inline system text lands in the system prompt, and no
synthetic assistant turn is fabricated between the user turns.
Purpose: The exact failing shape from issue #255 must convert cleanly.
"""
print("Creating request: user message followed by inline system message...")
request = AnthropicMessagesRequest(
model="claude-sonnet-4.5",
messages=[
AnthropicMessage(role="user", content="What is the branch structure?"),
AnthropicMessage(role="system", content="INLINE_CONTEXT_MARKER"),
],
max_tokens=1024,
system=[{"type": "text", "text": "BASE_PROMPT_MARKER"}],
)

print("Calling anthropic_to_kiro...")
with patch("kiro.converters_anthropic.get_model_id_for_kiro", return_value="claude-sonnet-4.5"):
payload = anthropic_to_kiro(request, "test-conv-123", "arn:aws:test")

conversation_state = payload["conversationState"]
history = conversation_state.get("history", [])
content = conversation_state["currentMessage"]["userInputMessage"]["content"]

print(f"Comparing history length: Expected 0, Got {len(history)}")
assert len(history) == 0, "No synthetic assistant turn should be fabricated"

print("Checking system prompt ordering: base before inline...")
assert "BASE_PROMPT_MARKER" in content
assert "INLINE_CONTEXT_MARKER" in content
assert content.index("BASE_PROMPT_MARKER") < content.index("INLINE_CONTEXT_MARKER")

print("Checking the user's actual question survived...")
assert "What is the branch structure?" in content

def test_system_only_request_gets_placeholder_user_turn(self):
"""
What it does: Verifies a messages array of only system entries still converts.
Purpose: Stripping all messages must not raise "No messages to send".
"""
print("Creating request with only a system message...")
request = AnthropicMessagesRequest(
model="claude-sonnet-4.5",
messages=[AnthropicMessage(role="system", content="ONLY_SYSTEM_MARKER")],
max_tokens=1024,
)

print("Calling anthropic_to_kiro...")
with patch("kiro.converters_anthropic.get_model_id_for_kiro", return_value="claude-sonnet-4.5"):
payload = anthropic_to_kiro(request, "test-conv-123", "arn:aws:test")

content = payload["conversationState"]["currentMessage"]["userInputMessage"]["content"]

print("Checking system text preserved and placeholder inserted...")
assert "ONLY_SYSTEM_MARKER" in content
assert "(empty placeholder)" in content

def test_normal_conversation_unaffected(self):
"""
What it does: Verifies a standard conversation still builds normal history.
Purpose: Guard against regression on the common path.
"""
print("Creating a normal multi-turn request...")
request = AnthropicMessagesRequest(
model="claude-sonnet-4.5",
messages=[
AnthropicMessage(role="user", content="First question"),
AnthropicMessage(role="assistant", content="First answer"),
AnthropicMessage(role="user", content="Second question"),
],
max_tokens=1024,
)

print("Calling anthropic_to_kiro...")
with patch("kiro.converters_anthropic.get_model_id_for_kiro", return_value="claude-sonnet-4.5"):
payload = anthropic_to_kiro(request, "test-conv-123", "arn:aws:test")

conversation_state = payload["conversationState"]
history = conversation_state.get("history", [])
content = conversation_state["currentMessage"]["userInputMessage"]["content"]

print(f"Comparing history length: Expected 2 entries, Got {len(history)}")
assert len(history) == 2
assert "userInputMessage" in history[0]
assert "assistantResponseMessage" in history[1]
assert "Second question" in content
15 changes: 8 additions & 7 deletions tests/unit/test_routes_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,12 +337,13 @@ def test_validates_invalid_json(self, test_client, valid_proxy_api_key):
print(f"Status: {response.status_code}")
assert response.status_code == 422

def test_validates_invalid_role(self, test_client, valid_proxy_api_key):
def test_accepts_unknown_role(self, test_client, valid_proxy_api_key):
"""
What it does: Verifies invalid message role is rejected.
Purpose: Anthropic model strictly validates role (only 'user' or 'assistant').
What it does: Verifies an unknown message role is not rejected at validation.
Purpose: Clients (e.g. Claude Code) inject mid-conversation 'system' messages.
Unknown roles are normalized to 'user' downstream, so they must not 422.
"""
print("Action: POST /v1/messages with invalid role...")
print("Action: POST /v1/messages with an unknown role...")
response = test_client.post(
"/v1/messages",
headers={"x-api-key": valid_proxy_api_key},
Expand All @@ -352,10 +353,10 @@ def test_validates_invalid_role(self, test_client, valid_proxy_api_key):
"messages": [{"role": "invalid_role", "content": "Hello"}]
}
)

print(f"Status: {response.status_code}")
# Anthropic model strictly validates role - only 'user' or 'assistant' allowed
assert response.status_code == 422
# Must pass Pydantic validation; failures beyond that are upstream/auth concerns
assert response.status_code != 422

def test_accepts_valid_request_format(self, test_client, valid_proxy_api_key):
"""
Expand Down