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.
This commit is contained in:
1 parent
89ffac5a2c
commit
75834e96ff
3 files changed
+97
-1
No files matched your search
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user