diff --git a/apps/server/src/integrations/ai/ai-error.util.spec.ts b/apps/server/src/integrations/ai/ai-error.util.spec.ts index 414701d4..5828e6bd 100644 --- a/apps/server/src/integrations/ai/ai-error.util.spec.ts +++ b/apps/server/src/integrations/ai/ai-error.util.spec.ts @@ -22,10 +22,58 @@ describe('describeProviderError', () => { expect(describeProviderError('boom')).toBe('boom'); }); - it('formats statusCode + message', () => { + it('formats statusCode + message (non-classified status)', () => { + // 500 is not in the well-known status map, so no label is prepended and the + // plain ": " path is exercised. expect( - describeProviderError({ statusCode: 401, message: 'Unauthorized' }), - ).toBe('401: Unauthorized'); + describeProviderError({ statusCode: 500, message: 'Server error' }), + ).toBe('500: Server error'); + }); + + it('prepends an auth label for 401 (the real cause behind "User not found.")', () => { + const out = describeProviderError({ + statusCode: 401, + message: 'User not found.', + }); + expect(out).toBe( + 'AI provider authentication failed (invalid or missing API key) — 401: User not found.', + ); + // The provider status is still present after the label. + expect(out).toContain('401:'); + // With a response body, the snippet is appended AFTER the label/detail. + const withBody = describeProviderError({ + statusCode: 401, + message: 'User not found.', + responseBody: '{"error":{"message":"User not found.","code":401}}', + }); + expect( + withBody.startsWith( + 'AI provider authentication failed (invalid or missing API key) — 401: User not found. | response body: ', + ), + ).toBe(true); + expect(withBody).toContain('| response body:'); + }); + + it('prepends the same auth label for 403', () => { + expect( + describeProviderError({ statusCode: 403, message: 'Forbidden' }), + ).toBe( + 'AI provider authentication failed (invalid or missing API key) — 403: Forbidden', + ); + }); + + it('prepends a billing label for 402', () => { + expect( + describeProviderError({ statusCode: 402, message: 'Payment Required' }), + ).toBe( + 'AI provider rejected the request: insufficient credits or quota — 402: Payment Required', + ); + }); + + it('prepends a rate-limit label for 429', () => { + expect( + describeProviderError({ statusCode: 429, message: 'Too Many Requests' }), + ).toBe('AI provider rate limit exceeded — 429: Too Many Requests'); }); it('falls back to message when there is no statusCode', () => { diff --git a/apps/server/src/integrations/ai/ai-error.util.ts b/apps/server/src/integrations/ai/ai-error.util.ts index 0a0f949b..2d24ab47 100644 --- a/apps/server/src/integrations/ai/ai-error.util.ts +++ b/apps/server/src/integrations/ai/ai-error.util.ts @@ -10,6 +10,12 @@ * None of these fields contain the API key (it is sent as an Authorization * header and never echoed in the response body), so this is safe to log/return. * + * A small set of well-known HTTP statuses (auth / billing / rate limit) are + * classified and a clear, human-readable English label is prepended, so the + * log/UI states the real cause instead of only the provider's opaque message + * (e.g. a 401 "User not found." is really a bad/missing API key). The label is + * a static string and never contains the API key. + * * `fallback` is used when the error carries no usable message (e.g. a bare * object); defaults to 'Unknown error'. */ @@ -31,9 +37,29 @@ export function describeProviderError( ? `${e.statusCode}: ${e.message ?? ''}`.trim() : (e.message ?? fallback); const body = (e.responseBody ?? e.text ?? '').trim(); - if (!body) return base; // Collapse whitespace so a multi-line HTML body stays on one log line. const oneLine = body.replace(/\s+/g, ' '); const snippet = oneLine.length > 300 ? `${oneLine.slice(0, 300)}…` : oneLine; - return `${base} | response body: ${snippet}`; + const detail = body ? `${base} | response body: ${snippet}` : base; + // Classify well-known HTTP statuses so the log/UI states the real problem + // (auth / billing / rate limit) instead of only the provider's opaque message. + const label = classifyStatus(e.statusCode); + return label ? `${label} — ${detail}` : detail; +} + +// Map a small set of well-known provider HTTP statuses to a clear, +// human-readable cause. Returns null for anything else so the existing +// ": | response body: …" output is preserved unchanged. +function classifyStatus(statusCode?: number): string | null { + switch (statusCode) { + case 401: + case 403: + return 'AI provider authentication failed (invalid or missing API key)'; + case 402: + return 'AI provider rejected the request: insufficient credits or quota'; + case 429: + return 'AI provider rate limit exceeded'; + default: + return null; + } }