From f0048e85242e41b0b2875acae0f759018efca4af Mon Sep 17 00:00:00 2001 From: yutengjing Date: Fri, 12 Jun 2026 17:42:59 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20preserve=20model=20fetch?= =?UTF-8?q?=20error=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../webapi/models/[provider]/route.test.ts | 27 +++++++++ .../webapi/models/[provider]/route.ts | 38 ++++++++++++- .../provider/features/ModelList/index.tsx | 6 +- src/services/__tests__/models.test.ts | 56 +++++++++++++++++++ src/services/models.ts | 55 ++++++++++++++++-- 5 files changed, 176 insertions(+), 6 deletions(-) diff --git a/src/app/(backend)/webapi/models/[provider]/route.test.ts b/src/app/(backend)/webapi/models/[provider]/route.test.ts index 11bdc45b56..1b2d734513 100644 --- a/src/app/(backend)/webapi/models/[provider]/route.test.ts +++ b/src/app/(backend)/webapi/models/[provider]/route.test.ts @@ -119,6 +119,33 @@ describe('GET handler', () => { expect(responseBody.body.error.code).toBe('PROVIDER_ERROR'); expect(responseBody.body.error.details).toBe('API limit exceeded'); + expect(responseBody.body.message).toBe('API limit exceeded'); + }); + + it('should prefer nested provider messages over wrapper messages', async () => { + const mockParams = Promise.resolve({ provider: 'cloudflare' }); + + const structuredError = { + errorType: AgentRuntimeErrorType.ProviderBizError, + message: 'Provider request failed', + error: { + errors: [{ message: 'Cloudflare authentication error' }], + message: 'Request failed', + result: null, + }, + }; + + const mockRuntime: LobeRuntimeAI = { + baseURL: 'abc', + chat: vi.fn(), + models: vi.fn().mockRejectedValue(structuredError), + }; + vi.mocked(initModelRuntimeFromDB).mockResolvedValue(new ModelRuntime(mockRuntime)); + + const response = await GET(request, { params: mockParams }); + const responseBody = await response.json(); + + expect(responseBody.body.message).toBe('Cloudflare authentication error'); }); it('should return provider biz error for unstructured errors', async () => { diff --git a/src/app/(backend)/webapi/models/[provider]/route.ts b/src/app/(backend)/webapi/models/[provider]/route.ts index 52a753d13e..7bbf5c823c 100644 --- a/src/app/(backend)/webapi/models/[provider]/route.ts +++ b/src/app/(backend)/webapi/models/[provider]/route.ts @@ -140,6 +140,42 @@ const normalizeModelFetchError = (error: unknown): Record => { return { message: safeError === undefined ? 'Unknown error' : String(safeError) }; }; +const extractModelFetchErrorMessage = ( + error: unknown, + seen = new WeakSet(), + depth = 0, +): string | undefined => { + if (error === null || error === undefined) return; + if (typeof error === 'string') return error || undefined; + if (typeof error === 'number' || typeof error === 'boolean' || typeof error === 'bigint') { + return String(error); + } + if (!isRecord(error)) return; + if (seen.has(error) || depth >= MAX_ERROR_DEPTH) return; + + seen.add(error); + + const record = error as Record; + const nestedErrorKeys = ['error', 'body', 'cause', 'response', 'detail', 'details', 'reason']; + + for (const key of nestedErrorKeys) { + const message = extractModelFetchErrorMessage(record[key], seen, depth + 1); + if (message) return message; + } + + if (Array.isArray(record.errors)) { + for (const item of record.errors) { + const message = extractModelFetchErrorMessage(item, seen, depth + 1); + if (message) return message; + } + } + + if (error instanceof Error && error.message) return error.message; + if (typeof record.message === 'string' && record.message) return record.message; + if (typeof record.status === 'number') return `HTTP ${record.status}`; + if (typeof record.statusCode === 'number') return `HTTP ${record.statusCode}`; +}; + const normalizeModelListResponse = (list: unknown) => toJsonSafeValue(list); export const GET = checkAuth(async (req, { params, userId, serverDB }) => { @@ -158,9 +194,9 @@ export const GET = checkAuth(async (req, { params, userId, serverDB }) => { const errorPayload = isRecord(e) ? (e as Partial) : undefined; const errorType = errorPayload?.errorType || AgentRuntimeErrorType.ProviderBizError; const errorContent = errorPayload?.error; - const message = errorPayload?.message; const error = errorContent || e; + const message = extractModelFetchErrorMessage(error) || errorPayload?.message; // track the error at server side console.error(`Route: [${provider}] ${errorType}:`, error); diff --git a/src/routes/(main)/settings/provider/features/ModelList/index.tsx b/src/routes/(main)/settings/provider/features/ModelList/index.tsx index 31f11ecb89..de6c33cef1 100644 --- a/src/routes/(main)/settings/provider/features/ModelList/index.tsx +++ b/src/routes/(main)/settings/provider/features/ModelList/index.tsx @@ -169,10 +169,14 @@ const ModelFetchErrorAlert = memo(({ error, provider if (!error) return null; + const title = getRuntimeErrorMessage(t, error.type, { provider: providerName }); + const description = error.message && error.message !== title ? error.message : undefined; + return ( diff --git a/src/services/__tests__/models.test.ts b/src/services/__tests__/models.test.ts index 305373a6e8..7731b821c3 100644 --- a/src/services/__tests__/models.test.ts +++ b/src/services/__tests__/models.test.ts @@ -130,6 +130,62 @@ describe('ModelsService', () => { error: { message: 'Provider failed' }, provider: 'openai', }, + message: 'Provider failed', + type: AgentRuntimeErrorType.ProviderBizError, + }); + }); + + it('should extract nested provider message from parsed server errors', async () => { + (fetch as Mock).mockResolvedValueOnce( + new Response( + JSON.stringify({ + body: { + error: { + errors: [{ message: 'Cloudflare authentication error' }], + result: null, + }, + provider: 'cloudflare', + }, + errorType: AgentRuntimeErrorType.ProviderBizError, + }), + { status: 471 }, + ), + ); + + await expect(modelsService.getModels('cloudflare')).rejects.toMatchObject({ + body: { + error: { + errors: [{ message: 'Cloudflare authentication error' }], + result: null, + }, + provider: 'cloudflare', + }, + message: 'Cloudflare authentication error', + type: AgentRuntimeErrorType.ProviderBizError, + }); + }); + + it('should prefer nested provider messages over parsed wrapper messages', async () => { + (fetch as Mock).mockResolvedValueOnce( + new Response( + JSON.stringify({ + body: { + error: { + errors: [{ message: 'Cloudflare authentication error' }], + message: 'Request failed', + result: null, + }, + provider: 'cloudflare', + }, + errorType: AgentRuntimeErrorType.ProviderBizError, + message: 'Provider request failed', + }), + { status: 471 }, + ), + ); + + await expect(modelsService.getModels('cloudflare')).rejects.toMatchObject({ + message: 'Cloudflare authentication error', type: AgentRuntimeErrorType.ProviderBizError, }); }); diff --git a/src/services/models.ts b/src/services/models.ts index 9e8b23462a..3b4366ad4e 100644 --- a/src/services/models.ts +++ b/src/services/models.ts @@ -20,8 +20,50 @@ const isChatMessageError = (error: unknown): error is ChatMessageError => { return 'type' in error && 'message' in error; }; +const MAX_ERROR_DEPTH = 4; + +const extractModelFetchErrorMessage = ( + error: unknown, + seen = new WeakSet(), + depth = 0, +): string | undefined => { + if (error === null || error === undefined) return; + if (typeof error === 'string') return error || undefined; + if (typeof error === 'number' || typeof error === 'boolean' || typeof error === 'bigint') { + return String(error); + } + if (!isRecord(error) || depth >= MAX_ERROR_DEPTH) return; + if (seen.has(error)) return; + + seen.add(error); + + const nestedErrorKeys = ['error', 'body', 'cause', 'response', 'detail', 'details', 'reason']; + + for (const key of nestedErrorKeys) { + const message = extractModelFetchErrorMessage(error[key], seen, depth + 1); + if (message) return message; + } + + if (Array.isArray(error.errors)) { + for (const item of error.errors) { + const message = extractModelFetchErrorMessage(item, seen, depth + 1); + if (message) return message; + } + } + + if (error instanceof Error && error.message) return error.message; + if (typeof error.message === 'string' && error.message) return error.message; + if (typeof error.status === 'number') return `HTTP ${error.status}`; + if (typeof error.statusCode === 'number') return `HTTP ${error.statusCode}`; +}; + export const normalizeModelFetchError = (error: unknown, provider: string): ChatMessageError => { - if (isChatMessageError(error)) return error; + if (isChatMessageError(error)) { + return { + ...error, + message: extractModelFetchErrorMessage(error.body) || error.message, + }; + } if (isRecord(error) && 'errorType' in error) { const errorType = error.errorType as ChatMessageError['type']; @@ -29,22 +71,27 @@ export const normalizeModelFetchError = (error: unknown, provider: string): Chat return { body: { error: errorContent, provider }, - message: typeof error.message === 'string' ? error.message : String(errorType), + message: + extractModelFetchErrorMessage(errorContent) || + (typeof error.message === 'string' ? error.message : undefined) || + String(errorType), type: errorType, }; } if (error instanceof Error) { + const message = extractModelFetchErrorMessage(error) || error.message; + return { body: { error: { message: error.message, name: error.name }, provider }, - message: error.message, + message, type: AgentRuntimeErrorType.ProviderBizError, }; } return { body: { error, provider }, - message: String(error), + message: error === undefined ? 'Unknown error' : String(error), type: AgentRuntimeErrorType.ProviderBizError, }; };