diff --git a/kiro/config.py b/kiro/config.py index e0f3a527..30c02654 100644 --- a/kiro/config.py +++ b/kiro/config.py @@ -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"}, diff --git a/kiro/models_anthropic.py b/kiro/models_anthropic.py index c63d60ba..9f11796a 100644 --- a/kiro/models_anthropic.py +++ b/kiro/models_anthropic.py @@ -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): """ @@ -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 diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index dae701e5..524bbb19 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -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 diff --git a/tests/unit/test_models_anthropic.py b/tests/unit/test_models_anthropic.py index 7c364c9c..b57febec 100644 --- a/tests/unit/test_models_anthropic.py +++ b/tests/unit/test_models_anthropic.py @@ -38,6 +38,7 @@ # Request models SystemContentBlock, AnthropicMessagesRequest, + AnthropicCountTokensRequest, # Response models AnthropicUsage, AnthropicMessagesResponse, @@ -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