From 75834e96fffa082ea07d03356a02225f47bb3016 Mon Sep 17 00:00:00 2001 From: Amirsalar Sojoudi Date: Thu, 1 Oct 2026 10:26:54 +0700 Subject: [PATCH] fix(claude): keep a trailing user turn so cleanup never yields assistant prefill Newer Claude models reject a body that ends on an assistant turn. The empty-message cleanups in prepareClaudeRequest and normalizeClaudePassthrough protect a trailing assistant but not a trailing user turn, so an emptied last user turn silently made the previous assistant turn the last one. ensureTrailingUserTurn appends a minimal user turn ("Continue.") only when the client did not itself end on assistant, in both cleanup paths and in translateRequest against the role the client actually sent. --- open-sse/translator/formats/claude.js | 18 +++++ open-sse/translator/index.js | 5 +- tests/unit/claude-trailing-user-turn.test.js | 75 ++++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 tests/unit/claude-trailing-user-turn.test.js diff --git a/open-sse/translator/formats/claude.js b/open-sse/translator/formats/claude.js index 2795ecaa..96ef0ee6 100644 --- a/open-sse/translator/formats/claude.js +++ b/open-sse/translator/formats/claude.js @@ -226,6 +226,8 @@ export function normalizeClaudePassthrough(body, model = "") { if (Object.keys(body.output_config).length === 0) delete body.output_config; } + const originalLastRole = Array.isArray(body.messages) ? body.messages[body.messages.length - 1]?.role : undefined; + // 3. Wrap bare content-block objects as one-element arrays before folding. // Some clients send content: {block} instead of content: [{block}]; the // mid-conversation-system fold below assumes the array shape, so it must @@ -327,11 +329,25 @@ export function normalizeClaudePassthrough(body, model = "") { !(block?.type === CLAUDE_BLOCK.TEXT && !String(block.text ?? "").trim())); return msg.content.length > 0; }); + body.messages = ensureTrailingUserTurn(body.messages, originalLastRole); } return body; } +// Newer Claude models reject a body that ends on an assistant turn ("does not +// support assistant message prefill"). Cleanup passes delete messages left empty, +// so a trailing user turn that was empty (or held only dropped blocks) silently +// turns the previous assistant turn into the last one. Restore a user turn only +// when the client did not itself end on assistant (real prefill is its choice). +const TRAILING_USER_PLACEHOLDER = "Continue."; + +export function ensureTrailingUserTurn(messages, originalLastRole) { + if (!Array.isArray(messages) || originalLastRole === ROLE.ASSISTANT) return messages; + if (messages[messages.length - 1]?.role !== ROLE.ASSISTANT) return messages; + return [...messages, { role: ROLE.USER, content: [{ type: CLAUDE_BLOCK.TEXT, text: TRAILING_USER_PLACEHOLDER }] }]; +} + // Put a 5m breakpoint on the last cache-eligible block of a message. // thinking/redacted_thinking blocks do not accept cache_control. function markLastCacheableBlock(msg) { @@ -524,6 +540,7 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne // 2. Messages: process in optimized passes if (body.messages && Array.isArray(body.messages)) { const len = body.messages.length; + const originalLastRole = body.messages[len - 1]?.role; let filtered = []; // Pass 1: remove cache_control + filter empty messages @@ -548,6 +565,7 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne // Pass 1.5: Fix tool_use/tool_result ordering // Each tool_use must have tool_result in the NEXT message (not same message with other content) filtered = fixToolUseOrdering(filtered); + filtered = ensureTrailingUserTurn(filtered, originalLastRole); body.messages = filtered; diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index 8a1add5f..9f5953da 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -1,6 +1,6 @@ import { FORMATS } from "./formats.js"; import { ensureToolCallIds, fixMissingToolResponses } from "./concerns/toolCall.js"; -import { prepareClaudeRequest } from "./formats/claude.js"; +import { prepareClaudeRequest, ensureTrailingUserTurn } from "./formats/claude.js"; import { cloakClaudeTools, decloakStreamChunk } from "../utils/claudeCloaking.js"; import { restoreToolNames } from "../utils/opencodeFingerprint.js"; import { filterToOpenAIFormat } from "./formats/openai.js"; @@ -53,6 +53,8 @@ function stripContentTypes(body, stripList = []) { export function translateRequest(sourceFormat, targetFormat, model, body, stream = true, credentials = null, provider = null, reqLogger = null, stripList = [], connectionId = null, clientTool = null) { ensureInitialized(); let result = body; + // Role the client actually ended on, before any translator drops an emptied turn. + const clientLastRole = Array.isArray(body?.messages) ? body.messages[body.messages.length - 1]?.role : undefined; // Strip explicit content types (opt-in via strip[] in PROVIDER_MODELS entry) stripContentTypes(result, stripList); @@ -132,6 +134,7 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream if (targetFormat === FORMATS.CLAUDE) { const apiKey = credentials?.accessToken || credentials?.apiKey || null; result = prepareClaudeRequest(result, provider, apiKey, connectionId, credentials?.rawHeaders, clientSessionId); + if (Array.isArray(result?.messages)) result.messages = ensureTrailingUserTurn(result.messages, clientLastRole); } // Claude cloaking: rename client tools with CLAUDE_TOOL_SUFFIX (anti-ban) diff --git a/tests/unit/claude-trailing-user-turn.test.js b/tests/unit/claude-trailing-user-turn.test.js new file mode 100644 index 00000000..d197648a --- /dev/null +++ b/tests/unit/claude-trailing-user-turn.test.js @@ -0,0 +1,75 @@ +// Anthropic rejects a body ending on an assistant turn ("This model does not support +// assistant message prefill"). Cleanup passes delete emptied messages, so an emptied +// trailing user turn used to leave the previous assistant turn last. +import { describe, it, expect } from "vitest"; +import { normalizeClaudePassthrough, prepareClaudeRequest } from "../../open-sse/translator/formats/claude.js"; +import { translateRequest } from "../../open-sse/translator/index.js"; + +const roles = (body) => body.messages.map((m) => m.role); +const history = (last) => [ + { role: "user", content: "hi" }, + { role: "assistant", content: [{ type: "text", text: "hello" }] }, + last, +]; + +const emptyLastTurns = { + "empty string": { role: "user", content: "" }, + "blank text block": { role: "user", content: [{ type: "text", text: " " }] }, + "empty content array": { role: "user", content: [] }, + "unsupported block only": { role: "user", content: [{ type: "search_result", source: "x", title: "t", content: [] }] }, +}; + +describe("trailing user turn survives empty-message cleanup", () => { + for (const [name, last] of Object.entries(emptyLastTurns)) { + it(`prepareClaudeRequest: ${name}`, () => { + const out = prepareClaudeRequest({ model: "claude-opus-4-5", max_tokens: 100, messages: history(last) }, "claude"); + expect(roles(out)).toEqual(["user", "assistant", "user"]); + }); + it(`normalizeClaudePassthrough: ${name}`, () => { + const out = normalizeClaudePassthrough({ model: "claude-opus-4-5", messages: history(last) }, "claude-opus-4-5"); + expect(roles(out)).toEqual(["user", "assistant", "user"]); + }); + } + + it("passthrough: tool_result of a dropped foreign server_tool_use no longer empties the last turn into prefill", () => { + const out = normalizeClaudePassthrough({ + model: "claude-opus-4-5", + messages: [ + { role: "user", content: "analyze" }, + { role: "assistant", content: [{ type: "server_tool_use", id: "call_abc", name: "analyze_image", input: {} }, { type: "text", text: "done" }] }, + { role: "user", content: [{ type: "web_search_tool_result", tool_use_id: "call_abc", content: [] }] }, + ], + }, "claude-opus-4-5"); + expect(roles(out)).toEqual(["user", "assistant", "user"]); + }); + + it("full pipeline: OpenAI client with an empty last user message", () => { + const out = translateRequest("openai", "claude", "claude-opus-4-5", { + model: "x", max_tokens: 100, + messages: [{ role: "user", content: "hi" }, { role: "assistant", content: "yo" }, { role: "user", content: "" }], + }, true, null, "claude"); + expect(out.messages.at(-1).role).toBe("user"); + }); + + it("full pipeline: Claude client with a blank last user block", () => { + const out = translateRequest("claude", "claude", "claude-opus-4-5", { + model: "x", max_tokens: 100, messages: history({ role: "user", content: [{ type: "text", text: "" }] }), + }, true, null, "claude"); + expect(out.messages.at(-1).role).toBe("user"); + }); + + it("leaves intentional client prefill (last turn is assistant) untouched", () => { + const body = { model: "claude-opus-4-5", max_tokens: 100, messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: [{ type: "text", text: "Sure:" }] }, + ] }; + expect(roles(prepareClaudeRequest(structuredClone(body), "claude"))).toEqual(["user", "assistant"]); + expect(roles(normalizeClaudePassthrough(structuredClone(body), "claude-opus-4-5"))).toEqual(["user", "assistant"]); + }); + + it("does not append anything when the last user turn has content", () => { + const out = prepareClaudeRequest({ model: "claude-opus-4-5", max_tokens: 100, messages: history({ role: "user", content: "next" }) }, "claude"); + expect(roles(out)).toEqual(["user", "assistant", "user"]); + expect(out.messages.at(-1).content[0].text).toBe("next"); + }); +});