mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
committed by
GitHub
parent
42887b65b2
commit
cc850122e3
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
143
tests/unit/vertex-functioncall-id-3440.test.ts
Normal file
143
tests/unit/vertex-functioncall-id-3440.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user