Conversation
A /v1/responses request carrying both `instructions` and a system-level
message inside `input` was accepted with a 200, the SSE stream was opened,
and the provider's rejection ("System message must be at the beginning.")
only arrived as a `response.failed` event inside the committed stream.
Clients cannot tell that apart from a mid-stream disconnect and retry
indefinitely.
`build_messages` always prepends a system message synthesized from
`instructions` (or from the default language and time context) and then
forwards each `input` message's role verbatim, so a `system` or `developer`
role inside `input` can never be the first message in the provider payload.
Validate that in `CreateResponseRequest::validate`, which the route already
runs before anything is committed, so the failure is a real 400 with the
provider's own explanatory sentence.
Fixes #1025
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8ae1494ce2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if let Some(role) = item.role() { | ||
| if SYSTEM_LEVEL_INPUT_ROLES.contains(&role) { |
There was a problem hiding this comment.
Scope the rejection to providers that require it
This admission check applies before model/provider resolution, so it rejects every Responses request containing a system or developer input item, including requests routed to providers that accept or normalize multiple system-level messages. For example, the repository's Anthropic conversion path explicitly extracts MessageRole::System entries into Anthropic's top-level system field rather than returning a position error, while OpenAI-compatible providers may accept these messages directly. Such requests previously reached the provider and could succeed; they now unconditionally return 400. Move this check after provider resolution and apply it only to affected providers/models, or normalize their system messages safely.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed, and fixed in de3f4a7 — though by folding rather than by scoping. Confirmed the break is real: /v1/responses serves external models too (openai_tiered_pricing.rs drives it with openai/*). One correction to the mechanism: developer never reaches any provider as developer — to_chat_messages (completions/mod.rs:1468) maps "system" | "developer" => MessageRole::System for every backend, Anthropic conversion included. So the second system message is built here, not sent by the client, and the same payload is what OpenAI accepts today.
Rather than resolving the provider first, a system-level input message that precedes all other content (on a request replaying no history) is now folded into the leading system message the service already prepends. Nothing crosses it, so the ordering is unchanged, it stays provider-agnostic, and the Codex request works everywhere instead of failing cleanly on some backends. Only genuinely unfoldable shapes are refused at admission.
Review — fail-fast for non-leading system messagesThanks for the unusually thorough write-up; the diagnosis in
|
Review · Status🟩 CompletedIronLoop completed the review and posted it to GitHub. ResultRun detailsAutomatic trigger · attempt 1 of 3 · completed in 3m 24s |
There was a problem hiding this comment.
Review · Summary
🟢 No actionable findings
No additional actionable findings were identified in the reviewed change.
Validation
- ✅ Change inspection — Reviewed all changed files and traced the new validation through response creation and provider-message conversion.
- ✅ Diff hygiene — No whitespace errors were found in the change.
Review details
- Run:
d3c13e75-6d01-40b6-b917-339407738413 - Attempts: 1
| // stream has been committed with a 200, where clients cannot | ||
| // tell it apart from a mid-stream disconnect. | ||
| if let Some(role) = item.role() { | ||
| if SYSTEM_LEVEL_INPUT_ROLES.contains(&role) { |
There was a problem hiding this comment.
The role field in ResponseInputItem::Message is a raw String deserialized without any case normalization, and the role is forwarded verbatim to the provider (role: role.clone() in build_messages). The SYSTEM_LEVEL_INPUT_ROLES.contains(&role) check is case-sensitive, so a client sending "System", "Developer", or "SYSTEM" bypasses this admission-time validation entirely. If the downstream provider normalizes role case internally, the request would still hit the mid-stream "System message must be at the beginning" failure that this check was specifically designed to prevent. The check should compare case-insensitively.
Suggestion:
| if SYSTEM_LEVEL_INPUT_ROLES.contains(&role) { | |
| if SYSTEM_LEVEL_INPUT_ROLES.contains(&role.to_ascii_lowercase().as_str()) { |
There was a problem hiding this comment.
Deliberately not taken, and now commented in the code with a unit test pinning it. The role does not reach the provider as a raw string: to_chat_messages (completions/mod.rs:1468) matches "system" | "developer" exactly and everything else falls to _ => MessageRole::User. So "System" or "Developer" becomes a user message, produces no system message at all, and cannot hit the mid-stream failure — comparing case-insensitively here would refuse a request that currently works.
If you would rather make that mapping itself case-insensitive, I am happy to; both places should then move together, and the pinning test flipped.
| return Err(format!( | ||
| "System message must be at the beginning. Input item at index {index} \ | ||
| has role '{role}', which is placed after the system message derived \ | ||
| from `instructions`. Send system-level content in the top-level \ | ||
| `instructions` field instead." | ||
| )); |
There was a problem hiding this comment.
The error message hardcodes "the system message derived from instructions", but build_messages (service.rs lines 2293–2301) also prepends a system message when instructions is None — synthesized from the default language instruction and time context. In that case the message is inaccurate and will confuse a client that never sent instructions. The suggestion to use the instructions field is still valid, but the diagnostic portion should account for the no-instructions path (e.g. "the system message derived from instructions or the default context"). All three new unit tests and both E2E tests set instructions, so this scenario is untested.
Suggestion:
| return Err(format!( | |
| "System message must be at the beginning. Input item at index {index} \ | |
| has role '{role}', which is placed after the system message derived \ | |
| from `instructions`. Send system-level content in the top-level \ | |
| `instructions` field instead." | |
| )); | |
| return Err(format!( | |
| "System message must be at the beginning. Input item at index {index} \ | |
| has role '{role}', which is placed after the leading system message \ | |
| (derived from `instructions` or the default context). Send system-level \ | |
| content in the top-level `instructions` field instead." | |
| )); |
There was a problem hiding this comment.
Fixed in de3f4a7 — the wording is now "the system message the service prepends", which holds whether or not instructions is set. Thanks, this was accurate.
Addresses review on #1026: rejecting every system-level `input` message at admission would have refused requests that work today. `/v1/responses` also serves external models, and `to_chat_messages` maps a `developer` role to `MessageRole::System` for every backend, so the Codex-shaped request reaches OpenAI as two system messages and is accepted there. Fold instead of refuse. A system-level `input` message that precedes all other content, on a request that replays no `conversation` or `previous_response_id` history, is appended to the system message `load_conversation_context` already prepends. Nothing crosses it, so the model is told the same thing in the same order, and the provider sees the single leading system message it requires. Only the shapes that cannot be folded without reordering are refused at admission: a system-level message after other content, one on a request that replays history ahead of it, and one carrying image content. Match `to_chat_messages` exactly on the role spelling rather than case-insensitively: it matches `"system" | "developer"` exactly, so `"Developer"` becomes a user message and produces no system message to misplace. Treating it as system-level here would refuse a working request. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks — point 1 was right and I have reworked the PR around it. Pushed as de3f4a7. On the break. Confirmed: Now folds. A system-level Point 2, stored items. The history cases are now refused at admission rather than reaching the provider misplaced, so the laundering route you describe returns a 400. What remains is a replayed item that was itself stored with a On case sensitivity — I deliberately did not take this one, and added a comment saying why. Since Error text reworded to "the system message the service prepends", which is true whether or not Full local run against PostgreSQL 15 in Docker: fmt and clippy clean, |
The previous revision still refused three shapes at admission: a system-level `input` message after other content, one behind replayed conversation history, and one carrying non-text content. That check was a pure role-and-position test with no provider or model scoping, so it applied to backends that accept those shapes today. The constraint it encoded belongs to one chat template. Qwen 3.6 raises "System message must be at the beginning." from its Jinja template, but the identical request succeeds against DeepSeek and GLM adapters, and `/v1/responses` serves those models too. Refusing at admission would generalize one template's rule gateway-wide and 400 requests that work. Forward those shapes unchanged instead. Nothing that works today breaks, Qwen behaves exactly as it does now on them, and the Codex fix is untouched because it comes from the fold, which is unchanged: a system-level message that precedes all other content is still folded into the leading system message. The fold guard already tests the messages actually built rather than the request fields, so no code change was needed to get pass-through. That also corrects an assumption in the previous revision: `previous_response_id` alone replays no history - it only selects a branch within a `conversation` - so a request carrying it is folded, and the e2e test covering replayed history now sets up a conversation. Tests that asserted a 400 for the three shapes now assert pass-through, at the provider payload rather than only at `validate`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Narrowed again in b685496 — the PR now refuses nothing. Reviewing my own previous revision, the objection about admission-time checks not knowing the provider reaches one step further than my fix did. The asymmetry is what settled it: the fold is provider-agnostic because it preserves what the model is told, so it is correct on any backend. A rejection is provider-specific reasoning applied provider-agnostically. Those three shapes are now forwarded unchanged — nothing that works today breaks, Qwen behaves exactly as it does now, and the Codex fix is untouched because it comes from the fold. One correction to something I said earlier: Tests that asserted a 400 now assert pass-through at the provider payload rather than only at One thing worth your attention before merge. Everything I have is from the mock provider and reading the code — I could not get NEAR AI credentials in this environment, so "the folded payload satisfies the real Qwen template" is inference from the error text and the public template, not an observation. It is the PR's load-bearing claim and you can settle it in one request: send the two-system-message payload to Full local run against PostgreSQL 15 in Docker: fmt and clippy clean, |
|
Settled — the verification I asked you to do before merge is done, so please disregard that ask. The fold is confirmed against the real model, and the PR body now carries the transcript. Run on 2026-09-06 against A — the unfolded shape the gateway builds today: HTTP 400 {"error":{"message":"Provider failed for model 'Qwen/Qwen3.6-35B-A3B-FP8': System message must be at the beginning.","type":"invalid_request_error","param":null,"code":null}}B — the folded shape this PR produces: HTTP 200 {"choices":[{"finish_reason":"length","index":0,"message":{"role":"assistant","reasoning_content":"Here's a thinking process:\n\n1. **Analyze User Input:**"}}],
"id":"a4035f807e104471a02fbf01a5604c5f","model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"chat.completion",
"created":1788726154,"usage":{"prompt_tokens":28,"completion_tokens":16}}The error string in A is character-for-character the one in #1025. Being precise about the scope: this exercises the provider-facing chat payload — the messages the gateway builds and sends upstream, which is the level the fold operates at — not an end-to-end One consequence worth your attention. Result A rejects a system message at index 1, not merely one trailing user content — so the template admits a system message only at position 0. That turns the organization-system-prompt case from a guess into demonstrated behavior: when an org prompt is configured, No code change since b685496; CI is green on it. |
|
The DeepSeek/GLM half is now verified too, so both sides of the argument are transcripts rather than assertions. PR body updated; no code change, still Same gateway and method as the Qwen run, Four models, two families, all HTTP 200:
One payload, a 400 on Qwen and a 200 on four models elsewhere. That is the case for the PR refusing nothing: a gateway-wide admission check on this shape — which is what my first revision did — would have broken all four. Same limits as before, stated rather than glossed: both runs exercise the provider-facing chat payload, not an end-to-end |
|
The fold still leaves the reported failure reachable for organizations with a configured system prompt.
The live Qwen result in this PR establishes that a system message at index 1 is rejected, so this supported configuration still produces the same 200-then- |
hanakannzashi is right: the fold left the reported failure reachable for
any organization with a system prompt configured.
`load_conversation_context` emitted the organization prompt as its own
system message and the request context as a second one, so the fold
appended the leading developer message to the second, and the provider
still received
system(org prompt), system(instructions + developer), user(...)
with a system message at index 1 - which the live Qwen result on this PR
shows is rejected.
Coalesce the organization prompt, the request context (`instructions`,
language instruction, time context) and any foldable leading system-level
input message into one leading system message. The pieces keep the order
they had as separate messages - organization prompt, instructions,
language instruction, time context, then the folded input - so the model
is told the same things in the same sequence, which is what keeps this
safe on backends that accept either shape.
Nothing new is refused. A shape that cannot be folded, such as a
system-level input message behind replayed conversation history, is still
forwarded unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@hanakannzashi you are right, and thank you — this is fixed in a325aab. Your reading of the code was exactly correct, including the payload. Reverting the fix locally and re-running the new test reproduces the shape you described, character for character: The fold appended the developer message to the second system message, exactly as you said, leaving the organization prompt at index 0 and the request context at index 1. What changed. The organization prompt, the request context ( Nothing new is refused. A shape that still cannot be folded — a system-level input message behind replayed conversation history, say — is forwarded unchanged, as before. The E2E test you asked for is Full local run against PostgreSQL 15 in Docker: fmt and clippy clean, One note on the evidence, since you reasoned from it: the Qwen result in this PR was captured at the chat-payload level rather than end-to-end through |
|
@hanakannzashi following up with the live confirmation — your configuration is now reproduced against the model, not just against our reading of the code. Both directions, Coalesced — what the PR now produces: HTTP 200 {"choices":[{"finish_reason":"length","index":0,"message":{"role":"assistant","reasoning_content":"Here's a thinking process:\n\n1. **Analyze User Input:**"}}],
"id":"8a1a2400e2fc4060a5f892d15815ed6c","model":"Qwen/Qwen3.6-35B-A3B-FP8","created":1788790715,"object":"chat.completion"}Split — the shape you described, same content emitted as two system messages: HTTP 400 {"error":{"message":"Provider failed for model 'Qwen/Qwen3.6-35B-A3B-FP8': System message must be at the beginning.","type":"invalid_request_error","param":null,"code":null}}Worth noting this is the first direct evidence that an organization prompt specifically triggers the failure — the earlier Qwen transcript in this PR used the instructions-plus-developer shape, and you inferred the organization-prompt case from it. The inference was correct. Credit where it is due: you found this by reading the code without running it, your description of the resulting payload matched our reproduction character for character, and you got there by reasoning from a transcript we had attached for a different purpose. That is a better catch than the one this PR started as. Same limits as the other transcripts, stated rather than glossed: this exercises the provider-facing chat payload, not an end-to-end |
Fixes #1025.
Reported by an external team (Trace Commons) hitting this through a proxy in front of the gateway, with the Codex CLI as the client. Happy to adjust to whatever you prefer here.
The defect
A
POST /v1/responsescarrying bothinstructionsand adeveloper-roleinputmessage is accepted with HTTP 200, the SSE stream is opened, and only then does it fail:The body already carries
"status_code":400, but it is delivered inside a stream committed with a 200. Clients cannot tell it apart from a mid-stream disconnect: the Codex CLI retried five times and each attempt failed identically. Reproduced onQwen/Qwen3.6-35B-A3B-FP8andQwen/Qwen3.8-27B; the same request without a system-levelinputmessage succeeds, while the identical message list is accepted by four models across DeepSeek and GLM (transcript below).The triggering shape is the Codex CLI's: a ~17,000-character
instructionsstring plus adeveloper-role message as the firstinputitem (inputroles:developer, user, user).Root cause: the gateway builds the second system message itself
The second system message is not something the client sends and not something the provider invents — this code makes it:
Service::load_conversation_context(crates/services/src/responses/service.rs) pushes a system message derived frominstructions(merged with the language instruction and time context) first, before anyinputitem, then forwards eachinputmessage'sroleverbatim.Service::to_chat_messages(crates/services/src/completions/mod.rs:1468) maps"system" | "developer" => MessageRole::System.So
developerbecomes a system message at index 1, behind the one built frominstructions. Captured from the mock provider with the fix disabled — this is the payload that goes upstream, abridged for reading (eachcontentis reallySome(String(...)), and thename/tool_call_id/tool_callsfields are omitted):Two system messages, the second not at the beginning — exactly what Qwen's chat template objects to.
The fix
The gateway now builds one leading system message, coalescing every system-level source it has: the organization prompt (when configured), the request context (
instructions, the language instruction, the time context), and a system-levelinputmessage that precedes all other content. They keep the order they had as separate messages, so the model is told the same content in the same sequence; the provider sees the single leading system message its template requires; and the Codex request works rather than merely failing legibly.Coalescing the organization prompt came from review — @hanakannzashi pointed out that folding only the input message left
system(org prompt), system(instructions + developer), user(...), still a system message at index 1, so the reported failure stayed reachable for every organization with a prompt configured.That is the whole change. There is no new rejection.
Why nothing is refused at admission
The issue asks for a 400 at admission, and an earlier revision of this PR did that. It was wrong, and the review caught the first half of why:
CreateResponseRequest::validateruns before model and provider resolution, so any check it makes is a pure role-and-position test applied to every backend./v1/responsesserves external and non-Qwen models, and the shapes in question work there — the transcript below shows the identical message list returning 200 on four models across DeepSeek and GLM while Qwen returns 400.Note the asymmetry. The fold is provider-agnostic because it preserves what the model is told: it is correct regardless of which backend receives it. A rejection is not — it is provider-specific reasoning applied provider-agnostically, generalizing one chat template's constraint into a gateway-wide admission rule. So the three shapes that cannot be folded without reordering — a system-level message after other content, one behind replayed conversation history, one whose content is not plain text — are forwarded unchanged, exactly as today. Whether such a payload is acceptable is the provider's judgement. Nothing that works today breaks; Qwen behaves on those shapes exactly as it does now; and the value of the PR is untouched, because it comes from the fold.
The fold guard tests the messages actually built rather than the request fields, so pass-through needed no separate code path. That also corrected an assumption:
previous_response_idalone replays no history — it only selects a branch within aconversation— so such a request is folded, and the test covering replayed history sets up a conversation.Tests
Unit (
responses::models::tests): pins thatvalidateaccepts the leadingdevelopershape, each of the three un-foldable shapes,user/assistant, and an unmapped spelling ("Developer") anywhere —to_chat_messagesmatches exactly, so that becomes a user message and never produces a system message at all.E2E (
crates/api/tests/e2e_all/responses_system_message_position.rs, modelled onfirst_stream_event.rs). The first three assert the payload the provider actually receives, viamock.last_chat_params():instructionstext and containing the developer text, with both user turns intact in order;systemmessage after user content returns 200 and arrives as[System, User, System]— still in third place, exactly where the caller put it;developermessage behind replayed conversation history returns 200 and arrives as its own system message after that history, not folded;The fold fails without the change: with it disabled the first test sees the two-system-message payload quoted above.
Everything
.github/workflows/test.ymlruns, locally (PostgreSQL 15 in Docker;cargo testrather thancargo nextest):Verified live against the model
Confirmed on 2026-09-06 against
Qwen/Qwen3.6-35B-A3B-FP8viahttps://cloud-api.near.ai/v1/chat/completions,max_tokens: 16. Two requests differing only in whether the two system messages are folded.A — the unfolded shape (two system messages, the second not at index 0):
HTTP 400
{"error":{"message":"Provider failed for model 'Qwen/Qwen3.6-35B-A3B-FP8': System message must be at the beginning.","type":"invalid_request_error","param":null,"code":null}}B — the folded shape this PR produces:
HTTP 200
{"choices":[{"finish_reason":"length","index":0,"message":{"role":"assistant","reasoning_content":"Here's a thinking process:\n\n1. **Analyze User Input:**"}}], "id":"a4035f807e104471a02fbf01a5604c5f","model":"Qwen/Qwen3.6-35B-A3B-FP8","object":"chat.completion", "created":1788726154,"usage":{"prompt_tokens":28,"completion_tokens":16}}The error string in A is character-for-character the one in #1025. Folding is what makes the difference between a rejection and a completion.
The same payload A on other model families
Same gateway and method,
max_tokens: 8, also 2026-09-06. The request is byte-identical to payload A — the two-system-message shape Qwen refuses. Four models, two families, all HTTP 200:deepseek-ai/DeepSeek-V4-Flash—{"choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"OK."}}],"id":"520dead424fa4813b2e808f31c8d54e0","created":1788727428}deepseek/deepseek-v3.2—{"id":"c3173b1436a54795bdf9cd875c213964","created":1788727439,"choices":[{"index":0,"message":{"role":"assistant","content":"OK."}}]}zai-org/GLM-5.1-FP8—{"choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"OK"}}],"id":"9173ec431fb84a1d970388dd9e970974","created":1788727429}z-ai/glm-5.2—{"choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"OK."}}],"id":"1645a61c377f445aa67f2a95fea7f4b2","created":1788727441}This is the whole argument for refusing nothing, and it is now demonstrated rather than asserted: one payload, a 400 on Qwen and a 200 on four models elsewhere. A gateway-wide admission check on that shape would have broken all four.
The organization-prompt configuration
The configuration @hanakannzashi identified, run against the model rather than reasoned about. Same gateway and method,
max_tokens: 16, 2026-09-07. The two requests carry the same three pieces of content and differ only in whether they are coalesced.Coalesced — what this PR now produces:
HTTP 200
{"choices":[{"finish_reason":"length","index":0,"message":{"role":"assistant","reasoning_content":"Here's a thinking process:\n\n1. **Analyze User Input:**"}}], "id":"8a1a2400e2fc4060a5f892d15815ed6c","model":"Qwen/Qwen3.6-35B-A3B-FP8","created":1788790715,"object":"chat.completion"}Split — the shape the review describes, an organization prompt emitted on its own:
HTTP 400
{"error":{"message":"Provider failed for model 'Qwen/Qwen3.6-35B-A3B-FP8': System message must be at the beginning.","type":"invalid_request_error","param":null,"code":null}}This is the first direct evidence that an organization prompt specifically triggers the failure — the earlier Qwen run used the instructions-plus-developer shape. Coalescing is confirmed to fix it against the real template rather than against a reading of it.
What this does and does not cover. All three runs exercise the provider-facing chat payload — the messages the gateway builds and sends upstream — which is the level the fold operates at, and it is the claim the PR rests on. None was driven end-to-end through
/v1/responsesagainst a deployed build of this branch. The joined system messages here are the two or three sources concatenated, without the language instruction and time context the real path also merges in; what is verified is the shape, a single leading system message, not those exact strings. Four models across two families is what was tested — it does not establish how every model the gateway serves treats a non-leading system message.One thing left alone
Stored
system-role history items. Conversation items stored with asystemrole are replayed with that role intact. Folding or filtering those is a call about what stored history means, and not one to make provider-agnostically without knowing you want it.🤖 Generated with Claude Code