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
1 change: 1 addition & 0 deletions kiro/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ def _get_raw_env_value(var_name: str, env_file: str = ".env") -> Optional[str]:
{"modelId": "claude-opus-4.5"},
{"modelId": "claude-opus-4.6"},
{"modelId": "claude-opus-4.7"},
{"modelId": "claude-opus-4.8"},
{"modelId": "deepseek-3.2"},
{"modelId": "glm-5"},
{"modelId": "minimax-m2.1"},
Expand Down
73 changes: 72 additions & 1 deletion kiro/models_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,72 @@ class AnthropicMessagesRequest(BaseModel):

model_config = {"extra": "allow"}

@model_validator(mode="before")
@classmethod
def _hoist_system(cls, data: Any) -> Any:
return _hoist_system_messages(data)


def _hoist_system_messages(data: Any) -> Any:
"""
Move any inline ``role: "system"`` messages out of ``messages`` and into
the top-level ``system`` field. Some clients (Claude Code, LangChain,
older OpenAI-style callers) send system prompts inside the messages
array, but Anthropic's schema only accepts ``user``/``assistant`` there.
"""
if not isinstance(data, dict):
return data
messages = data.get("messages")
if not isinstance(messages, list):
return data

system_texts: List[str] = []
remaining: List[Any] = []
found_system = False
for msg in messages:
if isinstance(msg, dict) and msg.get("role") == "system":
found_system = True
content = msg.get("content")
if isinstance(content, str):
if content:
system_texts.append(content)
elif isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text")
if isinstance(text, str) and text:
system_texts.append(text)
elif isinstance(block, str) and block:
system_texts.append(block)
continue
remaining.append(msg)

# No inline system messages at all: leave the request untouched.
if not found_system:
return data

# Always strip inline system messages from `messages` — even empty ones,
# which would otherwise fail per-message validation with a 422.
data["messages"] = remaining

# Nothing to merge into `system` if every inline system message was empty.
if not system_texts:
return data

existing_system = data.get("system")
if existing_system is None:
data["system"] = "\n\n".join(system_texts)
elif isinstance(existing_system, str):
data["system"] = "\n\n".join([existing_system, *system_texts])
elif isinstance(existing_system, list):
data["system"] = list(existing_system) + [
{"type": "text", "text": t} for t in system_texts
]
else:
data["system"] = "\n\n".join(system_texts)

return data


class AnthropicCountTokensRequest(BaseModel):
"""
Expand All @@ -360,9 +426,14 @@ class AnthropicCountTokensRequest(BaseModel):
# Optional parameters - only those that affect token count
system: Optional[SystemPrompt] = None
tools: Optional[List[AnthropicTool]] = None

model_config = {"extra": "allow"}

@model_validator(mode="before")
@classmethod
def _hoist_system(cls, data: Any) -> Any:
return _hoist_system_messages(data)


# ==================================================================================================
# Response Models
Expand Down
36 changes: 34 additions & 2 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -681,14 +681,46 @@ async def test_fallback_models_appear_in_available_models(self):
print(f"Available models: {available}")
print(f"Comparing length: Expected {len(FALLBACK_MODELS)}, Got {len(available)}")
assert len(available) == len(FALLBACK_MODELS)

# Verify all fallback models are present
fallback_ids = {m["modelId"] for m in FALLBACK_MODELS}
available_set = set(available)

print(f"Comparing sets: Expected {fallback_ids}, Got {available_set}")
assert fallback_ids == available_set

@pytest.mark.asyncio
async def test_opus_4_8_present_and_resolves(self):
"""
What it does: Verifies claude-opus-4.8 is in FALLBACK_MODELS and resolves from cache.
Purpose: Regression guard — opus 4.8 was missing from the fallback list, so requests
for it were treated as unknown pass-through models and rejected by Kiro.
"""
print("Setup: Importing FALLBACK_MODELS and creating cache...")
from kiro.config import FALLBACK_MODELS
from kiro.cache import ModelInfoCache
from kiro.model_resolver import ModelResolver

model_ids = {m["modelId"] for m in FALLBACK_MODELS}
print(f"Verification: 'claude-opus-4.8' in fallback list...")
assert "claude-opus-4.8" in model_ids

cache = ModelInfoCache()
await cache.update(FALLBACK_MODELS)
resolver = ModelResolver(cache=cache, hidden_models={})

# Both dot and dash client formats must resolve to the cached model
for input_name in ("claude-opus-4.8", "claude-opus-4-8"):
print(f"\n Testing: {input_name}")
resolution = resolver.resolve(input_name)

print(f" Normalized: {resolution.normalized}, source: {resolution.source}")
assert resolution.normalized == "claude-opus-4.8"
assert resolution.source == "cache", (
f"Model {input_name} should resolve from cache, not pass-through"
)
assert resolution.is_verified is True


# ==================================================================================================
# Tests for WebSearch Configuration
Expand Down
234 changes: 233 additions & 1 deletion tests/unit/test_models_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
# Request models
SystemContentBlock,
AnthropicMessagesRequest,
AnthropicCountTokensRequest,
# Response models
AnthropicUsage,
AnthropicMessagesResponse,
Expand Down Expand Up @@ -1753,7 +1754,238 @@ def test_thinking_disabled(self):
max_tokens=1024,
thinking={"type": "disabled"}
)

print(f"Comparing thinking: got={request.thinking}")
assert request.thinking is not None
assert request.thinking["type"] == "disabled"


# ==================================================================================================
# Tests for Inline System Message Hoisting (422 fix)
# ==================================================================================================

class TestInlineSystemMessageHoisting:
"""
Tests for hoisting inline ``role: "system"`` messages into the top-level
``system`` field.

Some clients (Claude Code, LangChain, OpenAI-style callers) put the system
prompt inside the ``messages`` array. Anthropic's schema only accepts
``user``/``assistant`` roles there, so those requests failed with a 422
``literal_error``. The ``mode="before"`` validator moves them out before
per-message validation runs.
"""

def test_inline_system_message_hoisted_to_system_field(self):
"""
What it does: Verifies a single inline system message becomes the top-level system field.
Purpose: PRIMARY test for the 422 "Input should be 'user' or 'assistant'" fix.
"""
print("Setup: Creating request with inline system message...")
request = AnthropicMessagesRequest(
model="claude-opus-4.8",
max_tokens=1024,
messages=[
{"role": "user", "content": "hi"},
{"role": "system", "content": "You are helpful."},
{"role": "assistant", "content": "ok"},
],
)

print(f"Comparing system: Expected 'You are helpful.', Got '{request.system}'")
assert request.system == "You are helpful."

print(f"Comparing messages count: Expected 2, Got {len(request.messages)}")
assert len(request.messages) == 2
assert [m.role for m in request.messages] == ["user", "assistant"]

def test_multiple_inline_system_messages_joined(self):
"""
What it does: Verifies multiple inline system messages are joined with blank lines.
Purpose: Ensure order is preserved and all system text is retained.
"""
print("Setup: Creating request with multiple inline system messages...")
request = AnthropicMessagesRequest(
model="claude-opus-4.8",
max_tokens=1024,
messages=[
{"role": "system", "content": "First rule."},
{"role": "user", "content": "hi"},
{"role": "system", "content": "Second rule."},
],
)

print(f"Comparing system: Got '{request.system}'")
assert request.system == "First rule.\n\nSecond rule."

print(f"Comparing messages count: Expected 1, Got {len(request.messages)}")
assert len(request.messages) == 1
assert request.messages[0].role == "user"

def test_inline_system_merged_with_existing_string_system(self):
"""
What it does: Verifies inline system text is appended to an existing string system field.
Purpose: Ensure we never clobber a top-level system prompt the client already sent.
"""
print("Setup: Creating request with both top-level and inline system...")
request = AnthropicMessagesRequest(
model="claude-opus-4.8",
max_tokens=1024,
system="Top-level prompt.",
messages=[
{"role": "system", "content": "Inline prompt."},
{"role": "user", "content": "hi"},
],
)

print(f"Comparing system: Got '{request.system}'")
assert request.system == "Top-level prompt.\n\nInline prompt."

def test_inline_system_merged_with_existing_list_system(self):
"""
What it does: Verifies inline system text is appended as blocks to a list-form system field.
Purpose: Ensure prompt-caching (list) system format is preserved and extended.
"""
print("Setup: Creating request with list-form system + inline system...")
request = AnthropicMessagesRequest(
model="claude-opus-4.8",
max_tokens=1024,
system=[{"type": "text", "text": "Cached prompt."}],
messages=[
{"role": "system", "content": "Inline prompt."},
{"role": "user", "content": "hi"},
],
)

print(f"Comparing system: Got '{request.system}'")
assert isinstance(request.system, list)
assert len(request.system) == 2
assert request.system[0].text == "Cached prompt."
assert request.system[1].text == "Inline prompt."

def test_inline_system_with_block_content(self):
"""
What it does: Verifies system messages whose content is a list of text blocks are hoisted.
Purpose: Clients may send system content as [{"type":"text","text":...}], not a plain string.
"""
print("Setup: Creating request with block-form inline system content...")
request = AnthropicMessagesRequest(
model="claude-opus-4.8",
max_tokens=1024,
messages=[
{
"role": "system",
"content": [
{"type": "text", "text": "Part A."},
{"type": "text", "text": "Part B."},
],
},
{"role": "user", "content": "hi"},
],
)

print(f"Comparing system: Got '{request.system}'")
assert request.system == "Part A.\n\nPart B."

def test_no_system_message_is_passthrough(self):
"""
What it does: Verifies requests without inline system messages are unchanged.
Purpose: Ensure the validator is a no-op when there is nothing to hoist.
"""
print("Setup: Creating request with no inline system messages...")
request = AnthropicMessagesRequest(
model="claude-opus-4.8",
max_tokens=1024,
messages=[
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
],
)

print(f"Comparing system: Expected None, Got {request.system}")
assert request.system is None

print(f"Comparing messages count: Expected 2, Got {len(request.messages)}")
assert len(request.messages) == 2

def test_empty_inline_system_content_ignored(self):
"""
What it does: Verifies an inline system message with empty content adds no system text.
Purpose: Ensure empty strings don't produce a spurious top-level system field.
"""
print("Setup: Creating request with empty inline system content...")
request = AnthropicMessagesRequest(
model="claude-opus-4.8",
max_tokens=1024,
messages=[
{"role": "system", "content": ""},
{"role": "user", "content": "hi"},
],
)

print(f"Comparing system: Expected None, Got {request.system}")
assert request.system is None

print(f"Comparing messages count: Expected 1, Got {len(request.messages)}")
assert len(request.messages) == 1
assert request.messages[0].role == "user"

def test_all_system_messages_leaves_valid_messages(self):
"""
What it does: Verifies that after hoisting, a request keeping at least one real message validates.
Purpose: Ensure min_length constraint on messages is satisfied by remaining messages.
"""
print("Setup: Creating request where system precedes a single user message...")
request = AnthropicMessagesRequest(
model="claude-opus-4.8",
max_tokens=1024,
messages=[
{"role": "system", "content": "Be concise."},
{"role": "user", "content": "hi"},
],
)

print(f"Comparing system: Got '{request.system}'")
assert request.system == "Be concise."
assert len(request.messages) == 1

def test_count_tokens_request_hoists_system(self):
"""
What it does: Verifies AnthropicCountTokensRequest applies the same hoisting.
Purpose: Consistency — the count_tokens endpoint shares the schema and must not 422 either.
"""
print("Setup: Creating AnthropicCountTokensRequest with inline system...")
request = AnthropicCountTokensRequest(
model="claude-opus-4.8",
messages=[
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "hi"},
],
)

print(f"Comparing system: Expected 'You are helpful.', Got '{request.system}'")
assert request.system == "You are helpful."

print(f"Comparing messages count: Expected 1, Got {len(request.messages)}")
assert len(request.messages) == 1
assert request.messages[0].role == "user"

def test_pydantic_object_messages_still_validate(self):
"""
What it does: Verifies passing AnthropicMessage objects (not dicts) still works.
Purpose: The validator only hoists dict system messages; typed user/assistant
objects must pass through untouched.
"""
print("Setup: Creating request from AnthropicMessage objects...")
request = AnthropicMessagesRequest(
model="claude-opus-4.8",
max_tokens=1024,
messages=[
AnthropicMessage(role="user", content="hi"),
AnthropicMessage(role="assistant", content="hello"),
],
)

print(f"Comparing messages count: Expected 2, Got {len(request.messages)}")
assert len(request.messages) == 2
assert request.system is None