Summary
A /v1/messages request whose messages array ends with a role: "system" entry is translated to an OpenAI payload that ends on an assistant turn, which upstream (Copilot's Gemini backend) rejects with:
400 {"error":{"message":"The last message in a Gemini request must not be from the assistant. Ensure the conversation ends with a user or tool message.","code":"invalid_message_role"}}
Root cause
In src/routes/messages/non-stream-translation.ts, translateAnthropicMessagesToOpenAI only special-cases role === "user" and routes everything else through handleAssistantMessage:
const otherMessages = anthropicMessages.flatMap((message) =>
message.role === "user" ?
handleUserMessage(message)
: handleAssistantMessage(message),
)
So a role: "system" message — which Anthropic clients such as Claude Code place in messages[] rather than the top-level system field — becomes an OpenAI assistant message. When that message is the last one, the forwarded request ends on an assistant turn and upstream returns invalid_message_role.
Repro
Works (single user message):
{"model":"gemini-3.6-flash","max_tokens":100,"messages":[{"role":"user","content":"hello"}]}
Fails (trailing role: "system" — what Claude Code actually sends):
{"model":"gemini-3.6-flash","max_tokens":100,"messages":[{"role":"user","content":"hello"},{"role":"system","content":"some instructions"}]}
role: "system" is not part of the AnthropicUserMessage | AnthropicAssistantMessage union in anthropic-types.ts, which is why the translator never handles it.
Suggested fix (minimal)
Fold any role: "system" message's text into the OpenAI system prompt instead of routing it to the assistant branch, so the last OpenAI message is never an assistant turn:
- Treat
role === "system" messages like the top-level system field (append text to the leading system message, or emit one if none exists).
- Ensure a system-only/assistant-only tail can never reach upstream.
Related: #261 covers the adjacent "trailing assistant prefill" case but not inline role:"system" messages.
Summary
A
/v1/messagesrequest whosemessagesarray ends with arole: "system"entry is translated to an OpenAI payload that ends on anassistantturn, which upstream (Copilot's Gemini backend) rejects with:Root cause
In
src/routes/messages/non-stream-translation.ts,translateAnthropicMessagesToOpenAIonly special-casesrole === "user"and routes everything else throughhandleAssistantMessage:So a
role: "system"message — which Anthropic clients such as Claude Code place inmessages[]rather than the top-levelsystemfield — becomes an OpenAIassistantmessage. When that message is the last one, the forwarded request ends on an assistant turn and upstream returnsinvalid_message_role.Repro
Works (single user message):
{"model":"gemini-3.6-flash","max_tokens":100,"messages":[{"role":"user","content":"hello"}]}Fails (trailing
role: "system"— what Claude Code actually sends):{"model":"gemini-3.6-flash","max_tokens":100,"messages":[{"role":"user","content":"hello"},{"role":"system","content":"some instructions"}]}role: "system"is not part of theAnthropicUserMessage | AnthropicAssistantMessageunion inanthropic-types.ts, which is why the translator never handles it.Suggested fix (minimal)
Fold any
role: "system"message's text into the OpenAIsystemprompt instead of routing it to the assistant branch, so the last OpenAI message is never an assistant turn:role === "system"messages like the top-levelsystemfield (append text to the leading system message, or emit one if none exists).Related: #261 covers the adjacent "trailing assistant prefill" case but not inline
role:"system"messages.