diff --git a/package.json b/package.json
index df62bea10ee..ce948e4522d 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
}
}
diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts
index aa205367248..fb75468f633 100644
--- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts
+++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts
@@ -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 dynamic parameter 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;
}),
};
diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/error-handling.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/error-handling.ts
index 8fcaffba28f..8f34eafe1ed 100644
--- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/error-handling.ts
+++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/error-handling.ts
@@ -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 : "
+ 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;
+}
diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/test/LmChatGoogleVertex.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/test/LmChatGoogleVertex.test.ts
index 8b000e2347f..822391fd17e 100644
--- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/test/LmChatGoogleVertex.test.ts
+++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/test/LmChatGoogleVertex.test.ts
@@ -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);
+ }
+ });
+ });
});
diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/test/error-handling.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/test/error-handling.test.ts
new file mode 100644
index 00000000000..fd3de4b4388
--- /dev/null
+++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/test/error-handling.test.ts
@@ -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();
+ });
+});
diff --git a/packages/@n8n/nodes-langchain/utils/agent-execution/buildSteps.ts b/packages/@n8n/nodes-langchain/utils/agent-execution/buildSteps.ts
index 4402ec15c7b..2988fc22c42 100644
--- a/packages/@n8n/nodes-langchain/utils/agent-execution/buildSteps.ts
+++ b/packages/@n8n/nodes-langchain/utils/agent-execution/buildSteps.ts
@@ -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 {
- 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),
});
}
diff --git a/packages/@n8n/nodes-langchain/utils/agent-execution/test/buildSteps.test.ts b/packages/@n8n/nodes-langchain/utils/agent-execution/test/buildSteps.test.ts
index 080390b35f6..5a1dc68bd7b 100644
--- a/packages/@n8n/nodes-langchain/utils/agent-execution/test/buildSteps.test.ts
+++ b/packages/@n8n/nodes-langchain/utils/agent-execution/test/buildSteps.test.ts
@@ -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', () => {
diff --git a/patches/@langchain__google-common@2.1.24.patch b/patches/@langchain__google-common@2.1.24.patch
new file mode 100644
index 00000000000..8acef50fbd0
--- /dev/null
+++ b/patches/@langchain__google-common@2.1.24.patch
@@ -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;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 249b44ee97a..0ec0b0a67a0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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'