From c44c7de59b2aa78dcfc2a2d3916be863a7778381 Mon Sep 17 00:00:00 2001 From: Michael Drury Date: Tue, 11 Aug 2026 10:19:10 +0100 Subject: [PATCH] fix(core): Surface model discovery authentication failures to users (#35979) --- .../__tests__/model-discovery.test.ts | 63 +++++++++++++++++-- .../src/model-discovery/request.ts | 11 +++- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/packages/@n8n/ai-utilities/src/model-discovery/__tests__/model-discovery.test.ts b/packages/@n8n/ai-utilities/src/model-discovery/__tests__/model-discovery.test.ts index 04e8c9ba85a..a2fb138d160 100644 --- a/packages/@n8n/ai-utilities/src/model-discovery/__tests__/model-discovery.test.ts +++ b/packages/@n8n/ai-utilities/src/model-discovery/__tests__/model-discovery.test.ts @@ -1,3 +1,5 @@ +import { UserError } from 'n8n-workflow'; + import { isOpenAiCustomEndpoint, listModelsForProvider, MODEL_DISCOVERY_PROVIDERS } from '../index'; function mockFetch(body: unknown, ok = true, status = 200) { @@ -207,11 +209,64 @@ describe('model-discovery', () => { }); describe('error handling', () => { - it('throws a descriptive error on a non-2xx response', async () => { - const fetch = mockFetch({ error: { message: 'invalid x-api-key' } }, false, 401); + it.each([401, 403])( + 'throws a non-reportable user error on an authentication response with status %s', + async (status) => { + const fetch = mockFetch( + { error: { message: 'provider response must not be exposed' } }, + false, + status, + ); - await expect(listModelsForProvider('anthropic', { apiKey: 'bad', fetch })).rejects.toThrow( - /anthropic.*401/i, + const error = await listModelsForProvider('anthropic', { + apiKey: 'bad', + fetch, + }).catch((error: unknown) => error); + + expect(error).toBeInstanceOf(UserError); + expect(error).toMatchObject({ + message: + "Models couldn't be loaded. Check that the selected credential is valid and has the required permissions, then try again.", + shouldReport: false, + }); + }, + ); + + it('keeps server errors reportable', async () => { + const fetch = mockFetch({ error: { message: 'provider unavailable' } }, false, 500); + + const error = await listModelsForProvider('anthropic', { + apiKey: 'key', + fetch, + }).catch((error: unknown) => error); + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(UserError); + expect(error).toMatchObject({ + message: + 'Failed to list anthropic models (status 500): {"error":{"message":"provider unavailable"}}', + }); + }); + + it('propagates network errors unchanged', async () => { + const networkError = new Error('Network unavailable'); + const fetch = vi.fn().mockRejectedValue(networkError) as unknown as typeof globalThis.fetch; + + await expect(listModelsForProvider('anthropic', { apiKey: 'key', fetch })).rejects.toBe( + networkError, + ); + }); + + it('propagates malformed response errors unchanged', async () => { + const parseError = new SyntaxError('Unexpected token'); + const fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => await Promise.reject(parseError), + }) as unknown as typeof globalThis.fetch; + + await expect(listModelsForProvider('anthropic', { apiKey: 'key', fetch })).rejects.toBe( + parseError, ); }); diff --git a/packages/@n8n/ai-utilities/src/model-discovery/request.ts b/packages/@n8n/ai-utilities/src/model-discovery/request.ts index bf43ebb764a..2805b23ae12 100644 --- a/packages/@n8n/ai-utilities/src/model-discovery/request.ts +++ b/packages/@n8n/ai-utilities/src/model-discovery/request.ts @@ -1,6 +1,8 @@ +import { UserError } from 'n8n-workflow'; + import type { ListModelsFn, ListModelsOptions, ProviderModel } from './types'; -/** GET a provider endpoint and parse JSON, throwing a descriptive error on non-2xx. */ +/** GET a provider endpoint and parse JSON, treating rejected credentials as user errors. */ export async function getJson( url: string, headers: Record, @@ -13,6 +15,13 @@ export async function getJson( headers: { ...headers, ...options.headers }, }); if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw new UserError( + "Models couldn't be loaded. Check that the selected credential is valid and has the required permissions, then try again.", + { shouldReport: false }, + ); + } + const body = await response.text().catch(() => ''); throw new Error( `Failed to list ${provider} models (status ${response.status})${body ? `: ${body.slice(0, 500)}` : ''}`,