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'