fix(translator): strip function_call.id for Vertex AI provider (#3440) (#3457)

Vertex AI's FunctionCall/FunctionResponse protos have no id field; emitting it made Vertex reject tool calls with 400 'Unknown name id'. The id is now stripped only when the routed provider is vertex/vertex-partner (threaded via credentials._provider), preserving it for the public Gemini API where Gemini 3+ uses it for signature matching.

Co-authored-by: nullbytef0x <nullbytef0x@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-08 20:54:20 -03:00
committed by GitHub
parent 42887b65b2
commit cc850122e3
5 changed files with 181 additions and 7 deletions

View File

@@ -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

View File

@@ -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;
result = directTranslator(model, result, stream, directCredentials);
} else {
// Fallback: hub-and-spoke via OpenAI
// Step 1: source -> openai (if source is not openai)
@@ -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 : {}),
...(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);

View File

@@ -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";
const result: {
model: string;
contents: Array<Record<string, unknown>>;
@@ -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 || {},
},
@@ -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 },
},

View File

@@ -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;
@@ -430,7 +441,7 @@ function openaiToGeminiBase(
parts.push({
...(embeddedThoughtSignature ? { thoughtSignature: embeddedThoughtSignature } : {}),
functionCall: {
id: id,
...(toolNameOptions.stripFunctionCallId ? {} : { id: id }),
name: sanitizeToolName(fn.name),
args: args,
},
@@ -482,7 +493,7 @@ function openaiToGeminiBase(
toolParts.push({
functionResponse: {
id: fid,
...(toolNameOptions.stripFunctionCallId ? {} : { id: fid }),
name: name,
response:
toolNameOptions.functionResponseShape === "output"
@@ -605,6 +616,7 @@ export function openaiToGeminiRequest(
return openaiToGeminiBase(model, body, stream, {
signatureNamespace,
signaturelessToolCallMode: options.signaturelessToolCallMode,
stripFunctionCallId: isVertexGeminiProvider(credentials?._provider),
});
}

View File

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