Skip to content

fix(anthropic): accept inline system messages in messages array - #264

Open
willgosnold wants to merge 1 commit into
jwadow:mainfrom
willgosnold:fix/inline-system-messages
Open

fix(anthropic): accept inline system messages in messages array#264
willgosnold wants to merge 1 commit into
jwadow:mainfrom
willgosnold:fix/inline-system-messages

Conversation

@willgosnold

Copy link
Copy Markdown

Summary

Claude Code 2.1.x and its IDE extensions deliver runtime context — <system-reminder> blocks, currentDate, agent-type listings — as messages with role: "system" inside the messages array, not only in the top-level system field. AnthropicMessage.role was Literal["user", "assistant"], so Pydantic rejected these with HTTP 422 before the converter ever ran, and the client could not complete a single turn.

  • Relax AnthropicMessage.role to str
  • Add extract_inline_system_messages() to peel role="system" entries out of the conversation and merge their text into the system prompt
  • Fall back to a placeholder user turn when the messages array contains nothing but system entries

Fixes #190
Fixes #219
Fixes #226
Fixes #255

Root cause

{"type": "literal_error", "loc": ["body", "messages", 1, "role"],
 "msg": "Input should be 'user' or 'assistant'", "input": "system"}

Minimal repro against current main:

curl -sS -X POST http://localhost:8000/v1/messages \
  -H "x-api-key: $PROXY_API_KEY" -H 'content-type: application/json' \
  -d '{"model":"claude-sonnet-4-5","max_tokens":16,
       "messages":[{"role":"user","content":"hi"},
                   {"role":"system","content":"context blob"}]}'

normalize_message_roles() already knows how to handle unrecognised roles (added for developer in #64), but validation rejects the request before it can run.

Why hoist instead of normalising to "user"

Two fixes were proposed across the linked issues: widen the Literal and let normalize_message_roles() fold the message into "user", or hoist the content into the system prompt. This PR hoists, for three reasons:

  1. Consistency across API surfaces. convert_openai_messages_to_unified() (kiro/converters_openai.py:161) already extracts inline system messages and merges them into the system prompt. Normalising to "user" on the Anthropic path would leave the two surfaces behaving differently on identical input.
  2. Preserves user intent. The content is instructions; hoisting keeps it as instructions rather than rewriting it into a user turn.
  3. Avoids fabricating assistant turns. Normalising to "user" leaves two adjacent user messages. normalize_message_roles() runs after merge_adjacent_messages() in build_kiro_payload(), so they never merge, and ensure_alternating_roles() then inserts a synthetic (empty placeholder) assistant message between them — inventing an assistant reply that never happened, mid-conversation. Hoisting removes the messages before any of that logic sees them, so the core pipeline needs no reordering.

Changes

  • kiro/models_anthropic.pyAnthropicMessage.role: Literal["user", "assistant"]str. Chosen over Literal[..., "system"] for forward compatibility, since clients have already shipped developer (BUG: API error calling from codex App #64) and may ship more; unrecognised roles are still handled downstream.
  • kiro/converters_anthropic.py — new extract_inline_system_messages(), wired into anthropic_to_kiro(). Inline text is appended after the top-level system content so the client's ordering is preserved. A system-only messages array falls back to an (empty placeholder) user turn instead of raising ValueError("No messages to send").

/v1/messages/count_tokens (raised in #255) needed no separate change — it counts message text irrespective of role, so content is still counted once validation accepts it. Verified below.

Tests

9 new tests in tests/unit/test_converters_anthropic.py.

TestExtractInlineSystemMessages — single extraction; list-of-blocks content with cache_control; multiple system messages joined in send order; no-system-messages passthrough; empty system message produces no stray separator.

TestInlineSystemMessagesEndToEndrole="system" accepted by the model; inline content merged into the system prompt with base-before-inline ordering asserted and no synthetic assistant turn in history; system-only array gets a placeholder turn; normal multi-turn conversation still builds correct history.

tests/unit/test_routes_anthropic.py::test_validates_invalid_role asserted the old 422 behaviour, so it is replaced by test_accepts_unknown_role, which asserts the request passes validation.

Test plan

  • Unit + integration tests above pass
  • E2E non-streaming: inline system message (the failing shape) → 200
  • E2E streaming: inline system message → 200
  • Hoisted content reaches the model — returned a codeword supplied only via an inline system message, confirming the content influences the response rather than being silently dropped
  • /v1/messages/count_tokens with an inline system message → 200
  • System-only messages array → 200
  • Regression: normal user/assistant conversation → 200, history unchanged
  • Full suite: no new failures (32 pre-existing failures in test_auth_manager.py/test_config.py are environment-dependent — local .env/credentials.json leaking into those tests — and are identical before and after this change)

@cla-bot

cla-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

Thank you for your pull request and welcome to our community. We could not parse the GitHub identity of the following contributors: Will Gosnold.
This is most likely caused by a git client misconfiguration; please make sure to:

  1. check if your git client is configured with an email to sign commits git config --list | grep email
  2. If not, set it up using git config --global user.email email@example.com
  3. Make sure that the git commit email is configured in your GitHub account settings, see https://github.com/settings/emails

…ow#190, jwadow#219, jwadow#226, jwadow#255)

Claude Code 2.1.x and its IDE extensions deliver runtime context (such as
<system-reminder> blocks and agent-type listings) as messages with
role="system" inside the messages array, rather than only in the
top-level system field.

AnthropicMessage.role was Literal["user", "assistant"], so these requests
failed Pydantic validation with HTTP 422 before reaching the converter.
The client could not complete a single turn.

Relax role to str and add extract_inline_system_messages(), which peels
system-role entries out of the conversation and merges their text into
the system prompt (top-level content first, inline content after, so the
client's ordering is preserved). This mirrors
convert_openai_messages_to_unified(), which already extracts system
messages the same way, keeping both API surfaces consistent.

Hoisting rather than normalizing to "user" keeps the content as
instructions instead of folding it into the user turn, and avoids
ensure_alternating_roles() fabricating a synthetic assistant reply
between a user turn and a following system message.

A messages array containing only system entries now falls back to a
placeholder user turn instead of raising "No messages to send".
@willgosnold
willgosnold force-pushed the fix/inline-system-messages branch from 619b416 to 188912d Compare July 27, 2026 15:12
@cla-bot

cla-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

Thanks for the PR! 🎉

Before merge, we need a one-time CLA confirmation.
It confirms that you have the right to contribute this code and allow the project to use it.

Full CLA text:
https://github.com/jwadow/kiro-gateway/blob/main/CLA.md

Please reply once with:

I have read the CLA and I accept its terms

You need to write once, all further messages from me can be ignored.

@willgosnold

Copy link
Copy Markdown
Author

I have read the CLA and I accept its terms

@ankitcharolia

Copy link
Copy Markdown

@willgosnold could you give a try to this gateway: https://github.com/ankitcharolia/kiro-gateway

It works quite well with All AI harness and actively being developed. The most important thing is that it is ACP compliant

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants