fix(Google Vertex Chat Model Node): Preserve Gemini thought signature on single tool calls (#34797)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: yehorkardash <yehor.kardash@n8n.io>
This commit is contained in:
Eugene 2026-07-28 11:51:15 +02:00 committed by GitHub
parent 18964aaeb5
commit c6e5ecd861
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 265 additions and 14 deletions

View File

@ -223,7 +223,8 @@
"vuedraggable@4.1.0": "patches/vuedraggable@4.1.0.patch",
"assert@2.1.0": "patches/assert@2.1.0.patch",
"lodash@4.18.1": "patches/lodash@4.18.1.patch",
"@langchain/openai@1.4.4": "patches/@langchain__openai@1.4.4.patch"
"@langchain/openai@1.4.4": "patches/@langchain__openai@1.4.4.patch",
"@langchain/google-common@2.1.24": "patches/@langchain__google-common@2.1.24.patch"
}
}
}

View File

@ -15,11 +15,12 @@ import {
type SupplyData,
type ILoadOptionsFunctions,
type JsonObject,
type NodeError,
NodeOperationError,
validateNodeParameters,
} from 'n8n-workflow';
import { makeErrorFromStatus } from './error-handling';
import { extractGoogleErrorMessage, makeErrorFromStatus } from './error-handling';
import { getAdditionalOptions } from '../gemini-common/additional-options';
import {
getVertexEndpoint,
@ -27,6 +28,14 @@ import {
vertexLocationField,
} from '../gemini-common/vertex-location';
function errorDescriptionMapper(error: NodeError) {
if (error.description?.includes('properties: should be non-empty for OBJECT type')) {
return 'Google Vertex requires at least one <a href="https://docs.n8n.io/advanced-ai/examples/using-the-fromai-function/" target="_blank">dynamic parameter</a> when using tools';
}
return error.description ?? 'Unknown error';
}
export class LmChatGoogleVertex implements INodeType {
description: INodeTypeDescription = {
displayName: 'Google Vertex Chat Model',
@ -198,11 +207,12 @@ export class LmChatGoogleVertex implements INodeType {
temperature: options.temperature,
maxOutputTokens: options.maxOutputTokens,
safetySettings,
callbacks: [new N8nLlmTracing(this)],
callbacks: [new N8nLlmTracing(this, { errorDescriptionMapper })],
// Handle ChatVertexAI invocation errors to provide better error messages
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, (error: any) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const customError = makeErrorFromStatus(Number(error?.response?.status), {
const status = Number(error?.response?.status);
const customError = makeErrorFromStatus(status, {
modelName,
});
@ -210,6 +220,18 @@ export class LmChatGoogleVertex implements INodeType {
throw new NodeOperationError(this.getNode(), error as JsonObject, customError);
}
if (status === 400) {
// Surface Google's error detail; a bare rethrow would hide it behind a
// generic "Bad request" wrapper
throw new NodeOperationError(this.getNode(), error as JsonObject, {
message: 'Bad request - please check your parameters',
description:
extractGoogleErrorMessage(error as { message?: string }) ??
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(typeof error?.message === 'string' ? (error.message as string) : undefined),
});
}
throw error;
}),
};

View File

@ -23,3 +23,40 @@ export function makeErrorFromStatus(statusCode: number, context?: ErrorContext):
return errorMessages[statusCode];
}
interface GoogleErrorLike {
message?: string;
response?: { status?: number; data?: unknown };
}
function readGoogleErrorMessage(data: unknown): string | undefined {
// Google error bodies look like `{ "error": { "message": "..." } }`, sometimes array-wrapped
const entry: unknown = Array.isArray(data) ? data[0] : data;
if (entry && typeof entry === 'object' && 'error' in entry) {
const innerError = (entry as { error?: { message?: unknown } }).error;
if (innerError && typeof innerError.message === 'string') return innerError.message;
}
return undefined;
}
/**
* Extracts the human-readable Google API error message from a failed request, so it can be
* surfaced to the user instead of a generic status-code wrapper.
*/
export function extractGoogleErrorMessage(error: GoogleErrorLike): string | undefined {
const fromData = readGoogleErrorMessage(error.response?.data);
if (fromData) return fromData;
// @langchain/google-common embeds the raw body in the error message:
// "Google request failed with status code <n>: <body>"
const match = error.message?.match(/status code \d+: ([\s\S]+)/);
if (match) {
try {
return readGoogleErrorMessage(JSON.parse(match[1])) ?? match[1];
} catch {
return match[1];
}
}
return undefined;
}

View File

@ -201,4 +201,81 @@ describe('LmChatGoogleVertex - Thinking Budget', () => {
);
});
});
describe('supplyData - failed attempt error handling', () => {
const getCustomErrorHandler = async () => {
const mockContext = setupMockContext();
mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
if (paramName === 'modelName') return 'gemini-3.1-flash-lite';
if (paramName === 'projectId') return 'test-project';
if (paramName === 'options') return {};
if (paramName === 'options.safetySettings.values') return null;
return undefined;
});
await lmChatGoogleVertex.supplyData.call(mockContext, 0);
return mockedMakeN8nLlmFailedAttemptHandler.mock.calls[0][1]!;
};
it('should map 403 to a friendly unauthorized error', async () => {
const handler = await getCustomErrorHandler();
expect(() => handler({ response: { status: 403 } })).toThrowError(
'Unauthorized for this project',
);
});
it('should surface the Google error detail on 400', async () => {
const handler = await getCustomErrorHandler();
const error = {
message: 'Google request failed with status code 400',
response: {
status: 400,
data: { error: { message: 'Function call is missing a thought_signature' } },
},
};
try {
handler(error);
expect.unreachable('handler should throw');
} catch (e) {
expect(e).toMatchObject({
message: 'Bad request - please check your parameters',
description: 'Function call is missing a thought_signature',
});
}
});
it('should fall back to the raw error message on 400 when no Google detail is found', async () => {
const handler = await getCustomErrorHandler();
const error = {
message: 'Request had invalid authentication scopes',
response: { status: 400 },
};
try {
handler(error);
expect.unreachable('handler should throw');
} catch (e) {
expect(e).toMatchObject({
message: 'Bad request - please check your parameters',
description: 'Request had invalid authentication scopes',
});
}
});
it('should rethrow the original error for unmapped statuses', async () => {
const handler = await getCustomErrorHandler();
const error = { message: 'boom', response: { status: 500 } };
try {
handler(error);
expect.unreachable('handler should throw');
} catch (e) {
expect(e).toBe(error);
}
});
});
});

View File

@ -0,0 +1,67 @@
import { extractGoogleErrorMessage, makeErrorFromStatus } from '../error-handling';
describe('makeErrorFromStatus', () => {
it('should map 403 to an unauthorized error', () => {
expect(makeErrorFromStatus(403)).toEqual({
message: 'Unauthorized for this project',
description:
'Check your Google Cloud project ID, that your credential has access to that project and that billing is enabled',
});
});
it('should map 404 with the model name', () => {
expect(makeErrorFromStatus(404, { modelName: 'gemini-3.1-flash-lite' })).toEqual({
message: "No model found called 'gemini-3.1-flash-lite'",
});
});
it('should return undefined for unmapped statuses', () => {
expect(makeErrorFromStatus(400)).toBeUndefined();
expect(makeErrorFromStatus(500)).toBeUndefined();
});
});
describe('extractGoogleErrorMessage', () => {
it('should read the message from response data', () => {
const error = {
message: 'Google request failed with status code 400',
response: {
status: 400,
data: { error: { code: 400, message: 'Function call is missing a thought_signature' } },
},
};
expect(extractGoogleErrorMessage(error)).toBe('Function call is missing a thought_signature');
});
it('should read the message from array-wrapped response data', () => {
const error = {
response: {
status: 400,
data: [{ error: { message: 'properties: should be non-empty for OBJECT type' } }],
},
};
expect(extractGoogleErrorMessage(error)).toBe(
'properties: should be non-empty for OBJECT type',
);
});
it('should parse the body embedded in the error message', () => {
const error = {
message:
'Google request failed with status code 400: {"error":{"message":"Function call is missing a thought_signature","status":"INVALID_ARGUMENT"}}',
};
expect(extractGoogleErrorMessage(error)).toBe('Function call is missing a thought_signature');
});
it('should fall back to the raw embedded body when it is not JSON', () => {
const error = {
message: 'Google request failed with status code 400: something went wrong',
};
expect(extractGoogleErrorMessage(error)).toBe('something went wrong');
});
it('should return undefined when no detail is available', () => {
expect(extractGoogleErrorMessage({ message: 'Bad request' })).toBeUndefined();
expect(extractGoogleErrorMessage({})).toBeUndefined();
});
});

View File

@ -191,21 +191,30 @@ interface ProcessedToolResponse {
* The structure matches what @langchain/google-common expects:
* - `__gemini_function_call_thought_signatures__`: maps the first tool call ID to the signature
* - `tool_calls`: array of tool call descriptors
* - `signatures`: array aligned to parts [textPart, functionCall_1, ...], with the signature
* only on the first function call (per Google's docs)
* - `signatures`: array positionally aligned to the Gemini request parts, with the signature
* only on the first function call (per Google's docs). @langchain/google-common applies it
* only when `signatures.length` equals the part count, so the array must account for whether
* the message content produces a leading text part.
*
* @param toolCalls - Tool calls to include
* @param thoughtSignature - The Gemini thought signature
* @param hasLeadingTextPart - Whether the message content serializes to a text part before the
* function-call parts
* @returns additional_kwargs object for AIMessage
*/
function buildGeminiAdditionalKwargs(
toolCalls: Array<{ id: string; name: string; args: IDataObject }>,
thoughtSignature: string,
hasLeadingTextPart: boolean,
): Record<string, unknown> {
const signatures: string[] = ['', thoughtSignature];
for (let i = 2; i <= toolCalls.length; i++) {
signatures.push('');
}
const signatures = [
// Reserve the first position for the leading text part, which has no signature
...(hasLeadingTextPart ? [''] : []),
// Gemini attaches the thought signature only to the first function call
thoughtSignature,
// Reserve positions for the remaining unsigned function calls
...toolCalls.slice(1).map(() => ''),
];
return {
__gemini_function_call_thought_signatures__: {
@ -242,6 +251,8 @@ function buildIndividualAIMessage(
? buildGeminiAdditionalKwargs(
[{ id: toolId, name: toolName, args: toolInput }],
providerMetadata.thoughtSignature,
// Empty content produces no text part, so the function call is the first part
content !== null && content.length > 0,
)
: {};
// DeepSeek's thinking mode requires reasoning_content to be echoed back on the
@ -289,7 +300,8 @@ function buildSharedGeminiAIMessage(
return new AIMessage({
content: `Calling tools: ${toolNames}`,
tool_calls: allToolCalls,
additional_kwargs: buildGeminiAdditionalKwargs(allToolCalls, thoughtSignature),
// String content serializes to a text part preceding the function-call parts
additional_kwargs: buildGeminiAdditionalKwargs(allToolCalls, thoughtSignature, true),
});
}

View File

@ -1519,6 +1519,10 @@ describe('buildSteps', () => {
expect(message.additional_kwargs.__gemini_function_call_thought_signatures__).toEqual({
call_123: 'gemini_thought_sig_abc123',
});
// Content is empty, so the function call is the only Gemini request part; the
// signatures array must align with the parts or google-common drops it entirely
expect(message.content).toEqual([]);
expect(message.additional_kwargs.signatures).toEqual(['gemini_thought_sig_abc123']);
});
it('should group parallel tool calls into shared AIMessage with Gemini signature', () => {

View File

@ -0,0 +1,28 @@
diff --git a/dist/auth.cjs b/dist/auth.cjs
index 37cb150e17c88285b79d62883715b33dab80ac6a..6c73f2a6f4d6e05c97bf1dd8c6334bb6ef4e7638 100644
--- a/dist/auth.cjs
+++ b/dist/auth.cjs
@@ -54,6 +54,9 @@ var GoogleAbstractedFetchClient = class {
body = JSON.stringify(err.response.data);
} catch {}
}
+ // gaxios (responseType "stream") parses the error body into err.message
+ // while leaving response.data unset; fall back so the detail isn't lost
+ if (body === undefined && typeof err?.message === "string" && err.message.length > 0) body = err.message;
this._throwRequestError(status, body, err?.response ?? { status }, context);
}
throw fetchError;
diff --git a/dist/auth.js b/dist/auth.js
index b3f1afa39a53dfb2b4e7020df01e17f54996dacc..111cb0005d210addf190036be9e191597f72da91 100644
--- a/dist/auth.js
+++ b/dist/auth.js
@@ -54,6 +54,9 @@ var GoogleAbstractedFetchClient = class {
body = JSON.stringify(err.response.data);
} catch {}
}
+ // gaxios (responseType "stream") parses the error body into err.message
+ // while leaving response.data unset; fall back so the detail isn't lost
+ if (body === undefined && typeof err?.message === "string" && err.message.length > 0) body = err.message;
this._throwRequestError(status, body, err?.response ?? { status }, context);
}
throw fetchError;

View File

@ -782,6 +782,9 @@ overrides:
'@babel/traverse': ^7.23.2
patchedDependencies:
'@langchain/google-common@2.1.24':
hash: b50436a6c7b0a82d54472dff331ba1b42ab8b816995f83295eef2ee4b482a87d
path: patches/@langchain__google-common@2.1.24.patch
'@langchain/openai@1.4.4':
hash: 0160a23d307d3bb4aedce23be0d1df4224738bee9184dd4b56bc03eaa37c9a87
path: patches/@langchain__openai@1.4.4.patch
@ -3117,7 +3120,7 @@ importers:
version: 1.2.0(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0))(openai@6.46.0(@smithy/signature-v4@5.3.5)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))
'@langchain/google-common':
specifier: 2.1.24
version: 2.1.24(@langchain/core@1.2.0(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0))(openai@6.46.0(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))
version: 2.1.24(patch_hash=b50436a6c7b0a82d54472dff331ba1b42ab8b816995f83295eef2ee4b482a87d)(@langchain/core@1.2.0(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0))(openai@6.46.0(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))
'@langchain/google-genai':
specifier: 2.1.24
version: 2.1.24(@langchain/core@1.2.0(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0))(openai@6.46.0(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))
@ -26599,14 +26602,14 @@ snapshots:
- openai
- ws
'@langchain/google-common@2.1.24(@langchain/core@1.2.0(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0))(openai@6.46.0(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))':
'@langchain/google-common@2.1.24(patch_hash=b50436a6c7b0a82d54472dff331ba1b42ab8b816995f83295eef2ee4b482a87d)(@langchain/core@1.2.0(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0))(openai@6.46.0(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))':
dependencies:
'@langchain/core': 1.2.0(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0))(openai@6.46.0(@smithy/signature-v4@5.3.5)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))
uuid: 13.0.1
'@langchain/google-gauth@2.1.24(@langchain/core@1.2.0(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0))(openai@6.46.0(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))':
dependencies:
'@langchain/google-common': 2.1.24(@langchain/core@1.2.0(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0))(openai@6.46.0(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))
'@langchain/google-common': 2.1.24(patch_hash=b50436a6c7b0a82d54472dff331ba1b42ab8b816995f83295eef2ee4b482a87d)(@langchain/core@1.2.0(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0))(openai@6.46.0(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))
google-auth-library: 10.1.0
transitivePeerDependencies:
- '@langchain/core'