| import { |
| APIConnectionError, |
| APIContextOverflowError, |
| APIEmptyResponseError, |
| APIProviderQuotaExhaustedError, |
| APIProviderRateLimitError, |
| APIRequestTooLargeError, |
| APIStatusError, |
| APITimeoutError, |
| ChatProviderError, |
| isImageFormatError, |
| isProviderRateLimitError, |
| isRecoverableRequestStructureError, |
| isRetryableGenerateError, |
| isToolExchangeAdjacencyError, |
| normalizeAPIStatusError, |
| } from '#/errors'; |
| import { describe, expect, it } from 'vitest'; |
|
|
| describe('ChatProviderError', () => { |
| it('is an instance of Error', () => { |
| const err = new ChatProviderError('base error'); |
| expect(err).toBeInstanceOf(Error); |
| expect(err).toBeInstanceOf(ChatProviderError); |
| expect(err.message).toBe('base error'); |
| expect(err.name).toBe('ChatProviderError'); |
| }); |
| }); |
|
|
| describe('APIConnectionError', () => { |
| it('extends ChatProviderError', () => { |
| const err = new APIConnectionError('connection refused'); |
| expect(err).toBeInstanceOf(ChatProviderError); |
| expect(err).toBeInstanceOf(Error); |
| expect(err.name).toBe('APIConnectionError'); |
| expect(err.message).toBe('connection refused'); |
| }); |
| }); |
|
|
| describe('APITimeoutError', () => { |
| it('extends ChatProviderError', () => { |
| const err = new APITimeoutError('request timed out after 30s'); |
| expect(err).toBeInstanceOf(ChatProviderError); |
| expect(err).toBeInstanceOf(Error); |
| expect(err.name).toBe('APITimeoutError'); |
| expect(err.message).toBe('request timed out after 30s'); |
| }); |
| }); |
|
|
| describe('APIStatusError', () => { |
| it('extends ChatProviderError and stores status code', () => { |
| const err = new APIStatusError(429, 'rate limited', 'req-abc'); |
| expect(err).toBeInstanceOf(ChatProviderError); |
| expect(err).toBeInstanceOf(Error); |
| expect(err.name).toBe('APIStatusError'); |
| expect(err.message).toBe('rate limited'); |
| expect(err.statusCode).toBe(429); |
| expect(err.requestId).toBe('req-abc'); |
| }); |
|
|
| it('accepts null requestId', () => { |
| const err = new APIStatusError(500, 'server error', null); |
| expect(err.statusCode).toBe(500); |
| expect(err.requestId).toBeNull(); |
| }); |
|
|
| it('defaults requestId to null when omitted', () => { |
| const err = new APIStatusError(502, 'bad gateway'); |
| expect(err.statusCode).toBe(502); |
| expect(err.requestId).toBeNull(); |
| }); |
| }); |
|
|
| describe('APIEmptyResponseError', () => { |
| it('extends ChatProviderError', () => { |
| const err = new APIEmptyResponseError('empty response'); |
| expect(err).toBeInstanceOf(ChatProviderError); |
| expect(err).toBeInstanceOf(Error); |
| expect(err.name).toBe('APIEmptyResponseError'); |
| expect(err.message).toBe('empty response'); |
| expect(err.finishReason).toBeNull(); |
| expect(err.rawFinishReason).toBeNull(); |
| }); |
|
|
| it('preserves provider finish reason details', () => { |
| const err = new APIEmptyResponseError('empty response', { |
| finishReason: 'filtered', |
| rawFinishReason: 'content_filter', |
| }); |
|
|
| expect(err.finishReason).toBe('filtered'); |
| expect(err.rawFinishReason).toBe('content_filter'); |
| }); |
| }); |
|
|
| describe('APIContextOverflowError', () => { |
| it('extends APIStatusError and preserves HTTP details', () => { |
| const err = new APIContextOverflowError(400, 'Context length exceeded', 'req-context'); |
| expect(err).toBeInstanceOf(APIStatusError); |
| expect(err).toBeInstanceOf(ChatProviderError); |
| expect(err.name).toBe('APIContextOverflowError'); |
| expect(err.statusCode).toBe(400); |
| expect(err.requestId).toBe('req-context'); |
| }); |
| }); |
|
|
| describe('APIProviderRateLimitError', () => { |
| it('extends APIStatusError and preserves HTTP details', () => { |
| const err = new APIProviderRateLimitError('Rate limited', 'req-rate'); |
| expect(err).toBeInstanceOf(APIStatusError); |
| expect(err).toBeInstanceOf(ChatProviderError); |
| expect(err.name).toBe('APIProviderRateLimitError'); |
| expect(err.statusCode).toBe(429); |
| expect(err.requestId).toBe('req-rate'); |
| }); |
| }); |
|
|
| describe('APIRequestTooLargeError', () => { |
| it('extends APIStatusError and preserves HTTP details', () => { |
| const err = new APIRequestTooLargeError(413, 'Request exceeds the maximum size.', 'req-large'); |
| expect(err).toBeInstanceOf(APIStatusError); |
| expect(err).toBeInstanceOf(ChatProviderError); |
| expect(err.name).toBe('APIRequestTooLargeError'); |
| expect(err.statusCode).toBe(413); |
| expect(err.requestId).toBe('req-large'); |
| }); |
|
|
| it('is not retryable', () => { |
| expect( |
| isRetryableGenerateError(new APIRequestTooLargeError(413, 'Request exceeds the maximum size.')), |
| ).toBe(false); |
| }); |
| }); |
|
|
| describe('isRetryableGenerateError', () => { |
| it('matches transient provider errors and empty generate responses', () => { |
| expect(isRetryableGenerateError(new APIConnectionError('conn'))).toBe(true); |
| expect(isRetryableGenerateError(new APITimeoutError('timeout'))).toBe(true); |
| expect(isRetryableGenerateError(new APIEmptyResponseError('empty'))).toBe(true); |
| }); |
|
|
| it('does not retry empty responses blocked by the provider content filter', () => { |
| expect( |
| isRetryableGenerateError(new APIEmptyResponseError('filtered', { finishReason: 'filtered' })), |
| ).toBe(false); |
| expect( |
| isRetryableGenerateError(new APIEmptyResponseError('empty', { finishReason: 'completed' })), |
| ).toBe(true); |
| expect( |
| isRetryableGenerateError(new APIEmptyResponseError('empty', { finishReason: null })), |
| ).toBe(true); |
| }); |
|
|
| it.each([408, 409, 429, 500, 502, 503, 504, 529])('treats HTTP %i as retryable', (statusCode) => { |
| expect(isRetryableGenerateError(new APIStatusError(statusCode, 'retryable'))).toBe(true); |
| }); |
|
|
| it.each([400, 401, 403, 404, 422])('treats HTTP %i as non-retryable', (statusCode) => { |
| expect(isRetryableGenerateError(new APIStatusError(statusCode, 'non-retryable'))).toBe(false); |
| }); |
|
|
| it('propagates retryAfterMs through normalizeAPIStatusError onto the typed error', () => { |
| const rateLimited = normalizeAPIStatusError(429, 'rate limited', 'req-1', 12_500); |
| expect(rateLimited).toBeInstanceOf(APIProviderRateLimitError); |
| expect(rateLimited.retryAfterMs).toBe(12_500); |
|
|
| const generic = normalizeAPIStatusError(503, 'bad gateway', null, 3_000); |
| expect(generic).toBeInstanceOf(APIStatusError); |
| expect(generic.retryAfterMs).toBe(3_000); |
| }); |
|
|
| it('defaults retryAfterMs to null when no retry-after header is present', () => { |
| expect(new APIStatusError(429, 'x').retryAfterMs).toBeNull(); |
| expect(normalizeAPIStatusError(429, 'x').retryAfterMs).toBeNull(); |
| }); |
|
|
| it('does not retry context overflow or unknown errors', () => { |
| expect( |
| isRetryableGenerateError(new APIContextOverflowError(400, 'Context length exceeded')), |
| ).toBe(false); |
| expect(isRetryableGenerateError(new Error('boom'))).toBe(false); |
| expect(isRetryableGenerateError('boom')).toBe(false); |
| }); |
|
|
| it('retries an unclassified base ChatProviderError as a transient fallback', () => { |
| |
| |
| |
| |
| |
| expect(isRetryableGenerateError(new ChatProviderError('unclassified upstream failure'))).toBe( |
| true, |
| ); |
| }); |
| }); |
|
|
| describe('error hierarchy instanceof checks', () => { |
| it('all error types are instanceof ChatProviderError', () => { |
| const errors = [ |
| new APIConnectionError('conn'), |
| new APITimeoutError('timeout'), |
| new APIStatusError(400, 'status', null), |
| new APIContextOverflowError(400, 'context length exceeded'), |
| new APIEmptyResponseError('empty'), |
| ]; |
|
|
| for (const err of errors) { |
| expect(err).toBeInstanceOf(ChatProviderError); |
| } |
| }); |
|
|
| it('specific types are distinguishable', () => { |
| const connErr = new APIConnectionError('conn'); |
| const statusErr = new APIStatusError(400, 'status', null); |
|
|
| expect(connErr).not.toBeInstanceOf(APIStatusError); |
| expect(statusErr).not.toBeInstanceOf(APIConnectionError); |
| }); |
|
|
| it('can catch with ChatProviderError and inspect subtype', () => { |
| const err: ChatProviderError = new APIStatusError(404, 'not found', 'req-123'); |
|
|
| if (err instanceof APIStatusError) { |
| expect(err.statusCode).toBe(404); |
| expect(err.requestId).toBe('req-123'); |
| } else { |
| expect.unreachable('Expected APIStatusError'); |
| } |
| }); |
| }); |
|
|
| describe('normalizeAPIStatusError', () => { |
| it('normalizes HTTP 429 to APIProviderRateLimitError', () => { |
| const error = normalizeAPIStatusError(429, 'Too many requests', 'req-rate'); |
| expect(error).toBeInstanceOf(APIProviderRateLimitError); |
| expect(error.statusCode).toBe(429); |
| expect(error.requestId).toBe('req-rate'); |
| }); |
|
|
| it.each([ |
| [400, 'Context length exceeded'], |
| [400, 'Exceeded max tokens'], |
| [413, 'Context length exceeded'], |
| [422, 'Maximum context window exceeded'], |
| [400, 'context_length_exceeded'], |
| [422, 'Too many tokens in prompt'], |
| [400, 'prompt is too long: 210000 tokens exceeds the maximum'], |
| [400, 'input token count 131072 exceeds the maximum number of tokens allowed'], |
| [400, 'Invalid request: Your request exceeded model token limit: 262144 (requested: 274613)'], |
| ])('normalizes %i "%s" to APIContextOverflowError', (statusCode, message) => { |
| const error = normalizeAPIStatusError(statusCode, message, 'req-context'); |
| expect(error).toBeInstanceOf(APIContextOverflowError); |
| expect(error.statusCode).toBe(statusCode); |
| expect(error.requestId).toBe('req-context'); |
| }); |
|
|
| it.each([ |
| [401, 'Context length exceeded'], |
| [500, 'Context length exceeded'], |
| [400, 'Bad request'], |
| [422, 'Invalid tool schema'], |
| [400, 'max_tokens must be less than or equal to 4096'], |
| [422, 'max_output_tokens must not exceed 8192'], |
| [400, 'max tokens must not exceed the configured output limit'], |
| ])('keeps %i "%s" as APIStatusError', (statusCode, message) => { |
| const error = normalizeAPIStatusError(statusCode, message); |
| expect(error).toBeInstanceOf(APIStatusError); |
| expect(error).not.toBeInstanceOf(APIContextOverflowError); |
| }); |
|
|
| it.each([ |
| |
| |
| [413, 'Request exceeds the maximum size'], |
| |
| [413, '413 <html><head><title>413 Request Entity Too Large</title></head></html>'], |
| |
| [413, 'request_too_large: Request exceeds the maximum allowed number of bytes'], |
| |
| [413, 'Payload Too Large'], |
| [413, 'Content Too Large'], |
| |
| |
| [413, 'Request too large'], |
| [413, 'Request body too large'], |
| [413, 'http: request body too large'], |
| ])('normalizes %i "%s" to APIRequestTooLargeError', (statusCode, message) => { |
| const error = normalizeAPIStatusError(statusCode, message, 'req-large'); |
| expect(error).toBeInstanceOf(APIRequestTooLargeError); |
| expect(error.statusCode).toBe(statusCode); |
| expect(error.requestId).toBe('req-large'); |
| }); |
|
|
| it('keeps a 413 with token-overflow wording as APIContextOverflowError', () => { |
| |
| |
| const error = normalizeAPIStatusError(413, 'prompt is too long: 210000 tokens > 200000 maximum'); |
| expect(error).toBeInstanceOf(APIContextOverflowError); |
| expect(error).not.toBeInstanceOf(APIRequestTooLargeError); |
| }); |
|
|
| it.each([ |
| |
| |
| |
| [413, 'Request failed'], |
| |
| [400, 'Payload too large'], |
| [422, 'Request entity too large'], |
| ])('keeps %i "%s" as plain APIStatusError', (statusCode, message) => { |
| const error = normalizeAPIStatusError(statusCode, message); |
| expect(error).toBeInstanceOf(APIStatusError); |
| expect(error).not.toBeInstanceOf(APIRequestTooLargeError); |
| expect(error).not.toBeInstanceOf(APIContextOverflowError); |
| }); |
| }); |
|
|
| describe('isToolExchangeAdjacencyError', () => { |
| |
| |
| const ANTHROPIC_MISSING_RESULT = |
| 'messages.142: `tool_use` ids were found without `tool_result` blocks immediately after: ' + |
| 'toolu_01MWFhDRqdbB4nzCJNuWYiun. Each `tool_use` block must have a corresponding ' + |
| '`tool_result` block in the next message.'; |
|
|
| it('matches the missing-tool_result 400', () => { |
| expect(isToolExchangeAdjacencyError(new APIStatusError(400, ANTHROPIC_MISSING_RESULT))).toBe( |
| true, |
| ); |
| }); |
|
|
| it('matches the reverse unexpected-tool_result 400', () => { |
| expect( |
| isToolExchangeAdjacencyError( |
| new APIStatusError( |
| 400, |
| 'messages.5: `tool_result` block(s) provided when previous message does not ' + |
| 'contain any `tool_use` blocks', |
| ), |
| ), |
| ).toBe(true); |
| expect( |
| isToolExchangeAdjacencyError(new APIStatusError(400, 'unexpected `tool_result` block')), |
| ).toBe(true); |
| }); |
|
|
| it('also matches a 422 with the same shape', () => { |
| expect(isToolExchangeAdjacencyError(new APIStatusError(422, ANTHROPIC_MISSING_RESULT))).toBe( |
| true, |
| ); |
| }); |
|
|
| |
| |
| |
| |
| const MOONSHOT_TOOL_CALL_ID_NOT_FOUND = '400 tool_call_id is not found'; |
|
|
| it('matches the OpenAI/Moonshot tool_call_id-not-found 400', () => { |
| expect( |
| isToolExchangeAdjacencyError(new APIStatusError(400, MOONSHOT_TOOL_CALL_ID_NOT_FOUND)), |
| ).toBe(true); |
| expect( |
| isToolExchangeAdjacencyError(new APIStatusError(400, "tool_call_id 'call_abc123' is not found")), |
| ).toBe(true); |
| }); |
|
|
| it('also matches a 422 tool_call_id-not-found', () => { |
| expect( |
| isToolExchangeAdjacencyError(new APIStatusError(422, MOONSHOT_TOOL_CALL_ID_NOT_FOUND)), |
| ).toBe(true); |
| }); |
|
|
| |
| |
| |
| |
| it('matches the OpenAI/DeepSeek role-tool-without-tool_calls 400', () => { |
| expect( |
| isToolExchangeAdjacencyError( |
| new APIStatusError( |
| 400, |
| "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'", |
| ), |
| ), |
| ).toBe(true); |
| expect( |
| isToolExchangeAdjacencyError( |
| new APIStatusError( |
| 400, |
| 'Role `tool` must be a response to a preceding message with `tool_calls`', |
| ), |
| ), |
| ).toBe(true); |
| }); |
|
|
| |
| |
| |
| |
| it('matches the assistant-tool_calls-without-response 400', () => { |
| expect( |
| isToolExchangeAdjacencyError( |
| new APIStatusError( |
| 400, |
| "An assistant message with 'tool_calls' must be followed by tool messages responding to each " + |
| "'tool_call_id'. The following tool_call_ids did not have response messages: call_hSmZB4G8", |
| ), |
| ), |
| ).toBe(true); |
| expect( |
| isToolExchangeAdjacencyError( |
| new APIStatusError( |
| 400, |
| 'An assistant message with "tool_calls" must be followed by tool messages responding to each ' + |
| '"tool_call_id". The following tool_call_ids did not have response messages: message[322].role', |
| ), |
| ), |
| ).toBe(true); |
| expect( |
| isToolExchangeAdjacencyError( |
| new APIStatusError(400, '(insufficient tool messages following tool_calls message)'), |
| ), |
| ).toBe(true); |
| }); |
|
|
| it('does not match a context-overflow 400 or unrelated errors', () => { |
| expect( |
| isToolExchangeAdjacencyError(new APIContextOverflowError(400, 'context length exceeded')), |
| ).toBe(false); |
| expect(isToolExchangeAdjacencyError(new APIStatusError(400, 'Bad request'))).toBe(false); |
| |
| |
| expect(isToolExchangeAdjacencyError(new APIStatusError(400, 'resource not found'))).toBe(false); |
| |
| |
| expect( |
| isToolExchangeAdjacencyError( |
| new APIStatusError(400, '400 Not supported model mimo-v2.5-pro-ultraspeed'), |
| ), |
| ).toBe(false); |
| expect(isToolExchangeAdjacencyError(new APIStatusError(500, ANTHROPIC_MISSING_RESULT))).toBe( |
| false, |
| ); |
| expect(isToolExchangeAdjacencyError(new Error(ANTHROPIC_MISSING_RESULT))).toBe(false); |
| expect(isToolExchangeAdjacencyError('boom')).toBe(false); |
| }); |
| }); |
|
|
| describe('isRecoverableRequestStructureError', () => { |
| it('matches the whole tool_use/tool_result adjacency family', () => { |
| expect( |
| isRecoverableRequestStructureError( |
| new APIStatusError(400, '`tool_use` ids were found without `tool_result` blocks'), |
| ), |
| ).toBe(true); |
| }); |
|
|
| it('matches the OpenAI/Moonshot tool_call_id-not-found 400', () => { |
| expect( |
| isRecoverableRequestStructureError(new APIStatusError(400, '400 tool_call_id is not found')), |
| ).toBe(true); |
| }); |
|
|
| it('matches the OpenAI-compatible role-tool / assistant-tool_calls pairing 400s', () => { |
| expect( |
| isRecoverableRequestStructureError( |
| new APIStatusError( |
| 400, |
| "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'", |
| ), |
| ), |
| ).toBe(true); |
| expect( |
| isRecoverableRequestStructureError( |
| new APIStatusError( |
| 400, |
| "An assistant message with 'tool_calls' must be followed by tool messages responding to each " + |
| "'tool_call_id'. The following tool_call_ids did not have response messages: call_hSmZB4G8", |
| ), |
| ), |
| ).toBe(true); |
| }); |
|
|
| it('matches the Anthropic duplicate tool_use id rejection', () => { |
| expect( |
| isRecoverableRequestStructureError( |
| new APIStatusError(400, 'messages: `tool_use` ids must be unique'), |
| ), |
| ).toBe(true); |
| }); |
|
|
| it('matches empty / whitespace-only text content rejections', () => { |
| expect( |
| isRecoverableRequestStructureError( |
| new APIStatusError(400, 'messages: text content blocks must be non-empty'), |
| ), |
| ).toBe(true); |
| expect( |
| isRecoverableRequestStructureError( |
| new APIStatusError(400, 'text content blocks must contain non-whitespace text'), |
| ), |
| ).toBe(true); |
| }); |
|
|
| it('matches first-message-must-be-user and role-alternation rejections', () => { |
| expect( |
| isRecoverableRequestStructureError( |
| new APIStatusError(400, 'messages: first message must use the "user" role'), |
| ), |
| ).toBe(true); |
| expect( |
| isRecoverableRequestStructureError( |
| new APIStatusError( |
| 400, |
| 'messages: roles must alternate between "user" and "assistant", but found multiple "user" roles in a row', |
| ), |
| ), |
| ).toBe(true); |
| }); |
|
|
| it('matches the Moonshot/Kimi vacuous-message rejection', () => { |
| expect( |
| isRecoverableRequestStructureError( |
| new APIStatusError( |
| 400, |
| "400 the message at position 105 with role 'assistant' must not be empty", |
| ), |
| ), |
| ).toBe(true); |
| }); |
|
|
| it('does not match context overflow, auth, or non-status errors', () => { |
| expect( |
| isRecoverableRequestStructureError(new APIContextOverflowError(400, 'context length exceeded')), |
| ).toBe(false); |
| expect(isRecoverableRequestStructureError(new APIStatusError(401, 'unauthorized'))).toBe(false); |
| expect(isRecoverableRequestStructureError(new APIStatusError(400, 'Bad request'))).toBe(false); |
| expect(isRecoverableRequestStructureError(new Error('roles must alternate'))).toBe(false); |
| }); |
| }); |
|
|
| describe('isProviderRateLimitError', () => { |
| it('matches explicit HTTP 429 status errors', () => { |
| expect(isProviderRateLimitError(new APIProviderRateLimitError('rate limited'))).toBe(true); |
| expect(isProviderRateLimitError(new APIStatusError(429, 'rate limited'))).toBe(true); |
| expect(isProviderRateLimitError({ response: { status: 429 } })).toBe(true); |
| expect(isProviderRateLimitError({ statusCode: 503, message: 'rate limit' })).toBe(false); |
| }); |
|
|
| it('matches wrapped provider rate-limit messages without status metadata', () => { |
| expect( |
| isProviderRateLimitError( |
| new Error( |
| 'APIStatusError: 429 request id: req-429, request reached user+model max RPM: 50', |
| ), |
| ), |
| ).toBe(true); |
| expect( |
| isProviderRateLimitError( |
| "[provider.api_error] We're receiving too many requests at the moment. Please wait.", |
| ), |
| ).toBe(true); |
| expect(isProviderRateLimitError(new Error('[provider.rate_limit] slow down'))).toBe(true); |
| }); |
|
|
| it('does not match non-rate-limit provider errors', () => { |
| expect(isProviderRateLimitError(new APIStatusError(401, 'unauthorized'))).toBe(false); |
| expect(isProviderRateLimitError('APIStatusError: 401 unauthorized')).toBe(false); |
| expect(isProviderRateLimitError(new Error('context length exceeded'))).toBe(false); |
| }); |
| }); |
|
|
| describe('isImageFormatError', () => { |
| it('matches documented provider image format/data rejections', () => { |
| |
| expect( |
| isImageFormatError( |
| new APIStatusError(400, 'The image data you provided does not represent a valid image'), |
| ), |
| ).toBe(true); |
| |
| expect( |
| isImageFormatError( |
| new APIStatusError( |
| 400, |
| "messages.0.content.1.image.source.base64.media_type: Input should be 'image/jpeg'", |
| ), |
| ), |
| ).toBe(true); |
| |
| expect(isImageFormatError(new APIStatusError(400, 'Could not process image'))).toBe(true); |
| |
| expect( |
| isImageFormatError( |
| new APIStatusError(400, 'Invalid request: unsupported image url: /tmp/photo.avif'), |
| ), |
| ).toBe(true); |
| expect(isImageFormatError(new APIStatusError(400, 'unsupported image format'))).toBe(true); |
| |
| expect(isImageFormatError(new APIStatusError(400, 'Unable to process input image'))).toBe(true); |
| expect( |
| isImageFormatError( |
| new APIStatusError(400, 'The mime_type must accurately match the actual image format'), |
| ), |
| ).toBe(true); |
| }); |
|
|
| it('matches kosong client-side image whitelist throws', () => { |
| expect( |
| isImageFormatError(new ChatProviderError('Unsupported media type for base64 image: image/avif')), |
| ).toBe(true); |
| expect( |
| isImageFormatError( |
| new ChatProviderError('Invalid data URL for image: data:image/avif;BASE64,AAA'), |
| ), |
| ).toBe(true); |
| }); |
|
|
| it('does not match a non-image 400, an unrelated status, or overflow/413 subclasses', () => { |
| expect(isImageFormatError(new APIStatusError(400, 'max_tokens must be positive'))).toBe(false); |
| expect(isImageFormatError(new APIStatusError(422, 'image is bad'))).toBe(false); |
| expect(isImageFormatError(new APIStatusError(401, 'invalid api key'))).toBe(false); |
| expect( |
| isImageFormatError(new APIContextOverflowError(400, 'context length exceeded for image model')), |
| ).toBe(false); |
| expect( |
| isImageFormatError(new APIRequestTooLargeError(413, 'image request too large')), |
| ).toBe(false); |
| expect(isImageFormatError(new ChatProviderError('connection reset'))).toBe(false); |
| expect(isImageFormatError(new Error('image is bad'))).toBe(false); |
| }); |
|
|
| it('does not match image count/size/support errors that stripping media cannot fix', () => { |
| |
| |
| |
| expect(isImageFormatError(new APIStatusError(400, 'too many images in request'))).toBe(false); |
| expect( |
| isImageFormatError(new APIStatusError(400, 'image dimension 5000 exceeds maximum 2048')), |
| ).toBe(false); |
| expect( |
| isImageFormatError(new APIStatusError(400, 'image input is disabled for this model')), |
| ).toBe(false); |
| expect(isImageFormatError(new APIStatusError(400, 'image_url is not allowed'))).toBe(false); |
| |
| |
| |
| expect( |
| isImageFormatError( |
| new APIStatusError( |
| 400, |
| 'messages.44.content.1.image.source.base64: image exceeds 5 MB maximum: 11641928 bytes > 5242880 bytes', |
| ), |
| ), |
| ).toBe(false); |
| expect(isImageFormatError(new APIStatusError(400, 'Image Input Not Supported'))).toBe(false); |
| expect( |
| isImageFormatError(new APIStatusError(400, "`inlineData` isn't supported by this model.")), |
| ).toBe(false); |
| |
| |
| |
| expect( |
| isImageFormatError( |
| new APIStatusError( |
| 400, |
| "messages.0.content.1.video.source.base64.media_type: Input should be 'video/mp4'", |
| ), |
| ), |
| ).toBe(false); |
| |
| expect( |
| isImageFormatError(new APIStatusError(400, 'unsupported media type for audio input')), |
| ).toBe(false); |
| expect(isImageFormatError(new APIStatusError(400, 'invalid media type'))).toBe(false); |
| }); |
|
|
| it('is excluded from the transient-retry fallback so dedicated recovery fires first', () => { |
| |
| |
| |
| expect(isRetryableGenerateError(new ChatProviderError('transient blip'))).toBe(true); |
| expect( |
| isRetryableGenerateError( |
| new ChatProviderError('Unsupported media type for base64 image: image/avif'), |
| ), |
| ).toBe(false); |
| expect( |
| isRetryableGenerateError(new APIStatusError(400, 'unsupported image format')), |
| ).toBe(false); |
| }); |
| }); |
|
|
| describe('APIProviderQuotaExhaustedError', () => { |
| it('extends APIStatusError and preserves HTTP details', () => { |
| const err = new APIProviderQuotaExhaustedError('quota exhausted', 'req-quota', 12_500); |
| expect(err).toBeInstanceOf(APIStatusError); |
| expect(err).toBeInstanceOf(ChatProviderError); |
| expect(err).not.toBeInstanceOf(APIProviderRateLimitError); |
| expect(err.name).toBe('APIProviderQuotaExhaustedError'); |
| expect(err.statusCode).toBe(429); |
| expect(err.requestId).toBe('req-quota'); |
| expect(err.retryAfterMs).toBe(12_500); |
| }); |
| }); |
|
|
| describe('normalizeAPIStatusError: 429 stays vendor-neutral', () => { |
| |
| |
| |
| |
| it.each([ |
| 'Too many requests', |
| 'request reached user+model max RPM: 50', |
| 'your token quota per minute was exceeded', |
| 'Your account org-0123456789abcdef <ak-test> is suspended due to insufficient balance, please recharge your account or check your plan and billing details', |
| ])('normalizes 429 "%s" to APIProviderRateLimitError', (message) => { |
| const error = normalizeAPIStatusError(429, message); |
| expect(error).toBeInstanceOf(APIProviderRateLimitError); |
| expect(error).not.toBeInstanceOf(APIProviderQuotaExhaustedError); |
| }); |
|
|
| it('keeps billing wording on other statuses a generic status error', () => { |
| const error = normalizeAPIStatusError(403, 'insufficient balance'); |
| expect(error).not.toBeInstanceOf(APIProviderQuotaExhaustedError); |
| expect(error.constructor).toBe(APIStatusError); |
| }); |
| }); |
|
|
| describe('quota-exhausted retry and rate-limit semantics', () => { |
| it('is not retryable while a plain rate limit stays retryable', () => { |
| expect(isRetryableGenerateError(new APIProviderQuotaExhaustedError('quota exhausted'))).toBe( |
| false, |
| ); |
| expect(isRetryableGenerateError(new APIProviderRateLimitError('rate limited'))).toBe(true); |
| expect(isRetryableGenerateError(new APIStatusError(429, 'rate limited'))).toBe(true); |
| }); |
|
|
| it('is not a provider rate limit despite carrying status 429', () => { |
| expect(isProviderRateLimitError(new APIProviderQuotaExhaustedError('quota exhausted'))).toBe( |
| false, |
| ); |
| expect(isProviderRateLimitError(new APIProviderRateLimitError('rate limited'))).toBe(true); |
| }); |
| }); |
|
|