From bc9090ba658f783f68a9430edb418bebf518e84c Mon Sep 17 00:00:00 2001 From: MSiva <113901375+Siva010@users.noreply.github.com> Date: Fri, 21 Aug 2026 04:34:20 +0530 Subject: [PATCH] fix(translator): merge consecutive same-role contents in direct claudeToGeminiRequest (#10658) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Obrigado — bug real: a tradução direta claudeToGeminiRequest emitia mensagens consecutivas do mesmo role em contents[], o que a API do Gemini rejeita com HTTP 400 (turnos alternados user/model são obrigatórios). Traz claudeToGeminiRequest à paridade com openaiToGeminiRequest reutilizando mergeConsecutiveSameRoleContents. Validação (worktree combinado a partir de origin/release/v3.8.50, merge limpo, 0 conflitos): - typecheck:core limpo, complexity/cognitive-complexity dentro do baseline - tests/unit/claude-to-gemini-consecutive-roles.test.ts — 7/7 passando - tests/unit/claude-to-gemini-budget-tokens-zero-6813.test.ts — 2/2 passando (sem regressão) --- .../claude-to-gemini-consecutive-roles.md | 1 + .../translator/request/claude-to-gemini.ts | 9 +- .../translator/request/openai-to-gemini.ts | 31 +--- .../request/openai-to-gemini/helpers.ts | 26 +++ ...claude-to-gemini-consecutive-roles.test.ts | 150 ++++++++++++++++++ 5 files changed, 190 insertions(+), 27 deletions(-) create mode 100644 changelog.d/fixes/claude-to-gemini-consecutive-roles.md create mode 100644 tests/unit/claude-to-gemini-consecutive-roles.test.ts diff --git a/changelog.d/fixes/claude-to-gemini-consecutive-roles.md b/changelog.d/fixes/claude-to-gemini-consecutive-roles.md new file mode 100644 index 0000000000..17483dce52 --- /dev/null +++ b/changelog.d/fixes/claude-to-gemini-consecutive-roles.md @@ -0,0 +1 @@ +- **fix(translator):** merge consecutive same-role contents in direct Claude to Gemini request translation to prevent upstream HTTP 400 errors diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index b45bf7730a..853e696dbb 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -15,6 +15,8 @@ import { getModelSpec } from "../../../src/shared/constants/modelSpecs.ts"; import { buildChangedToolNameMap, buildHistoricalToolResultContext, + mergeConsecutiveSameRoleContents, + type GeminiContent, } from "./openai-to-gemini/helpers.ts"; /** @@ -45,7 +47,7 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { : null; const result: { model: string; - contents: Array>; + contents: GeminiContent[]; generationConfig: Record; safetySettings: unknown; systemInstruction?: { role: string; parts: Array<{ text: string }> }; @@ -314,6 +316,11 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { result._toolNameMap = changedToolNameMap; } + // Gemini strictly rejects requests containing consecutive messages with the same role + // (400 INVALID_ARGUMENT: "Request contains consecutive messages with the same role"). + // Normalize adjacent same-role messages by concatenating their parts. + result.contents = mergeConsecutiveSameRoleContents(result.contents); + return result; } diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index cb1cedd713..92399743ff 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -39,8 +39,13 @@ import { escapeHistoricalContextAttribute, escapeHistoricalContextContent, buildHistoricalToolResultContext, + type GeminiPart, + type GeminiContent, + mergeConsecutiveSameRoleContents, } from "./openai-to-gemini/helpers.ts"; +export { mergeConsecutiveSameRoleContents, type GeminiContent, type GeminiPart }; + // Observed Antigravity wrapper output cap, not an underlying model capability. // Keep this bridge-local: Antigravity currently caps visible output around 16K. // See: https://github.com/keisksw/antigravity-output-analysis @@ -56,9 +61,6 @@ const GEMINI_BUILTIN_TOOL_NAMES = new Set([ "googleSearch", ]); -type GeminiPart = Record; -type GeminiContent = { role: string; parts: GeminiPart[] }; - type GeminiFunctionDeclaration = { name: string; description: string; @@ -158,29 +160,6 @@ type GeminiToolNameOptions = { supportsSignatureBypass?: boolean; }; -// Gemini-family APIs (incl. Antigravity / Vertex) reject a `contents[]` array that -// has two adjacent entries with the same role: -// 400 INVALID_ARGUMENT "Request contains consecutive messages with the same role". -// Client history that carries consecutive user turns — or a tool-result turn (mapped -// to role:"user") immediately followed by a plain user turn — would otherwise leak -// that invalid alternation through. Merge adjacent same-role entries by concatenating -// their parts, the same normalization the Kiro and Claude request paths already apply -// (9router#2191). -export function mergeConsecutiveSameRoleContents(contents: GeminiContent[]): GeminiContent[] { - const merged: GeminiContent[] = []; - for (const entry of contents) { - const last = merged[merged.length - 1]; - if (last && last.role === entry.role) { - last.parts.push(...entry.parts); - } else { - // Shallow-copy the entry and its `parts` array so a later same-role merge - // (`last.parts.push(...)`) never mutates the caller's input objects. - merged.push({ ...entry, parts: [...entry.parts] }); - } - } - return merged; -} - // Core: Convert OpenAI request to Gemini format (base for all variants) function openaiToGeminiBase( model: string, diff --git a/open-sse/translator/request/openai-to-gemini/helpers.ts b/open-sse/translator/request/openai-to-gemini/helpers.ts index 810620d8f9..092a857a2c 100644 --- a/open-sse/translator/request/openai-to-gemini/helpers.ts +++ b/open-sse/translator/request/openai-to-gemini/helpers.ts @@ -152,3 +152,29 @@ export function buildHistoricalToolResultContext(name: string, response: unknown "", ].join("\n"); } + +export type GeminiPart = Record; +export type GeminiContent = { role: string; parts: GeminiPart[] }; + +// Gemini-family APIs (incl. Antigravity / Vertex) reject a `contents[]` array that +// has two adjacent entries with the same role: +// 400 INVALID_ARGUMENT "Request contains consecutive messages with the same role". +// Client history that carries consecutive user turns — or a tool-result turn (mapped +// to role:"user") immediately followed by a plain user turn — would otherwise leak +// that invalid alternation through. Merge adjacent same-role entries by concatenating +// their parts, the same normalization the Kiro and Claude request paths already apply +// (9router#2191). +export function mergeConsecutiveSameRoleContents(contents: GeminiContent[]): GeminiContent[] { + const merged: GeminiContent[] = []; + for (const entry of contents) { + const last = merged[merged.length - 1]; + if (last && last.role === entry.role) { + last.parts.push(...entry.parts); + } else { + // Shallow-copy the entry and its `parts` array so a later same-role merge + // (`last.parts.push(...)`) never mutates the caller's input objects. + merged.push({ ...entry, parts: [...entry.parts] }); + } + } + return merged; +} diff --git a/tests/unit/claude-to-gemini-consecutive-roles.test.ts b/tests/unit/claude-to-gemini-consecutive-roles.test.ts new file mode 100644 index 0000000000..eef412fc13 --- /dev/null +++ b/tests/unit/claude-to-gemini-consecutive-roles.test.ts @@ -0,0 +1,150 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { claudeToGeminiRequest } = + await import("../../open-sse/translator/request/claude-to-gemini.ts"); + +test("Claude -> Gemini merges consecutive user text turns into a single user turn", () => { + const result = claudeToGeminiRequest( + "gemini-2.5-flash", + { + messages: [ + { role: "user", content: "hello" }, + { role: "user", content: [{ type: "text", text: "world" }] }, + ], + }, + false + ); + + assert.equal(result.contents.length, 1); + assert.equal(result.contents[0].role, "user"); + assert.deepEqual(result.contents[0].parts, [{ text: "hello" }, { text: "world" }]); +}); + +test("Claude -> Gemini merges tool_result and subsequent user instruction into single user turn", () => { + const result = claudeToGeminiRequest( + "gemini-2.5-flash", + { + messages: [ + { role: "user", content: "Calculate 2+2" }, + { + role: "assistant", + content: [{ type: "text", text: "I will calculate that." }], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool_call_1", + content: "4", + }, + ], + }, + { + role: "user", + content: "Now add 10 to that result", + }, + ], + }, + false + ); + + // Contents must alternate properly and not have consecutive same-role turns + for (let i = 1; i < result.contents.length; i++) { + assert.notEqual( + result.contents[i].role, + result.contents[i - 1].role, + `Consecutive same-role detected at index ${i - 1} and ${i}: ${result.contents[i].role}` + ); + } + + // The last turn should be a merged user turn containing both the tool context and the text + const lastTurn = result.contents[result.contents.length - 1]; + assert.equal(lastTurn.role, "user"); + assert.equal(lastTurn.parts.length, 2); + assert.ok( + typeof (lastTurn.parts[0] as { text: string }).text === "string" && + (lastTurn.parts[0] as { text: string }).text.includes("previous_tool_result_context") + ); + assert.deepEqual(lastTurn.parts[1], { text: "Now add 10 to that result" }); +}); + +test("Claude -> Gemini preserves alternating conversation turns without spurious merging", () => { + const result = claudeToGeminiRequest( + "gemini-2.5-flash", + { + messages: [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi! How can I help?" }, + { role: "user", content: "What is the capital of France?" }, + ], + }, + false + ); + + assert.equal(result.contents.length, 3); + assert.equal(result.contents[0].role, "user"); + assert.deepEqual(result.contents[0].parts, [{ text: "Hello" }]); + assert.equal(result.contents[1].role, "model"); + assert.deepEqual(result.contents[1].parts, [{ text: "Hi! How can I help?" }]); + assert.equal(result.contents[2].role, "user"); + assert.deepEqual(result.contents[2].parts, [{ text: "What is the capital of France?" }]); +}); + +test("Claude -> Gemini merges three or more consecutive user turns into a single user turn", () => { + const result = claudeToGeminiRequest( + "gemini-2.5-flash", + { + messages: [ + { role: "user", content: "part 1" }, + { role: "user", content: "part 2" }, + { role: "user", content: "part 3" }, + ], + }, + false + ); + + assert.equal(result.contents.length, 1); + assert.equal(result.contents[0].role, "user"); + assert.deepEqual(result.contents[0].parts, [ + { text: "part 1" }, + { text: "part 2" }, + { text: "part 3" }, + ]); +}); + +test("Claude -> Gemini handles empty messages array without error", () => { + const result = claudeToGeminiRequest( + "gemini-2.5-flash", + { + messages: [], + }, + false + ); + + assert.deepEqual(result.contents, []); +}); + +test("Claude -> Gemini merges consecutive assistant turns into a single model turn", () => { + const result = claudeToGeminiRequest( + "gemini-2.5-flash", + { + messages: [ + { role: "user", content: "hello" }, + { role: "assistant", content: "response part 1" }, + { role: "assistant", content: [{ type: "text", text: "response part 2" }] }, + ], + }, + false + ); + + assert.equal(result.contents.length, 2); + assert.equal(result.contents[0].role, "user"); + assert.deepEqual(result.contents[0].parts, [{ text: "hello" }]); + assert.equal(result.contents[1].role, "model"); + assert.deepEqual(result.contents[1].parts, [ + { text: "response part 1" }, + { text: "response part 2" }, + ]); +});