mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-22 23:22:09 +03:00
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:
1
changelog.d/fixes/claude-to-gemini-consecutive-roles.md
Normal file
1
changelog.d/fixes/claude-to-gemini-consecutive-roles.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(translator):** merge consecutive same-role contents in direct Claude to Gemini request translation to prevent upstream HTTP 400 errors
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
150
tests/unit/claude-to-gemini-consecutive-roles.test.ts
Normal file
150
tests/unit/claude-to-gemini-consecutive-roles.test.ts
Normal 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" },
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user