From 98799eff6bd07fa9449fae3d4f382b7bf996da5e Mon Sep 17 00:00:00 2001 From: Balaji Gandhi Date: Sun, 4 Oct 2026 17:16:52 -0700 Subject: [PATCH] Fix: append trailing user turn when assistant message would be last GitHub Copilot's backend rejects Anthropic Messages API requests that end on an assistant turn for newer Claude models (400 invalid_request_body, 'This model does not support assistant message prefill'). This appends a placeholder user message ('Continue.') whenever translateAnthropicMessagesToOpenAI would otherwise produce a trailing assistant message, so Claude Code can use these models through copilot-api without hitting this error. An empty-string placeholder was tried first but is silently dropped by the upstream backend, leaving the conversation ending on assistant again; a non-empty placeholder is required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/routes/messages/non-stream-translation.ts | 10 +++++ tests/anthropic-request.test.ts | 40 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/routes/messages/non-stream-translation.ts b/src/routes/messages/non-stream-translation.ts index dc41e63825..0ac1c67abd 100644 --- a/src/routes/messages/non-stream-translation.ts +++ b/src/routes/messages/non-stream-translation.ts @@ -68,6 +68,16 @@ function translateAnthropicMessagesToOpenAI( : handleAssistantMessage(message), ) + // Newer Claude models reject requests whose message list ends on an + // assistant turn ("assistant message prefill" is no longer supported). + // Append a placeholder user turn so the conversation always ends on + // "user". An empty string is silently dropped upstream, so a non-empty + // placeholder is required. + const lastMessage = otherMessages.at(-1) + if (lastMessage?.role === "assistant") { + otherMessages.push({ role: "user", content: "Continue." }) + } + return [...systemMessages, ...otherMessages] } diff --git a/tests/anthropic-request.test.ts b/tests/anthropic-request.test.ts index 06c6637788..f99be49e19 100644 --- a/tests/anthropic-request.test.ts +++ b/tests/anthropic-request.test.ts @@ -197,6 +197,46 @@ describe("Anthropic to OpenAI translation logic", () => { expect(assistantMessage?.tool_calls).toHaveLength(1) expect(assistantMessage?.tool_calls?.[0].function.name).toBe("get_weather") }) + + test("should append a trailing user turn when the message list would otherwise end on assistant", () => { + const anthropicPayload: AnthropicMessagesPayload = { + model: "claude-sonnet-5", + messages: [ + { role: "user", content: "Hi" }, + { role: "assistant", content: "Hello" }, + ], + max_tokens: 20, + } + const openAIPayload = translateToOpenAI(anthropicPayload) + expect(isValidChatCompletionRequest(openAIPayload)).toBe(true) + + const lastMessage = openAIPayload.messages.at(-1) + expect(lastMessage?.role).toBe("user") + // The placeholder must be non-empty, since some backends silently drop + // empty-string user turns, which would leave the conversation ending on + // an assistant message again. + expect(lastMessage?.content).not.toBe("") + }) + + test("should not append a trailing user turn when the message list already ends on user", () => { + const anthropicPayload: AnthropicMessagesPayload = { + model: "claude-sonnet-5", + messages: [ + { role: "user", content: "Hi" }, + { role: "assistant", content: "Hello" }, + { role: "user", content: "How are you?" }, + ], + max_tokens: 20, + } + const openAIPayload = translateToOpenAI(anthropicPayload) + expect(isValidChatCompletionRequest(openAIPayload)).toBe(true) + + const nonSystemMessages = openAIPayload.messages.filter( + (m) => m.role !== "system", + ) + expect(nonSystemMessages).toHaveLength(3) + expect(nonSystemMessages.at(-1)?.content).toBe("How are you?") + }) }) describe("OpenAI Chat Completion v1 Request Payload Validation with Zod", () => {