Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@

_Development cycle in progress — entries are added as work merges into `release/v3.8.17` and finalized by the release flow._

### 🔧 Bug Fixes

- **fix(translator):** Vertex AI tool calls no longer fail with `400 Unknown name "id" at contents[].parts[].function_call` — the OpenAI-style `id` field is now stripped from `functionCall`/`functionResponse` parts when the routed provider is `vertex`/`vertex-partner`. The public Gemini API still receives `id` (required for Gemini 3+ signature matching). ([#3440](https://github.com/diegosouzapw/OmniRoute/issues/3440) — thanks @nullbytef0x)

---

## [3.8.16] — 2026-06-08
Expand Down
14 changes: 12 additions & 2 deletions open-sse/translator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,13 @@ export function translateRequest(
// Check for direct translation path first (e.g., Claude → Gemini)
const directTranslator = getRequestTranslator(sourceFormat, targetFormat);
if (directTranslator && sourceFormat !== FORMATS.OPENAI && targetFormat !== FORMATS.OPENAI) {
result = directTranslator(model, result, stream, credentials);
// Thread the routed provider id so target translators can apply provider-specific
// quirks (e.g. Vertex rejects function_call.id — #3440).
const directCredentials =
provider != null
? { ...(credentials && typeof credentials === "object" ? credentials : {}), _provider: provider }
: credentials;
Comment on lines +180 to +183

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This ternary check can be simplified. In modern JavaScript/TypeScript, spreading null or undefined is a safe no-op (e.g., { ...null } evaluates to {}). We can simplify the check to typeof credentials === "object" ? credentials : null to avoid the redundant truthiness check.

Suggested change
const directCredentials =
provider != null
? { ...(credentials && typeof credentials === "object" ? credentials : {}), _provider: provider }
: credentials;
const directCredentials =
provider != null
? { ...(typeof credentials === "object" ? credentials : null), _provider: provider }
: credentials;

result = directTranslator(model, result, stream, directCredentials);
} else {
// Fallback: hub-and-spoke via OpenAI
// Step 1: source -> openai (if source is not openai)
Expand Down Expand Up @@ -205,13 +211,17 @@ export function translateRequest(
const hasNs = options?.signatureNamespace != null;
const hasPreCompression = options?.preCompressionBody != null;
const hasCopilot = options?.copilotClient === true;
const hasProvider = provider != null;
const translationCredentials =
hasNs || hasPreCompression || hasCopilot
hasNs || hasPreCompression || hasCopilot || hasProvider
? {
...(credentials && typeof credentials === "object" ? credentials : {}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similarly, we can simplify this spread expression by using typeof credentials === "object" ? credentials : null to avoid the redundant truthiness check.

Suggested change
...(credentials && typeof credentials === "object" ? credentials : {}),
...(typeof credentials === "object" ? credentials : null),

...(hasNs ? { _signatureNamespace: options.signatureNamespace } : {}),
...(hasPreCompression ? { _preCompressionBody: options.preCompressionBody } : {}),
...(hasCopilot ? { _copilotClient: true } : {}),
// Routed provider id so target translators can apply provider-specific
// quirks (e.g. Vertex rejects function_call.id — #3440).
...(hasProvider ? { _provider: provider } : {}),
}
: credentials;
result = fromOpenAI(model, result, stream, translationCredentials);
Expand Down
11 changes: 8 additions & 3 deletions open-sse/translator/request/claude-to-gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,17 @@ import { capMaxOutputTokens } from "../../../src/lib/modelCapabilities.ts";
* Converts Claude Messages API body directly to Gemini format,
* skipping the OpenAI hub intermediate step.
*/
export function claudeToGeminiRequest(model, body, stream) {
export function claudeToGeminiRequest(model, body, stream, credentials = null) {
const toolNameMap = new Map<string, string>();
const sanitizeToolName = (name: string) =>
sanitizeGeminiToolName(name, {
toolNameMap,
});
// Vertex AI rejects the `id` field inside function_call / function_response parts
// (#3440). The public Gemini API keeps it for Gemini 3+ signature matching, so this
// is scoped to the routed vertex provider only (threaded via credentials._provider).
const provider = credentials && typeof credentials === "object" ? credentials._provider : null;
const stripFunctionCallId = provider === "vertex" || provider === "vertex-partner";
Comment on lines +26 to +27

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

We can simplify this check using optional chaining (credentials?._provider). This is more idiomatic and readable in TypeScript.

Suggested change
const provider = credentials && typeof credentials === "object" ? credentials._provider : null;
const stripFunctionCallId = provider === "vertex" || provider === "vertex-partner";
const provider = credentials?._provider;
const stripFunctionCallId = provider === "vertex" || provider === "vertex-partner";

const result: {
model: string;
contents: Array<Record<string, unknown>>;
Expand Down Expand Up @@ -105,7 +110,7 @@ export function claudeToGeminiRequest(model, body, stream) {
case "tool_use":
parts.push({
functionCall: {
id: block.id,
...(stripFunctionCallId ? {} : { id: block.id }),
name: sanitizeToolName(block.name),
args: block.input || {},
},
Expand All @@ -127,7 +132,7 @@ export function claudeToGeminiRequest(model, body, stream) {
}
parts.push({
functionResponse: {
id: block.tool_use_id,
...(stripFunctionCallId ? {} : { id: block.tool_use_id }),
name: toolUseNames[block.tool_use_id] || "unknown",
response: { result: parsedContent },
},
Expand Down
16 changes: 14 additions & 2 deletions open-sse/translator/request/openai-to-gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,19 @@ type GeminiToolNameOptions = {
functionResponseShape?: "result" | "output";
signatureNamespace?: string | null;
signaturelessToolCallMode?: "native" | "text" | "context";
// Vertex AI's FunctionCall/FunctionResponse protos have no `id` field; emitting it
// makes Vertex reject the request with 400 "Unknown name id" (#3440). The public
// Gemini API DOES use `id` for Gemini 3+ signature matching, so this is scoped to
// the vertex provider only.
stripFunctionCallId?: boolean;
};

// Vertex AI (and Vertex Partner models) reject the OpenAI-style `id` field inside
// function_call / function_response parts. Detect these by the routed provider id.
function isVertexGeminiProvider(provider: unknown): boolean {
return provider === "vertex" || provider === "vertex-partner";
}

type OpenAIToolCallLike = {
thoughtSignature?: unknown;
thought_signature?: unknown;
Expand Down Expand Up @@ -430,7 +441,7 @@ function openaiToGeminiBase(
parts.push({
...(embeddedThoughtSignature ? { thoughtSignature: embeddedThoughtSignature } : {}),
functionCall: {
id: id,
...(toolNameOptions.stripFunctionCallId ? {} : { id: id }),
name: sanitizeToolName(fn.name),
args: args,
},
Expand Down Expand Up @@ -482,7 +493,7 @@ function openaiToGeminiBase(

toolParts.push({
functionResponse: {
id: fid,
...(toolNameOptions.stripFunctionCallId ? {} : { id: fid }),
name: name,
response:
toolNameOptions.functionResponseShape === "output"
Expand Down Expand Up @@ -605,6 +616,7 @@ export function openaiToGeminiRequest(
return openaiToGeminiBase(model, body, stream, {
signatureNamespace,
signaturelessToolCallMode: options.signaturelessToolCallMode,
stripFunctionCallId: isVertexGeminiProvider(credentials?._provider),
});
}

Expand Down
143 changes: 143 additions & 0 deletions tests/unit/vertex-functioncall-id-3440.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Regression test for #3440 — Vertex AI rejects tool calls because OmniRoute
// emits an `id` field inside `function_call` / `function_response` parts.
//
// Vertex AI (aiplatform.googleapis.com) follows an older Gemini REST schema whose
// FunctionCall/FunctionResponse protos have no `id` field, so it returns
// `400 INVALID_ARGUMENT: Unknown name "id" at 'contents[].parts[].function_call'`.
// The public Gemini API (generativelanguage.googleapis.com) DOES use `id` for
// Gemini 3+ signature matching, so the strip must be scoped to the vertex provider
// only (threaded via credentials._provider), never applied unconditionally.

import test from "node:test";
import assert from "node:assert/strict";

const { openaiToGeminiRequest } = await import(
"../../open-sse/translator/request/openai-to-gemini.ts"
);
const { claudeToGeminiRequest } = await import(
"../../open-sse/translator/request/claude-to-gemini.ts"
);

type UnknownRecord = Record<string, unknown>;

function findFunctionCall(result: any): UnknownRecord | undefined {
for (const content of result.contents ?? []) {
for (const part of content.parts ?? []) {
if (part?.functionCall) return part.functionCall as UnknownRecord;
}
}
return undefined;
}

function findFunctionResponse(result: any): UnknownRecord | undefined {
for (const content of result.contents ?? []) {
for (const part of content.parts ?? []) {
if (part?.functionResponse) return part.functionResponse as UnknownRecord;
}
}
return undefined;
}

const OPENAI_TOOL_BODY = {
messages: [
{ role: "user", content: "What's the weather?" },
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_weather_1",
type: "function",
function: { name: "get_weather", arguments: '{"city":"Tokyo"}' },
},
],
},
{ role: "tool", tool_call_id: "call_weather_1", content: '{"temp":20}' },
],
tools: [
{
type: "function",
function: {
name: "get_weather",
description: "Get weather",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
},
},
],
};

const CLAUDE_TOOL_BODY = {
messages: [
{ role: "user", content: "What's the weather?" },
{
role: "assistant",
content: [
{ type: "tool_use", id: "tu_weather_1", name: "get_weather", input: { city: "Tokyo" } },
],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "tu_weather_1", content: '{"temp":20}' }],
},
],
};

test("#3440 OpenAI->Gemini: vertex provider omits id from functionCall and functionResponse", () => {
const result = openaiToGeminiRequest("gemini-2.5-pro", OPENAI_TOOL_BODY, false, {
_provider: "vertex",
});

const fc = findFunctionCall(result);
assert.ok(fc, "expected a functionCall part");
assert.equal(fc.id, undefined, "functionCall.id must be omitted for Vertex");
assert.equal(fc.name, "get_weather");

const fr = findFunctionResponse(result);
assert.ok(fr, "expected a functionResponse part");
assert.equal(fr.id, undefined, "functionResponse.id must be omitted for Vertex");
});

test("#3440 OpenAI->Gemini: vertex-partner provider also omits id", () => {
const result = openaiToGeminiRequest("gemini-2.5-pro", OPENAI_TOOL_BODY, false, {
_provider: "vertex-partner",
});
assert.equal(findFunctionCall(result)?.id, undefined);
assert.equal(findFunctionResponse(result)?.id, undefined);
});

test("#3440 OpenAI->Gemini: public gemini provider PRESERVES id (Gemini 3+ signature matching)", () => {
const result = openaiToGeminiRequest("gemini-2.5-pro", OPENAI_TOOL_BODY, false, {
_provider: "gemini",
});
assert.equal(
findFunctionCall(result)?.id,
"call_weather_1",
"functionCall.id must be preserved for the public Gemini API"
);
});

test("#3440 OpenAI->Gemini: no provider hint PRESERVES id (default, non-vertex)", () => {
const result = openaiToGeminiRequest("gemini-2.5-pro", OPENAI_TOOL_BODY, false, null);
assert.equal(findFunctionCall(result)?.id, "call_weather_1");
});

test("#3440 Claude->Gemini: vertex provider omits id from functionCall and functionResponse", () => {
const result = claudeToGeminiRequest("gemini-2.5-pro", CLAUDE_TOOL_BODY, false, {
_provider: "vertex",
});
assert.equal(findFunctionCall(result)?.id, undefined, "functionCall.id must be omitted for Vertex");
assert.equal(
findFunctionResponse(result)?.id,
undefined,
"functionResponse.id must be omitted for Vertex"
);
});

test("#3440 Claude->Gemini: no provider hint PRESERVES id (default, non-vertex)", () => {
const result = claudeToGeminiRequest("gemini-2.5-pro", CLAUDE_TOOL_BODY, false);
assert.equal(findFunctionCall(result)?.id, "tu_weather_1");
});