fix(translator): merge consecutive same-role contents in direct claudeToGeminiRequest (#10658)

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)
This commit is contained in:
MSiva
2026-08-21 04:34:20 +05:30
committed by GitHub
parent d9cb4f5f5d
commit bc9090ba65
5 changed files with 190 additions and 27 deletions

View File

@@ -0,0 +1 @@
- **fix(translator):** merge consecutive same-role contents in direct Claude to Gemini request translation to prevent upstream HTTP 400 errors

View File

@@ -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<Record<string, unknown>>;
contents: GeminiContent[];
generationConfig: Record<string, unknown>;
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;
}

View File

@@ -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<string>([
"googleSearch",
]);
type GeminiPart = Record<string, unknown>;
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,

View File

@@ -152,3 +152,29 @@ export function buildHistoricalToolResultContext(name: string, response: unknown
"</previous_tool_result_context>",
].join("\n");
}
export type GeminiPart = Record<string, unknown>;
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;
}

View File

@@ -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" },
]);
});