From 3949c8aba1dccb8cc066372107ee593606e4e527 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 27 Aug 2026 16:15:28 +0000 Subject: [PATCH 1/2] port(upstream): #3506 - harden Claude trailing prefill and tool-type fallback Covers stacked #3484. Adds shared assistant-prefill policy applied on translated and native Claude paths, threads native request headers through normalizeClaudePassthrough options, and fixes the Claude tool fallback spread order so typed/server tools keep their object identity. Closes #580 --- CHANGELOG.md | 1 + docs/reference/api.md | 6 ++ open-sse/handlers/chatCore.js | 4 +- .../concerns/assistantPrefillPolicy.js | 63 ++++++++++++++ open-sse/translator/formats/claude.js | 13 ++- .../assistant-prefill-policy.test.js | 83 +++++++++++++++++++ tests/unit/force-stream-config.test.js | 61 +++++++++++++- 7 files changed, 223 insertions(+), 8 deletions(-) create mode 100644 open-sse/translator/concerns/assistantPrefillPolicy.js create mode 100644 tests/translator/assistant-prefill-policy.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f7b52c32c..9bc78fd77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Exact-model and account-wide model-lock timestamps are now validated independently, so an expired exact lock cannot mask an active account-wide lock. Ports decolua/9router#3516. Closes #578. - Thinking effort now reaches Ollama as `think`, Z.AI as both scalar and `reasoning.effort`, OpenCode free models as its gateway enum, and Grok CLI 4.6 virtual models through `xhigh`. +- Claude-format requests now normalize trailing assistant prefills into a continuation user turn (or matching error `tool_result`), unless `X-9Router-Assistant-Prefill: preserve` is set; falsy Claude `tools[].type` values now default to `custom`. Ports decolua/9router#3506 and #3484. Closes #580. - Qoder refreshes its fallback catalog with `lite`, Qwen3.8-Max, and GLM-5.3; opaque Qoder model IDs now expose correct reasoning, vision, context, and output capabilities, and vision models retain OpenAI URL/data-URI and Claude base64 image blocks in outgoing chat payloads. Ports decolua/9router#3555. Closes #583. - Groq replaces decommissioned seed model IDs with its current production catalog, discovers live models through the authenticated OpenAI-compatible `/models` endpoint, and accepts future model IDs via passthrough. Closes #582. diff --git a/docs/reference/api.md b/docs/reference/api.md index 15bf72d87..15abf764e 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -100,6 +100,12 @@ curl http://localhost:20128/v1/messages \ This route is useful for Claude-compatible clients and translation testing. +Claude-format requests normally end on a user turn. DurinDoor converts a trailing assistant text prefill into a continuation user turn and pairs trailing `tool_use` blocks with error `tool_result` blocks so strict Messages endpoints do not reject the request. Clients that require native assistant-prefill behavior can opt out per request: + +```http +X-9Router-Assistant-Prefill: preserve +``` + ## Count Tokens ```bash diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index b993070d2..f96de207d 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -537,7 +537,7 @@ export async function handleChatCore({ body, modelInfo, credentials: rawCredenti }); } // Normalize newer Cowork/CC beta shapes (adaptive thinking, mid-conversation system) the API rejects - if (clientTool === "claude") normalizeClaudePassthrough(translatedBody, translatedBody.model, provider, modelCapabilities?.maxOutput ?? null, { foldSystemTurns: true }); + if (clientTool === "claude") normalizeClaudePassthrough(translatedBody, translatedBody.model, provider, modelCapabilities?.maxOutput ?? null, { foldSystemTurns: true, rawHeaders: clientRawRequest?.headers }); /** * Native OpenAI Responses and Claude Messages bodies carry top-level * `stream`; Gemini-family native bodies do not and reject an injected key. @@ -652,7 +652,7 @@ export async function handleChatCore({ body, modelInfo, credentials: rawCredenti // Claude tool schema requires `type` to be explicitly set; strict gateways (e.g., MiniMax) // reject legacy payloads that omit it with HTTP 400. Default to "custom" when missing. if (finalFormat === "claude" && Array.isArray(translatedBody.tools)) { - translatedBody.tools = translatedBody.tools.map((tool) => tool.type ? tool : { type: "custom", ...tool }); + translatedBody.tools = translatedBody.tools.map((tool) => tool.type ? tool : { ...tool, type: "custom" }); } // Token-saver summary parts, printed as one "⚙" line at the end (only active ones) diff --git a/open-sse/translator/concerns/assistantPrefillPolicy.js b/open-sse/translator/concerns/assistantPrefillPolicy.js new file mode 100644 index 000000000..6e00f384a --- /dev/null +++ b/open-sse/translator/concerns/assistantPrefillPolicy.js @@ -0,0 +1,63 @@ +import { isFunction, isObject, isString } from "../../../src/shared/utils/typeChecks.js"; +import { CLAUDE_BLOCK, ROLE } from "../schema/index.js"; + +const ASSISTANT_CONTINUATION_PROMPT = "Continue from the assistant response above without repeating it."; +const INCOMPLETE_TOOL_RESULT = "Tool execution was not completed before this request continued."; +const PRESERVE_HEADER = "x-9router-assistant-prefill"; + +function getHeader(headers, name) { + if (!headers) return null; + if (isFunction(headers.get)) return headers.get(name); + if (!isObject(headers)) return null; + + const key = Object.keys(headers).find((candidate) => candidate.toLowerCase() === name); + const value = key === undefined ? null : headers[key]; + return Array.isArray(value) ? value[0] : value; +} + +function hasText(content) { + if (isString(content)) return Boolean(content.trim()); + return Array.isArray(content) && content.some((block) => + block?.type === CLAUDE_BLOCK.TEXT && isString(block.text) && Boolean(block.text.trim()) + ); +} + +/** + * Make Claude-format conversations end on a user turn unless a client explicitly + * requests native assistant-prefill semantics through the compatibility header. + * Mutates and returns `body` to match existing translator normalization helpers. + */ +export function applyAssistantPrefillPolicy(body, rawHeaders = null) { + if (!Array.isArray(body?.messages)) return body; + if (String(getHeader(rawHeaders, PRESERVE_HEADER) || "").toLowerCase() === "preserve") return body; + + const trailingAssistant = body.messages.at(-1); + if (trailingAssistant?.role !== ROLE.ASSISTANT) return body; + + const toolUses = Array.isArray(trailingAssistant.content) + ? trailingAssistant.content.filter((block) => block?.type === CLAUDE_BLOCK.TOOL_USE && block.id) + : []; + if (toolUses.length > 0) { + body.messages.push({ + role: ROLE.USER, + content: toolUses.map((toolUse) => ({ + type: CLAUDE_BLOCK.TOOL_RESULT, + tool_use_id: toolUse.id, + is_error: true, + content: INCOMPLETE_TOOL_RESULT, + })), + }); + return body; + } + + if (!hasText(trailingAssistant.content)) { + body.messages.pop(); + return body; + } + + body.messages.push({ + role: ROLE.USER, + content: [{ type: CLAUDE_BLOCK.TEXT, text: ASSISTANT_CONTINUATION_PROMPT }], + }); + return body; +} diff --git a/open-sse/translator/formats/claude.js b/open-sse/translator/formats/claude.js index 47a3ba3a5..3f903a775 100644 --- a/open-sse/translator/formats/claude.js +++ b/open-sse/translator/formats/claude.js @@ -9,6 +9,7 @@ import { PROVIDERS } from "../../providers/index.js"; import { getCapabilitiesForModel } from "../../providers/capabilities.js"; import { DEFAULT_MAX_TOKENS } from "../../config/runtimeConfig.js"; import { isObject, isString } from "../../../src/shared/utils/typeChecks.js"; +import { applyAssistantPrefillPolicy } from "../concerns/assistantPrefillPolicy.js"; const CACHE_CONTROL_5M = { type: "ephemeral" }; const CACHE_CONTROL_1H = { type: "ephemeral", ttl: "1h" }; @@ -391,7 +392,9 @@ export function normalizeClaudePassthrough(body, model = "", provider = "claude" } } - return reconcileClaudeThinkingBudget(body, provider, customMaxOutput); + reconcileClaudeThinkingBudget(body, provider, customMaxOutput); + applyAssistantPrefillPolicy(body, options?.rawHeaders); + return body; } // Prepare request for Claude format endpoints @@ -440,7 +443,7 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne } // Keep final assistant even if empty, otherwise check valid content - const isFinalAssistant = i === len - 1 && msg.role === "assistant"; + const isFinalAssistant = i === len - 1 && msg.role === ROLE.ASSISTANT; if (isFinalAssistant || hasValidContent(msg)) { filtered.push(msg); } @@ -451,10 +454,12 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne filtered = fixToolUseOrdering(filtered); body.messages = filtered; + applyAssistantPrefillPolicy(body, rawHeaders); + filtered = body.messages; // Check if thinking is enabled AND last message is from user const lastMessage = filtered[filtered.length - 1]; - const lastMessageIsUser = lastMessage?.role === "user"; + const lastMessageIsUser = lastMessage?.role === ROLE.USER; const thinkingEnabled = body.thinking?.type === "enabled" && lastMessageIsUser; // Pass 2 (reverse): add cache_control to last assistant + handle thinking for Anthropic @@ -462,7 +467,7 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne for (let i = filtered.length - 1; i >= 0; i--) { const msg = filtered[i]; - if (msg.role === "assistant" && Array.isArray(msg.content)) { + if (msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) { // Add cache_control to last non-thinking block of first (from end) assistant with content // thinking/redacted_thinking blocks do not support cache_control if (allowCacheControl && !lastAssistantProcessed && msg.content.length > 0) { diff --git a/tests/translator/assistant-prefill-policy.test.js b/tests/translator/assistant-prefill-policy.test.js new file mode 100644 index 000000000..4199ae011 --- /dev/null +++ b/tests/translator/assistant-prefill-policy.test.js @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeClaudePassthrough, + prepareClaudeRequest, +} from "../../open-sse/translator/formats/claude.js"; + +const continuationPattern = /continue.*without repeating/i; + +function translated(messages, headers = null) { + return prepareClaudeRequest({ + model: "claude-sonnet-4-6", + messages: structuredClone(messages), + }, "anthropic", null, null, headers); +} + +function passthrough(messages, headers = null) { + return normalizeClaudePassthrough({ + model: "claude-sonnet-4-6", + messages: structuredClone(messages), + }, "claude-sonnet-4-6", "claude", null, { rawHeaders: headers }); +} + +describe.each([ + ["translated Claude target", translated], + ["native Claude passthrough", passthrough], +])("assistant prefill policy — %s", (_name, run) => { + it("turns trailing assistant text into a continuation turn", () => { + const out = run([ + { role: "user", content: "Start" }, + { role: "assistant", content: [{ type: "text", text: "Partial answer" }] }, + ]); + + expect(out.messages.map((message) => message.role)).toEqual(["user", "assistant", "user"]); + expect(JSON.stringify(out.messages.at(-1).content)).toMatch(continuationPattern); + }); + + it("drops an empty trailing assistant", () => { + const out = run([ + { role: "user", content: "Start" }, + { role: "assistant", content: [] }, + ]); + + expect(out.messages).toHaveLength(1); + expect(out.messages[0].role).toBe("user"); + }); + + it("completes every id-bearing trailing tool_use with an error tool_result", () => { + const out = run([ + { role: "user", content: "Start" }, + { role: "assistant", content: [ + { type: "tool_use", id: "tool-1", name: "lookup", input: {} }, + { type: "tool_use", name: "missing-id", input: {} }, + ] }, + ]); + + expect(out.messages.at(-1)).toEqual({ + role: "user", + content: [{ + type: "tool_result", + tool_use_id: "tool-1", + is_error: true, + content: expect.stringMatching(/not completed/i), + }], + }); + }); + + it("keeps a final user turn unchanged", () => { + const out = run([{ role: "user", content: "Start" }]); + + expect(out.messages).toHaveLength(1); + expect(out.messages[0].role).toBe("user"); + }); + + it("preserves assistant prefill when explicitly requested", () => { + const out = run([ + { role: "user", content: "Start" }, + { role: "assistant", content: "Partial answer" }, + ], new Headers({ "X-9Router-Assistant-Prefill": "preserve" })); + + expect(out.messages).toHaveLength(2); + expect(JSON.stringify(out.messages.at(-1))).toContain("Partial answer"); + }); +}); diff --git a/tests/unit/force-stream-config.test.js b/tests/unit/force-stream-config.test.js index c6f213c8c..53f3085ab 100644 --- a/tests/unit/force-stream-config.test.js +++ b/tests/unit/force-stream-config.test.js @@ -2,11 +2,19 @@ import "../translator/registerAll.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { executeMock, dedupeToolsMock, detectClientToolMock, isNativePassthroughMock, translateRequestMock } = vi.hoisted(() => ({ +const { + executeMock, + dedupeToolsMock, + detectClientToolMock, + isNativePassthroughMock, + normalizeClaudePassthroughMock, + translateRequestMock, +} = vi.hoisted(() => ({ executeMock: vi.fn(), dedupeToolsMock: vi.fn((tools) => ({ tools, stripped: [] })), detectClientToolMock: vi.fn(() => null), isNativePassthroughMock: vi.fn(() => false), + normalizeClaudePassthroughMock: vi.fn(), translateRequestMock: vi.fn(), })); @@ -69,7 +77,7 @@ vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ vi.mock("../../open-sse/translator/formats/claude.js", () => ({ prepareClaudeRequest: vi.fn((body) => body), - normalizeClaudePassthrough: vi.fn(), + normalizeClaudePassthrough: normalizeClaudePassthroughMock, anchorClaudeCache: vi.fn(), })); @@ -321,6 +329,7 @@ describe("forceStream provider config", () => { detectClientToolMock.mockReturnValue(null); isNativePassthroughMock.mockReset(); isNativePassthroughMock.mockReturnValue(false); + normalizeClaudePassthroughMock.mockClear(); translateRequestMock.mockClear(); }); @@ -400,6 +409,54 @@ describe("forceStream provider config", () => { expect(executeMock.mock.calls[0][0].stream).toBe(false); }); + it("threads raw request headers through native Claude normalization", async () => { + const { handleChatCore } = await import("../../open-sse/handlers/chatCore.js"); + const options = makeNativeClaudeOptions(); + options.clientRawRequest.headers["x-9router-assistant-prefill"] = "preserve"; + detectClientToolMock.mockReturnValue("claude"); + isNativePassthroughMock.mockReturnValue(true); + + await handleChatCore(options); + + expect(normalizeClaudePassthroughMock).toHaveBeenCalledWith( + expect.any(Object), + "claude-sonnet-4.5", + "claude", + null, + { foldSystemTurns: true, rawHeaders: options.clientRawRequest.headers }, + ); + }); + + it.each([ + ["missing", undefined], + ["null", null], + ["empty", ""], + ])("defaults a %s Claude tool type to custom", async (_label, type) => { + const { handleChatCore } = await import("../../open-sse/handlers/chatCore.js"); + const options = makeNativeClaudeOptions(); + const tool = { name: "lookup", input_schema: { type: "object" } }; + if (type !== undefined) tool.type = type; + options.body.tools = [tool]; + options.clientRawRequest.body = options.body; + isNativePassthroughMock.mockReturnValue(true); + + await handleChatCore(options); + + expect(executeMock.mock.calls[0][0].body.tools[0]).toMatchObject({ name: "lookup", type: "custom" }); + }); + + it("preserves a truthy Claude tool object by reference", async () => { + const { handleChatCore } = await import("../../open-sse/handlers/chatCore.js"); + const options = makeNativeClaudeOptions(); + options.body.tools = [{ name: "computer", type: "computer_use", display_width: 1024 }]; + options.clientRawRequest.body = options.body; + isNativePassthroughMock.mockReturnValue(true); + await handleChatCore(options); + + const normalizedTools = dedupeToolsMock.mock.calls[0][0]; + expect(executeMock.mock.calls[0][0].body.tools[0]).toBe(normalizedTools[0]); + }); + it("does not inject stream into native Gemini CLI passthrough bodies", async () => { const { handleChatCore } = await import("../../open-sse/handlers/chatCore.js"); isNativePassthroughMock.mockReturnValue(true); From 05bb4417cbd68c91413bf666e9609fc9209b14f9 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 27 Aug 2026 17:00:06 +0000 Subject: [PATCH 2/2] test(translator): preserve assistant-prefill compatibility cases Closes #580 --- open-sse/executors/github.js | 3 +- open-sse/translator/index.js | 9 +++--- ...port-2401-reasoning-history-bridge.test.js | 7 ++++- tests/unit/claude-cache-control.test.js | 17 +++++++---- tests/unit/github-claude-messages.test.js | 29 +++++++++++++++++++ .../translator-request-normalization.test.js | 25 ++++++++++++++++ 6 files changed, 78 insertions(+), 12 deletions(-) diff --git a/open-sse/executors/github.js b/open-sse/executors/github.js index 465c1c927..7e162173a 100644 --- a/open-sse/executors/github.js +++ b/open-sse/executors/github.js @@ -261,7 +261,8 @@ export class GithubExecutor extends BaseExecutor { * the "github" branch KEEPS unvalidated blocks (see normalizeClaudePassthrough), * so we force the validating path to strip them for this route. */ - normalizeClaudePassthrough(transformedBody, model, "claude", requestContext?.modelCapabilities?.maxOutput ?? null); + /** Preserve the caller's assistant-prefill policy through the native GitHub Messages cleanup pass. */ + normalizeClaudePassthrough(transformedBody, model, "claude", requestContext?.modelCapabilities?.maxOutput ?? null, { rawHeaders: requestContext?.clientHeaders }); /** * Parallel-tool-use guard (Codex #291 P2): translateRequest converts OpenAI diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index 475d9d28c..f9e9f3bcd 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -216,10 +216,11 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream provider === "ollama" || provider === "ollama-local"; if (normalizesNativeClaudeTransport) { - // Ollama implements the Messages wire contract but not Anthropic's - // model-specific beta matrix. Normalize system turns without applying - // Claude-family model downgrades to the upstream Ollama model id. - result = normalizeClaudePassthrough(result, "", provider); + /** + * Ollama implements the Messages wire contract but not Anthropic's model beta matrix. + * Normalize against its model id while preserving the caller's assistant-prefill policy. + */ + result = normalizeClaudePassthrough(result, "", provider, null, { rawHeaders: credentials?.rawHeaders }); } const apiKey = credentials?.accessToken || credentials?.apiKey || null; const customMaxOutput = resolvedTranslationContext.modelCapabilities?.maxOutput ?? null; diff --git a/tests/translator/port-2401-reasoning-history-bridge.test.js b/tests/translator/port-2401-reasoning-history-bridge.test.js index efe702bae..daa25594a 100644 --- a/tests/translator/port-2401-reasoning-history-bridge.test.js +++ b/tests/translator/port-2401-reasoning-history-bridge.test.js @@ -115,7 +115,12 @@ describe("#2401 reasoning/thinking bridge (request)", () => { expect(assistant).toBeTruthy(); expect(assistant.reasoning_content ?? "").not.toContain("opaque-encrypted-payload-CCC"); - const final = translateRequest(FORMATS.OPENAI, FORMATS.CLAUDE, MODEL, mid, true, {}, "anthropic"); + // This case isolates the in-process redacted-thinking carrier. Preserve the + // terminal assistant deliberately; default continuation behavior is covered + // by the translated/native assistant-prefill-policy regression suite. + const final = translateRequest(FORMATS.OPENAI, FORMATS.CLAUDE, MODEL, mid, true, { + rawHeaders: { "x-9router-assistant-prefill": "preserve" }, + }, "anthropic"); const back = final.messages.find((m) => m.role === "assistant"); expect(back).toBeTruthy(); expect(back.content).toEqual([{ type: "redacted_thinking", data: "opaque-encrypted-payload-CCC" }]); diff --git a/tests/unit/claude-cache-control.test.js b/tests/unit/claude-cache-control.test.js index 74c1184b5..5710f00f2 100644 --- a/tests/unit/claude-cache-control.test.js +++ b/tests/unit/claude-cache-control.test.js @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import { anchorClaudeCache, normalizeClaudePassthrough } from "../../open-sse/translator/formats/claude.js"; +// These cases isolate system-turn folding/hoisting and intentionally assert the +// pre-policy assistant shape. Opt out explicitly; default trailing-assistant +// normalization is covered for both paths in assistant-prefill-policy.test.js. +const PRESERVE_ASSISTANT_PREFILL = { rawHeaders: { "x-9router-assistant-prefill": "preserve" } }; + describe("Claude passthrough cache-control anchoring", () => { it("re-anchors markers after preparation reshapes messages", () => { const body = { @@ -104,7 +109,7 @@ describe("normalizeClaudePassthrough default behavior (foldSystemTurns off)", () ], }; - normalizeClaudePassthrough(body); + normalizeClaudePassthrough(body, "", "claude", null, PRESERVE_ASSISTANT_PREFILL); expect(body.system).toEqual([{ type: "text", text: "volatile reminder" }]); expect(body.messages).toEqual([ @@ -165,7 +170,7 @@ describe("normalizeClaudePassthrough foldSystemTurns: true (native chatCore pass ], }; - normalizeClaudePassthrough(body, "", "claude", null, { foldSystemTurns: true }); + normalizeClaudePassthrough(body, "", "claude", null, { ...PRESERVE_ASSISTANT_PREFILL, foldSystemTurns: true }); // Top-level system must NOT pick up the volatile system message. expect(body.system).toEqual([{ type: "text", text: "stable" }]); @@ -197,7 +202,7 @@ describe("normalizeClaudePassthrough foldSystemTurns: true (native chatCore pass ], }; - normalizeClaudePassthrough(body, "", "claude", null, { foldSystemTurns: true }); + normalizeClaudePassthrough(body, "", "claude", null, { ...PRESERVE_ASSISTANT_PREFILL, foldSystemTurns: true }); // System reminder must attach to the FOLLOWING user turn, not the tool_result one. expect(body.messages).toEqual([ @@ -236,7 +241,7 @@ describe("normalizeClaudePassthrough foldSystemTurns: true (native chatCore pass ], }; - normalizeClaudePassthrough(body, "", "claude", null, { foldSystemTurns: true }); + normalizeClaudePassthrough(body, "", "claude", null, { ...PRESERVE_ASSISTANT_PREFILL, foldSystemTurns: true }); // No synthesized standalone user turn; the next existing user turn absorbs everything. expect(body.messages).toHaveLength(3); @@ -265,7 +270,7 @@ describe("normalizeClaudePassthrough foldSystemTurns: true (native chatCore pass ], }; - normalizeClaudePassthrough(body, "", "claude", null, { foldSystemTurns: true }); + normalizeClaudePassthrough(body, "", "claude", null, { ...PRESERVE_ASSISTANT_PREFILL, foldSystemTurns: true }); expect(body.messages.every((m) => m.role !== "system")).toBe(true); expect(body.messages).toEqual([ @@ -340,7 +345,7 @@ describe("normalizeClaudePassthrough foldSystemTurns: true (native chatCore pass ], }; - normalizeClaudePassthrough(body, "", "claude", null, { foldSystemTurns: true }); + normalizeClaudePassthrough(body, "", "claude", null, { ...PRESERVE_ASSISTANT_PREFILL, foldSystemTurns: true }); expect(body.messages).toEqual([ { role: "user", content: "ask" }, diff --git a/tests/unit/github-claude-messages.test.js b/tests/unit/github-claude-messages.test.js index e03b33d93..9007acebf 100644 --- a/tests/unit/github-claude-messages.test.js +++ b/tests/unit/github-claude-messages.test.js @@ -106,6 +106,35 @@ describe("GithubExecutor native Claude /v1/messages routing (upstream #2608)", ( expect(result.terminalProvenance).toBe("validated"); }); + it("honors assistant-prefill headers on the live native Messages route and keeps the default continuation", async () => { + const execute = async (rawHeaders = null) => { + proxyAwareFetch.mockResolvedValueOnce(claudeSSE([MSG_START, TEXT_START, TEXT_DELTA("hi"), TEXT_STOP, MSG_DELTA, MSG_STOP])); + const headers = rawHeaders || {}; + await new GithubExecutor().execute({ + model: "claude-sonnet-4.6", + body: { + model: "claude-sonnet-4.6", + messages: [ + { role: "user", content: "Start" }, + { role: "assistant", content: "Partial answer" }, + ], + }, + stream: true, + credentials: { ...credentials, rawHeaders: headers }, + signal: null, + log: null, + requestContext: { clientHeaders: headers }, + }); + return JSON.parse(proxyAwareFetch.mock.calls.at(-1)[1].body); + }; + + const preserved = await execute({ "x-9router-assistant-prefill": "preserve" }); + expect(preserved.messages.map((message) => message.role)).toEqual(["user", "assistant"]); + + const normalized = await execute(); + expect(normalized.messages.map((message) => message.role)).toEqual(["user", "assistant", "user"]); + }); + it("recognizes claude variants by name pattern (case-insensitive, unknown-to-registry)", () => { const exec = new GithubExecutor(); expect(exec.isClaudeModel("claude-opus-4.8")).toBe(true); diff --git a/tests/unit/translator-request-normalization.test.js b/tests/unit/translator-request-normalization.test.js index c9b9a66b7..ce841a5de 100644 --- a/tests/unit/translator-request-normalization.test.js +++ b/tests/unit/translator-request-normalization.test.js @@ -165,6 +165,31 @@ describe("request normalization", () => { expect(result.output_config).toEqual(body.output_config); }); + it.each([ + ["ollama", "ollama"], + ["ollama-local", "ollama-local"], + ])("translateRequest honors assistant-prefill headers on the live %s Claude route", (_name, provider) => { + const request = (rawHeaders) => translateRequest( + FORMATS.CLAUDE, + FORMATS.CLAUDE, + "qwen3.5", + { + model: "qwen3.5", + max_tokens: 64, + messages: [ + { role: "user", content: [{ type: "text", text: "Start" }] }, + { role: "assistant", content: [{ type: "text", text: "Partial answer" }] }, + ], + }, + true, + rawHeaders ? { rawHeaders } : null, + provider, + ); + + expect(request({ "x-9router-assistant-prefill": "preserve" }).messages.map((message) => message.role)).toEqual(["user", "assistant"]); + expect(request().messages.map((message) => message.role)).toEqual(["user", "assistant", "user"]); + }); + it("parseSSELine supports provider raw NDJSON stream lines", () => { const raw = JSON.stringify({ model: "gpt-oss:120b",