Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/routes/messages/non-stream-translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}

Expand Down
40 changes: 40 additions & 0 deletions tests/anthropic-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down