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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ state.json
.shard/

# Python
.venv/
__pycache__/
*.py[cod]
*$py.class
Expand All @@ -30,4 +31,4 @@ requests/
# Testing
.pytest_cache/
.coverage
htmlcov/
htmlcov/
76 changes: 76 additions & 0 deletions fix_anthropic-system-message-compatibility_en.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
Branch: `main`
Title: `Fix: Kiro Gateway Anthropic system message compatibility`

# Description

- Fixes Claude Code requests that place a `system` message inside the Anthropic `messages` array.
- Promotes inline system instructions into the Kiro system prompt instead of rejecting them or treating them as user input.
- Preserves the existing user/assistant conversation history and adds regression coverage.

# Root Cause

Claude Code sent a request containing `messages[1].role = "system"`. The gateway schema only accepted `user` and `assistant` roles, so Pydantic rejected the request with HTTP 422 before conversion.

## Old Logic

- `AnthropicMessage.role` was limited to `user` and `assistant`.
- The converter attempted to process every message as conversation history.
- Inline system messages could not pass validation and never reached the Kiro adapter.

## New Logic

- Allow `system` as a compatibility role in `AnthropicMessage`.
- Extract all inline system messages before conversation conversion.
- Combine extracted system text in source order and merge it with the top-level Anthropic `system` field.
- Convert only user and assistant messages into Kiro conversation history.

## Why it works

- Claude Code's actual request shape is accepted by the API model.
- System instructions retain system semantics and are not silently converted into user messages.
- Existing conversation ordering remains unchanged.
- The new tests cover extraction, ordering, content blocks, and the complete Anthropic-to-Kiro payload.

# DoD

- [x] Accept Anthropic inline system messages.
- [x] Preserve system semantics during Kiro conversion.
- [x] Add regression tests for the reported HTTP 422 shape.
- [x] Run the targeted gateway test suite.
- [x] Verify a direct gateway request and a Claude Code smoke request.

# Code Changes

- `kiro/models_anthropic.py`
- Allows the compatibility `system` role in `AnthropicMessage`.
- `kiro/converters_anthropic.py`
- Adds inline system-message extraction and combines it with the top-level system prompt.
- Excludes system messages from user/assistant conversation history.
- `tests/unit/test_converters_anthropic.py`
- Adds regression coverage for extraction, ordering, content blocks, and final payload conversion.
- `.gitignore`
- Ignores the local Python virtual environment and normalizes the file ending.

# Validate Results

```bash
./.venv/bin/pytest -q \
tests/unit/test_models_anthropic.py \
tests/unit/test_converters_anthropic.py \
tests/unit/test_routes_anthropic.py
```

Result: `249 passed, 1 warning`.

Additional smoke checks:

- Direct Anthropic-compatible gateway request: HTTP 200.
- Claude Code request through the gateway: successful response.
- Claude Code model usage: `claude-opus-5`.
- `git diff --check`: passed.

# Commit Messages

- `Fix: Kiro Gateway Anthropic system message compatibility`
- Accepts Claude Code's inline system-message format.
- Preserves system prompt semantics and adds regression tests.
76 changes: 76 additions & 0 deletions fix_anthropic-system-message-compatibility_zh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
Branch: `main`
Title: `Fix: Kiro Gateway Anthropic system message compatibility`

# Description

- 修正 Claude Code 將 `system` message 放在 Anthropic `messages` 陣列內的請求格式。
- 將 inline system instructions 提升為 Kiro system prompt,不再拒絕或誤當成 user input。
- 保留既有 user/assistant 對話歷史,並新增 regression coverage。

# Root Cause

Claude Code 傳送了 `messages[1].role = "system"`。Gateway 原本的 schema 只接受 `user` 與 `assistant`,因此請求在轉換前就被 Pydantic 以 HTTP 422 拒絕。

## Old Logic

- `AnthropicMessage.role` 只允許 `user` 與 `assistant`。
- Converter 嘗試將每個 message 都當成對話歷史處理。
- Inline system message 無法通過 validation,也無法進入 Kiro adapter。

## New Logic

- 在 `AnthropicMessage` 中允許相容性用的 `system` role。
- 在轉換對話前,先抽出所有 inline system messages。
- 依照原始順序合併 system text,並與 Anthropic 頂層 `system` 欄位合併。
- 只有 user 與 assistant messages 會轉換為 Kiro 對話歷史。

## Why it works

- Claude Code 的實際請求格式可以通過 API model validation。
- System instructions 保留 system 語意,不會被靜默轉成 user message。
- 原有對話順序不受影響。
- 新增測試涵蓋 extraction、排序、content blocks,以及完整 Anthropic-to-Kiro payload。

# DoD

- [x] 接受 Anthropic inline system messages。
- [x] 在 Kiro conversion 中保留 system 語意。
- [x] 新增原始 HTTP 422 問題的 regression tests。
- [x] 執行 gateway targeted test suite。
- [x] 驗證 direct gateway request 與 Claude Code smoke request。

# Code Changes

- `kiro/models_anthropic.py`
- 允許 `AnthropicMessage` 使用相容性的 `system` role。
- `kiro/converters_anthropic.py`
- 新增 inline system-message extraction,並與頂層 system prompt 合併。
- 將 system messages 排除在 user/assistant conversation history 外。
- `tests/unit/test_converters_anthropic.py`
- 新增 extraction、排序、content blocks 與最終 payload conversion 的 regression coverage。
- `.gitignore`
- 忽略本地 Python virtual environment,並補齊檔案結尾換行。

# Validate Results

```bash
./.venv/bin/pytest -q \
tests/unit/test_models_anthropic.py \
tests/unit/test_converters_anthropic.py \
tests/unit/test_routes_anthropic.py
```

結果:`249 passed, 1 warning`。

其他 smoke checks:

- Direct Anthropic-compatible gateway request:HTTP 200。
- 經由 gateway 的 Claude Code request:成功回覆。
- Claude Code 實際使用模型:`claude-opus-5`。
- `git diff --check`:通過。

# Commit Messages

- `Fix: Kiro Gateway Anthropic system message compatibility`
- 接受 Claude Code 的 inline system-message 格式。
- 保留 system prompt 語意並新增 regression tests。
48 changes: 44 additions & 4 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 @@ -113,6 +113,37 @@ def extract_system_prompt(system: Any) -> str:
return str(system)


def split_anthropic_system_messages(
messages: List[AnthropicMessage],
) -> Tuple[str, List[AnthropicMessage]]:
"""
Extracts inline system messages sent by Claude Code compatibility clients.

The Anthropic Messages API normally carries system instructions in the
top-level ``system`` field. Some compatible clients send them inside the
messages array instead. Kiro only supports user and assistant history, so
inline system messages must be promoted before conversion.

Args:
messages: Anthropic messages that may include inline system messages.

Returns:
A tuple containing the combined system prompt and conversation messages.
"""
system_parts: List[str] = []
conversation_messages: List[AnthropicMessage] = []

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

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


def extract_tool_results_from_anthropic_content(content: Any) -> List[Dict[str, Any]]:
"""
Extracts tool results from Anthropic message content.
Expand Down Expand Up @@ -279,6 +310,9 @@ def convert_anthropic_messages(
total_images = 0

for msg in messages:
if msg.role == "system":
continue

role = msg.role
content = msg.content

Expand Down Expand Up @@ -450,15 +484,21 @@ def anthropic_to_kiro(
Raises:
ValueError: If there are no messages to send
"""
# Convert messages to unified format
unified_messages = convert_anthropic_messages(request.messages)
# Promote compatibility clients' inline system messages before conversion.
inline_system_prompt, conversation_messages = split_anthropic_system_messages(
request.messages
)

# Convert user/assistant messages to unified format
unified_messages = convert_anthropic_messages(conversation_messages)

# Convert tools to unified format
unified_tools = convert_anthropic_tools(request.tools)

# System prompt is already separate in Anthropic format!
# It can be a string or list of content blocks (for prompt caching)
system_prompt = extract_system_prompt(request.system)
system_prompt_parts = [extract_system_prompt(request.system), inline_system_prompt]
system_prompt = "\n\n".join(part for part in system_prompt_parts if part)

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

Attributes:
role: Message role (user or assistant)
role: Message role (user, assistant, or compatibility system)
content: Message content (string or list of content blocks)
"""

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

model_config = {"extra": "allow"}
Expand Down
63 changes: 63 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,
split_anthropic_system_messages,
convert_anthropic_tools,
anthropic_to_kiro,
extract_thinking_config_from_anthropic,
Expand Down Expand Up @@ -980,6 +981,41 @@ def test_converts_simple_assistant_message(self):
assert result[0].role == "assistant"
assert result[0].content == "Hi there!"

def test_skips_inline_system_messages(self):
"""
What it does: Verifies inline system messages are excluded from history.
Purpose: Prevent Claude Code compatibility system prompts from becoming user messages.
"""
messages = [
AnthropicMessage(role="system", content="Follow the project rules."),
AnthropicMessage(role="user", content="Hello!"),
]

result = convert_anthropic_messages(messages)

assert [message.role for message in result] == ["user"]
assert result[0].content == "Hello!"


class TestSplitAnthropicSystemMessages:
"""Tests for promoting inline system messages to the system prompt."""

def test_extracts_and_preserves_conversation_order(self):
messages = [
Comment on lines +1000 to +1004
AnthropicMessage(role="user", content="Hello"),
AnthropicMessage(role="system", content="Be concise."),
AnthropicMessage(role="assistant", content="Hi"),
AnthropicMessage(
role="system",
content=[{"type": "text", "text": "Use Traditional Chinese."}],
),
]

system_prompt, conversation_messages = split_anthropic_system_messages(messages)

assert system_prompt == "Be concise.\n\nUse Traditional Chinese."
assert [message.role for message in conversation_messages] == ["user", "assistant"]

def test_converts_user_message_with_content_blocks(self):
"""
What it does: Verifies conversion of user message with content blocks.
Expand Down Expand Up @@ -1499,6 +1535,33 @@ def test_includes_system_prompt(self):
print(f"Current content: {current_content}")
assert "You are a helpful assistant." in current_content

def test_promotes_inline_system_message_into_payload(self):
"""
What it does: Verifies the Claude Code inline system-message shape.
Purpose: Regression coverage for the reported 422 validation error.
"""
request = AnthropicMessagesRequest(
model="claude-opus-5",
messages=[
AnthropicMessage(role="user", content="Hello!"),
AnthropicMessage(role="system", content="Follow these instructions."),
],
max_tokens=1024,
)

with patch(
"kiro.converters_anthropic.get_model_id_for_kiro",
return_value="claude-opus-5",
):
with patch("kiro.converters_core.FAKE_REASONING_ENABLED", False):
result = anthropic_to_kiro(request, "conv-123", "arn:aws:test")

current_content = result["conversationState"]["currentMessage"][
"userInputMessage"
]["content"]
assert "Follow these instructions." in current_content
assert "Hello!" in current_content

def test_includes_tools(self):
"""
What it does: Verifies that tools are included in payload.
Expand Down