mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 02:02:13 +03:00
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:
@@ -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, {
|
||||
|
||||
94
tests/unit/translator-gemini-consecutive-role-2191.test.ts
Normal file
94
tests/unit/translator-gemini-consecutive-role-2191.test.ts
Normal 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"]
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user