fix(translator): merge consecutive same-role contents for Gemini (port from 9router#2191)

The OpenAI->Gemini request translator built result.contents without a
consecutive-same-role merge pass (unlike the Kiro and Claude request paths).
Client history with adjacent user turns — or a tool-result turn (mapped to
role:"user") immediately followed by a plain user turn — produced two adjacent
role:"user" entries, which Gemini-family endpoints (incl. Antigravity / Vertex)
reject with 400 INVALID_ARGUMENT "Request contains consecutive messages with the
same role". The empty-parts half was already guarded (if parts.length > 0).

Collapse adjacent same-role entries by concatenating their parts right after the
message-conversion loop. Regression guard reproduces both the two-user and the
tool-result-then-user cases and asserts a normal alternating conversation is
unchanged.

Reported-by: warelik (https://github.com/decolua/9router/issues/2191)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-30 12:12:52 -03:00
parent 66374642f9
commit 41fa3df7d1
2 changed files with 118 additions and 0 deletions

View File

@@ -238,6 +238,27 @@ function buildHistoricalToolResultContext(name: string, response: unknown): stri
].join("\n");
}
// 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).
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 {
merged.push(entry);
}
}
return merged;
}
// Core: Convert OpenAI request to Gemini format (base for all variants)
function openaiToGeminiBase(
model: string,
@@ -585,6 +606,9 @@ function openaiToGeminiBase(
}
}
// Collapse any consecutive same-role contents Gemini would reject (9router#2191).
result.contents = mergeConsecutiveSameRoleContents(result.contents ?? []);
// Convert tools
const bodyTools = body.tools as Array<Record<string, unknown>> | undefined;
const geminiTools = buildGeminiTools(bodyTools, {

View File

@@ -0,0 +1,94 @@
import test from "node:test";
import assert from "node:assert/strict";
// Regression for 9router#2191: the OpenAI->Gemini request translator must not
// emit two adjacent `contents[]` entries with the same role. Gemini-family APIs
// (incl. Antigravity / Vertex) reject those with
// 400 INVALID_ARGUMENT "Request contains consecutive messages with the same role".
// The translator had no consecutive-same-role merge pass (unlike the Kiro and
// Claude paths), so consecutive `user` turns — or a tool-result turn (role:user)
// immediately followed by a plain user turn — produced an invalid alternation.
const { openaiToGeminiRequest } = await import(
"../../open-sse/translator/request/openai-to-gemini.ts"
);
type GeminiContent = { role: string; parts: Array<Record<string, unknown>> };
type GeminiReq = { contents: GeminiContent[] };
function assertNoConsecutiveSameRole(contents: GeminiContent[], label: string) {
for (let i = 1; i < contents.length; i++) {
assert.notStrictEqual(
contents[i].role,
contents[i - 1].role,
`${label}: contents[${i - 1}] and contents[${i}] both have role "${contents[i].role}" ` +
`(Gemini rejects consecutive same-role messages)`
);
}
}
test("OpenAI -> Gemini merges two consecutive user messages into one content block", () => {
const body = {
messages: [
{ role: "user", content: "Hello" },
{ role: "user", content: "Additional context" },
],
};
const result = openaiToGeminiRequest("gemini-2.5-pro", body, false) as GeminiReq;
assertNoConsecutiveSameRole(result.contents, "two-user");
// The two user turns collapse into a single user content carrying both parts.
assert.equal(result.contents.length, 1, "expected the two user turns to merge into one");
assert.equal(result.contents[0].role, "user");
const texts = result.contents[0].parts.map((p) => p.text);
assert.deepEqual(texts, ["Hello", "Additional context"]);
});
test("OpenAI -> Gemini does not emit a tool-result(user) turn adjacent to a user turn", () => {
// Agentic history: user -> assistant(tool_call) -> tool(result) -> user.
// The assistant block pushes model + user(toolResponse); the trailing plain
// user turn would otherwise produce two adjacent role:"user" contents.
const body = {
messages: [
{ role: "user", content: "List files" },
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "ls", arguments: '{"path":"."}' },
},
],
},
{ role: "tool", tool_call_id: "call_1", content: "a.ts\nb.ts" },
{ role: "user", content: "Now read a.ts" },
],
};
const result = openaiToGeminiRequest("gemini-2.5-pro", body, false) as GeminiReq;
assertNoConsecutiveSameRole(result.contents, "tool-result-then-user");
// Roles must strictly alternate: user, model, user (toolResp + "Now read a.ts" merged).
assert.deepEqual(
result.contents.map((c) => c.role),
["user", "model", "user"]
);
});
test("OpenAI -> Gemini keeps a normally alternating conversation unchanged", () => {
const body = {
messages: [
{ role: "user", content: "Hi" },
{ role: "assistant", content: "Hello there" },
{ role: "user", content: "How are you?" },
],
};
const result = openaiToGeminiRequest("gemini-2.5-pro", body, false) as GeminiReq;
assertNoConsecutiveSameRole(result.contents, "alternating");
assert.deepEqual(
result.contents.map((c) => c.role),
["user", "model", "user"]
);
});