🐛 fix: preserve model fetch error messages

This commit is contained in:
yutengjing
2026-06-12 17:42:59 +08:00
parent 180547ef42
commit f0048e8524
5 changed files with 176 additions and 6 deletions
@@ -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 () => {
@@ -140,6 +140,42 @@ const normalizeModelFetchError = (error: unknown): Record<string, unknown> => {
return { message: safeError === undefined ? 'Unknown error' : String(safeError) };
};
const extractModelFetchErrorMessage = (
error: unknown,
seen = new WeakSet<object>(),
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<string, unknown>;
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<ChatCompletionErrorPayload>) : 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);
@@ -169,10 +169,14 @@ const ModelFetchErrorAlert = memo<ModelFetchErrorAlertProps>(({ 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 (
<Alert
showIcon
title={getRuntimeErrorMessage(t, error.type, { provider: providerName })}
description={description}
title={title}
type={'error'}
extra={
<Flexbox paddingBlock={8} paddingInline={16}>
+56
View File
@@ -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,
});
});
+51 -4
View File
@@ -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<object>(),
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,
};
};