| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| import assert from 'node:assert/strict'; |
| import { describe, test } from 'node:test'; |
| import { createJsonErrorResponseHandler } from '@ai-sdk/provider-utils'; |
| import { RetryError } from 'ai'; |
| import { z } from 'zod/v4'; |
| |
| import { |
| classifyError, |
| providerFailureDiagnostic, |
| providerModelFailure, |
| } from '../provider-error-classification.js'; |
| import type { ModelFailureKind } from '../model-protocol.js'; |
| |
| describe('Provider error classification', () => { |
| test('projects only bounded allowlisted facts into durable diagnostics', () => { |
| const diagnostic = providerFailureDiagnostic( |
| Object.assign(new Error('must not persist sk-secret-or-prompt'), { |
| name: 'AI_APICallError', |
| statusCode: 429, |
| responseHeaders: { |
| 'x-request-id': 'req-123', |
| authorization: 'Bearer secret', |
| }, |
| data: { |
| error: { |
| code: 'rate_limit_exceeded', |
| message: 'private provider payload', |
| }, |
| prompt: 'private customer text', |
| }, |
| requestBodyValues: { input: 'private request body' }, |
| }), |
| ); |
| |
| assert.deepEqual(diagnostic, { |
| errorClass: 'rate_limit', |
| httpStatus: 429, |
| providerCode: 'rate_limit_exceeded', |
| providerRequestId: 'req-123', |
| retryable: true, |
| }); |
| const serialized = JSON.stringify(diagnostic); |
| assert.doesNotMatch(serialized, /secret|private|authorization|request body/i); |
| }); |
| |
| test('structured usage-limit codes project to billing regardless of status', () => { |
| const quotaOn401 = Object.assign(new Error('request failed'), { |
| name: 'AI_APICallError', |
| statusCode: 401, |
| data: { error: { code: 'insufficient_quota' } }, |
| }); |
| assert.equal(classifyError(quotaOn401), 'provider_billing'); |
| |
| const balanceOn403 = Object.assign(new Error('request failed'), { |
| name: 'AI_APICallError', |
| statusCode: 403, |
| data: { error: { code: 'insufficient_balance' } }, |
| }); |
| assert.equal(classifyError(balanceOn403), 'provider_billing'); |
| |
| // Explicit provider evidence outranks the numeric HTTP fallback: an |
| // exhausted quota is a closed window, not a transient throttle to retry. |
| const quotaOn429 = Object.assign(new Error('request failed'), { |
| name: 'AI_APICallError', |
| statusCode: 429, |
| data: { error: { code: 'insufficient_quota' } }, |
| }); |
| assert.equal(classifyError(quotaOn429), 'provider_billing'); |
| assert.equal(providerModelFailure(quotaOn429).retryable, false); |
| }); |
| |
| test('plan-window wording on a credential-shaped status projects to billing', () => { |
| // Providers that gate subscription windows behind 401/403 for validly |
| // signed-in users (#2516): their own wording outranks the bare status, |
| // whether the SDK surfaces it as the error message or keeps it only in |
| // the raw response body after a schema-parse failure. |
| const planWindow = Object.assign(new Error('Your account plan usage limit has been reached.'), { |
| name: 'AI_APICallError', |
| statusCode: 401, |
| data: { error: { type: 'authentication_error' } }, |
| }); |
| assert.equal(classifyError(planWindow), 'provider_billing'); |
| assert.equal(providerModelFailure(planWindow).retryable, false); |
| |
| const exhaustedCredits = Object.assign(new Error('Request failed with status code 403'), { |
| name: 'AI_APICallError', |
| statusCode: 403, |
| responseBody: JSON.stringify({ |
| error: { message: 'Your credits have been exhausted for this billing period.' }, |
| }), |
| }); |
| assert.equal(classifyError(exhaustedCredits), 'provider_billing'); |
| }); |
| |
| test('genuine credential and permission failures stay auth on 401/403', () => { |
| const invalidKey = Object.assign(new Error('Invalid API key provided'), { |
| name: 'AI_APICallError', |
| statusCode: 401, |
| data: { error: { message: 'Invalid API key. Check your credentials and try again.' } }, |
| }); |
| assert.equal(classifyError(invalidKey), 'auth'); |
| |
| const forbiddenModel = Object.assign(new Error('request failed'), { |
| name: 'AI_APICallError', |
| statusCode: 403, |
| data: { error: { message: 'You do not have access to this model.' } }, |
| }); |
| assert.equal(classifyError(forbiddenModel), 'auth'); |
| const serverErrorOn403 = Object.assign(new Error('request failed'), { |
| name: 'AI_APICallError', |
| statusCode: 403, |
| data: { error: { code: 'server_error' } }, |
| }); |
| assert.equal(classifyError(serverErrorOn403), 'auth'); |
| }); |
| |
| test('classifies exhausted Codex HTML edge 403 retries as provider unavailable', () => { |
| const exhaustedEdgeRejection = Object.assign( |
| new Error('Codex OAuth request failed: HTTP 403 Request rejected'), |
| { |
| name: 'OpenAiCodexEdgeRejectionError', |
| statusCode: 403, |
| data: { error: { code: 'openai_codex_edge_rejection' } }, |
| }, |
| ); |
| |
| assert.equal(classifyError(exhaustedEdgeRejection), 'provider_unavailable'); |
| assert.partialDeepStrictEqual(providerModelFailure(exhaustedEdgeRejection), { |
| retryable: false, |
| }); |
| assert.deepEqual(providerFailureDiagnostic(exhaustedEdgeRejection), { |
| errorClass: 'provider_unavailable', |
| httpStatus: 403, |
| providerCode: 'openai_codex_edge_rejection', |
| retryable: false, |
| }); |
| const spoofedProviderPayload = Object.assign(new Error('request failed'), { |
| name: 'AI_APICallError', |
| statusCode: 403, |
| data: { error: { code: 'openai_codex_edge_rejection' } }, |
| }); |
| assert.equal(classifyError(spoofedProviderPayload), 'auth'); |
| assert.notEqual( |
| classifyError({ code: 'openai_codex_edge_rejection', message: 'provider payload' }), |
| 'provider_unavailable', |
| ); |
| }); |
| |
| test('recovers structured Codex HTTP facts through an SDK wrapper and truncates identifiers', () => { |
| const cause = Object.assign(new Error('Codex OAuth request failed'), { |
| name: 'OpenAiCodexHttpError', |
| statusCode: 400, |
| data: { error: { code: 'x'.repeat(1_024) } }, |
| responseHeaders: { 'x-request-id': 'r'.repeat(1_024) }, |
| }); |
| const diagnostic = providerFailureDiagnostic( |
| Object.assign(new Error('Cannot connect to API'), { |
| name: 'AI_APICallError', |
| code: 'FETCH_FAILED', |
| cause, |
| }), |
| ); |
| |
| assert.equal(diagnostic.errorClass, 'request_rejected'); |
| assert.equal(diagnostic.httpStatus, 400); |
| assert.ok((diagnostic.providerCode?.length ?? 0) <= 256); |
| assert.ok((diagnostic.providerRequestId?.length ?? 0) <= 256); |
| assert.equal(diagnostic.retryable, false); |
| }); |
| |
| test('extracts allowlisted fields from JSON string failures without copying the payload', () => { |
| const summary = providerModelFailure( |
| JSON.stringify({ |
| error: { message: 'Invalid api_key=sk-test-diagnostic-value', code: 'bad_request' }, |
| request_id: 'req-123', |
| prompt: 'private customer text', |
| headers: { 'x-debug': 'internal' }, |
| }), |
| ); |
| |
| assert.partialDeepStrictEqual(summary, { |
| message: 'Invalid api_key=sk-test-diagnostic-value (code=bad_request, requestId=req-123)', |
| code: 'bad_request', |
| }); |
| assert.equal(JSON.stringify(summary).includes('private customer text'), false); |
| assert.equal(JSON.stringify(summary).includes('x-debug'), false); |
| assert.partialDeepStrictEqual( |
| providerModelFailure({ |
| error: JSON.stringify({ |
| message: 'nested provider rejection', |
| code: 'nested_error', |
| prompt: 'another private prompt', |
| }), |
| }), |
| { |
| message: 'nested provider rejection (code=nested_error)', |
| code: 'nested_error', |
| }, |
| ); |
| assert.equal( |
| providerModelFailure(JSON.stringify([{ prompt: 'private list payload' }])).message, |
| 'Model request failed', |
| ); |
| }); |
| |
| test('derives retryability from the failure kind alone', () => { |
| // One real provider shape per kind: the kind is the only authority, so the |
| // table is the whole retry contract. |
| const cases: Array<[string, unknown, ModelFailureKind, boolean]> = [ |
| [ |
| 'websocket transport', |
| Object.assign(new Error('closed before completion'), { |
| name: 'OpenAiResponsesTransportError', |
| code: 'OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR', |
| }), |
| 'network', |
| true, |
| ], |
| [ |
| 'missing continuation', |
| Object.assign(new Error('continuation unavailable'), { |
| name: 'OpenAiResponsesTransportError', |
| code: 'OPENAI_RESPONSES_CONTINUATION_UNAVAILABLE', |
| }), |
| 'network', |
| true, |
| ], |
| [ |
| 'xAI capacity', |
| Object.assign(new Error('The model is currently at capacity due to high demand.'), { |
| name: 'AI_APICallError', |
| data: { error: { code: 'resource-exhausted' } }, |
| }), |
| 'provider_capacity', |
| true, |
| ], |
| [ |
| 'upstream 503', |
| Object.assign(new Error('Service unavailable'), { |
| name: 'AI_APICallError', |
| statusCode: 503, |
| }), |
| 'provider_unavailable', |
| true, |
| ], |
| [ |
| 'bare 429', |
| Object.assign(new Error('Upstream model provider is unavailable'), { |
| name: 'AI_APICallError', |
| statusCode: 429, |
| data: { |
| error: { |
| code: 'rate_limit_error', |
| message: 'Upstream model provider is temporarily unavailable. Please try again.', |
| }, |
| }, |
| }), |
| 'rate_limit', |
| true, |
| ], |
| [ |
| 'truncated stream', |
| new Error('response stream ended without a finish reason'), |
| 'stream_truncated', |
| true, |
| ], |
| [ |
| 'stream timeout', |
| Object.assign(new Error('model stream stalled'), { code: 'MODEL_STREAM_TIMEOUT' }), |
| 'timeout', |
| true, |
| ], |
| ['fetch timeout', new DOMException('request timed out', 'TimeoutError'), 'timeout', true], |
| [ |
| 'invalid key', |
| Object.assign(new Error('Invalid API key provided'), { |
| name: 'AI_APICallError', |
| statusCode: 401, |
| }), |
| 'auth', |
| false, |
| ], |
| [ |
| 'exhausted quota', |
| Object.assign(new Error('request failed'), { |
| name: 'AI_APICallError', |
| statusCode: 429, |
| data: { error: { code: 'insufficient_quota' } }, |
| }), |
| 'provider_billing', |
| false, |
| ], |
| [ |
| 'input overflow', |
| Object.assign(new Error('Bad Request'), { |
| name: 'AI_APICallError', |
| statusCode: 400, |
| data: { error: { code: 'context_length_exceeded' } }, |
| }), |
| 'context_overflow', |
| false, |
| ], |
| [ |
| // The AI SDK's own isRetryable flag calls 409 retryable; the kind decides. |
| 'conflict', |
| Object.assign(new Error('Conflict'), { |
| name: 'AI_APICallError', |
| statusCode: 409, |
| isRetryable: true, |
| }), |
| 'request_rejected', |
| false, |
| ], |
| [ |
| 'bad request', |
| Object.assign(new Error('bad request'), { name: 'AI_APICallError', statusCode: 400 }), |
| 'request_rejected', |
| false, |
| ], |
| ['fetch failure', new TypeError('fetch failed'), 'network', true], |
| [ |
| 'unclassifiable', |
| { type: 'invalid_request_error', message: 'missing required field' }, |
| 'unknown', |
| false, |
| ], |
| ]; |
| |
| for (const [label, error, kind, retryable] of cases) { |
| assert.equal(classifyError(error), kind, label); |
| assert.equal(providerModelFailure(error).retryable, retryable, label); |
| assert.equal(providerFailureDiagnostic(error).retryable, retryable, label); |
| } |
| }); |
| |
| test('Retry-After only sets the delay, never the retryability', () => { |
| const failing = (statusCode: number, responseHeaders?: Record<string, string>) => |
| Object.assign(new Error('provider rejected the request'), { |
| name: 'AI_APICallError', |
| statusCode, |
| ...(responseHeaders ? { responseHeaders } : {}), |
| }); |
| const headerCases: Array<[string, Record<string, string> | undefined, number | undefined]> = [ |
| ['seconds', { 'retry-after': '40' }, 40_000], |
| ['milliseconds', { 'retry-after-ms': '1500' }, 1_500], |
| ['malformed', { 'retry-after': 'not-a-delay' }, undefined], |
| ['elapsed HTTP-date', { 'retry-after': 'Wed, 21 Oct 2015 07:28:00 GMT' }, undefined], |
| ['absent', undefined, undefined], |
| ]; |
| |
| for (const statusCode of [429, 503]) { |
| for (const [label, headers, retryAfterMs] of headerCases) { |
| const failure = providerModelFailure(failing(statusCode, headers)); |
| assert.equal(failure.retryable, true, `${statusCode} ${label}`); |
| assert.equal(failure.retryAfterMs, retryAfterMs, `${statusCode} ${label}`); |
| } |
| } |
| |
| // A non-retryable kind stays non-retryable however generous the header is. |
| const refusedWithDelay = Object.assign(new Error('Invalid API key provided'), { |
| name: 'AI_APICallError', |
| statusCode: 401, |
| responseHeaders: { 'retry-after': '40' }, |
| }); |
| assert.partialDeepStrictEqual(providerModelFailure(refusedWithDelay), { retryable: false }); |
| assert.equal(providerModelFailure(refusedWithDelay).retryAfterMs, undefined); |
| }); |
| |
| test('abort and a spent Codex edge budget override the retryable kinds', () => { |
| const aborted = new RetryError({ |
| message: 'Retry stopped', |
| reason: 'abort', |
| errors: [Object.assign(new Error('Service unavailable'), { statusCode: 503 })], |
| }); |
| assert.equal(classifyError(aborted), 'abort'); |
| assert.partialDeepStrictEqual(providerModelFailure(aborted), { retryable: false }); |
| |
| const exhaustedEdge = Object.assign( |
| new Error('Codex OAuth request failed: HTTP 403 Request rejected'), |
| { |
| name: 'OpenAiCodexEdgeRejectionError', |
| statusCode: 403, |
| data: { error: { code: 'openai_codex_edge_rejection' } }, |
| responseHeaders: { 'retry-after': '40' }, |
| }, |
| ); |
| assert.equal(classifyError(exhaustedEdge), 'provider_unavailable'); |
| assert.partialDeepStrictEqual(providerModelFailure(exhaustedEdge), { retryable: false }); |
| assert.equal(providerModelFailure(exhaustedEdge).retryAfterMs, undefined); |
| }); |
| |
| test('treats a status-less provider server_error as temporarily unavailable', () => { |
| const failure = { |
| type: 'model_failure', |
| kind: 'unknown', |
| retryable: false, |
| message: |
| 'Streaming response failed: [502] Upstream error from Nvidia: Service temporarily overloaded', |
| code: 'server_error', |
| }; |
| |
| assert.equal(classifyError(failure), 'provider_unavailable'); |
| assert.partialDeepStrictEqual(providerModelFailure(failure), { retryable: true }); |
| assert.deepEqual(providerFailureDiagnostic(failure), { |
| errorClass: 'provider_unavailable', |
| providerCode: 'server_error', |
| retryable: true, |
| }); |
| }); |
| |
| test('retries an AI SDK transport failure without an HTTP response', () => { |
| const failure = Object.assign( |
| new Error( |
| 'Cannot connect to API: 80E1BDF601000000:error:0A000119:SSL routines:tls_get_more_records:decryption failed or bad record mac:../deps/openssl/openssl/ssl/record/methods/tls_common.c:869:', |
| ), |
| { |
| name: 'AI_APICallError', |
| isRetryable: true, |
| cause: Object.assign(new TypeError('fetch failed'), { |
| cause: Object.assign(new Error('decryption failed or bad record mac'), { |
| code: 'ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC', |
| }), |
| }), |
| }, |
| ); |
| |
| assert.equal(classifyError(failure), 'network'); |
| assert.partialDeepStrictEqual(providerModelFailure(failure), { retryable: true }); |
| assert.equal(providerFailureDiagnostic(failure).retryable, true); |
| }); |
| |
| test('retries a transport failure identified only by a cause code', () => { |
| const failure = Object.assign(new Error('request failed'), { |
| cause: { code: 'ECONNRESET' }, |
| }); |
| |
| assert.equal(classifyError(failure), 'network'); |
| assert.partialDeepStrictEqual(providerModelFailure(failure), { retryable: true }); |
| }); |
| |
| test('an exhausted free tier on 429 is billing, not a throttle to retry', () => { |
| // OpenCode Zen reports the exhausted free allowance as error.type on a 429. |
| const freeUsageLimit = Object.assign(new Error('Rate limit exceeded'), { |
| name: 'AI_APICallError', |
| statusCode: 429, |
| data: { error: { type: 'FreeUsageLimitError', message: 'Rate limit exceeded' } }, |
| }); |
| |
| assert.equal(classifyError(freeUsageLimit), 'provider_billing'); |
| assert.partialDeepStrictEqual(providerModelFailure(freeUsageLimit), { retryable: false }); |
| assert.equal(providerFailureDiagnostic(freeUsageLimit).retryable, false); |
| }); |
| |
| test('classifies provider capacity errors and retries with backoff', () => { |
| const capacity = () => |
| Object.assign(new Error('The model is currently at capacity due to high demand.'), { |
| name: 'AI_APICallError', |
| data: { error: { code: 'resource-exhausted' } }, |
| }); |
| |
| assert.equal(classifyError(capacity()), 'provider_capacity'); |
| |
| const topLevelCode = Object.assign(new Error('The model is currently at capacity'), { |
| code: 'resource-exhausted', |
| }); |
| assert.equal(classifyError(topLevelCode), 'provider_capacity'); |
| |
| const capacityWithAbortText = Object.assign(new Error('Request aborted by upstream'), { |
| name: 'AI_APICallError', |
| data: { error: { code: 'resource-exhausted' } }, |
| }); |
| assert.equal(classifyError(capacityWithAbortText), 'provider_capacity'); |
| |
| const capacityWithRateLimitStatus = Object.assign(new Error('Too many requests'), { |
| name: 'AI_APICallError', |
| statusCode: 429, |
| data: { error: { code: 'resource-exhausted' } }, |
| }); |
| assert.equal(classifyError(capacityWithRateLimitStatus), 'provider_capacity'); |
| assert.partialDeepStrictEqual(providerModelFailure(capacityWithRateLimitStatus), { |
| retryable: true, |
| }); |
| assert.deepEqual(providerFailureDiagnostic(capacityWithRateLimitStatus), { |
| errorClass: 'provider_capacity', |
| httpStatus: 429, |
| providerCode: 'resource-exhausted', |
| retryable: true, |
| }); |
| assert.equal( |
| providerFailureDiagnostic( |
| Object.assign(new Error('The model is at capacity'), { |
| name: 'AI_APICallError', |
| statusCode: 503, |
| data: { error: { code: 'resource-exhausted' } }, |
| }), |
| ).errorClass, |
| 'provider_capacity', |
| ); |
| |
| const ambiguousQuotaCode = Object.assign(new Error('resource exhausted'), { |
| name: 'AI_APICallError', |
| data: { error: { code: 'resource_exhausted' } }, |
| }); |
| assert.notEqual(classifyError(ambiguousQuotaCode), 'provider_capacity'); |
| }); |
| |
| test('classifies context overflow by predicate, carrier shape, and evidence precedence', () => { |
| const overflow = (message: string, extra: Record<string, unknown> = {}) => |
| classifyError(Object.assign(new Error(message), { name: 'AI_APICallError', ...extra })); |
| |
| const textCases = [ |
| 'prompt is too long: 213462 tokens > 200000 maximum', |
| 'request_too_large: Request exceeds the maximum size', |
| 'Your input exceeds the context window of this model', |
| "Requested token count exceeds the model's maximum context length of 131072 tokens", |
| 'The input token count (1196265) exceeds the maximum number of tokens allowed', |
| "This model's maximum prompt length is 131072 but the request contains 537812 tokens", |
| 'Please reduce the length of the messages or completion', |
| "This endpoint's maximum context length is 262144 tokens", |
| 'Prompt contains 5000 tokens; too large for model with 4096 maximum context length', |
| 'invalid params, context window exceeds limit', |
| 'Your request exceeded model token limit: 200000', |
| 'prompt token count of 21000 exceeds the limit of 16384', |
| 'the prompt contains too many tokens', |
| 'Input token limit exceeded: 250000 tokens > 200000 maximum', |
| 'Failed to generate response: context_length_exceeded', |
| ]; |
| for (const message of textCases) { |
| assert.equal(overflow(message, { statusCode: 400 }), 'context_overflow', message); |
| } |
| |
| assert.equal( |
| overflow('Bad Request', { |
| statusCode: 400, |
| data: { error: { message: 'Bad Request', code: 'context_length_exceeded' } }, |
| }), |
| 'context_overflow', |
| ); |
| assert.equal( |
| overflow('Bad Request', { |
| statusCode: 400, |
| responseBody: '{"error":{"code":"context_length_exceeded"}}', |
| }), |
| 'context_overflow', |
| ); |
| assert.equal( |
| overflow('Request Entity Too Large', { |
| statusCode: 400, |
| data: { error: { type: 'request_too_large', message: 'Request Entity Too Large' } }, |
| }), |
| 'context_overflow', |
| ); |
| |
| assert.equal( |
| classifyError({ |
| type: 'error', |
| error: { |
| type: 'invalid_request_error', |
| code: 'context_length_exceeded', |
| message: 'Bad Request', |
| }, |
| }), |
| 'context_overflow', |
| ); |
| assert.equal( |
| classifyError( |
| "Requested token count exceeds the model's maximum context length of 131072 tokens.", |
| ), |
| 'context_overflow', |
| ); |
| assert.equal( |
| classifyError({ type: 'invalid_request_error', message: 'missing required field' }), |
| 'unknown', |
| ); |
| |
| assert.equal( |
| overflow('Service Unavailable', { |
| statusCode: 503, |
| data: { error: { message: 'Service Unavailable', code: 'context_length_exceeded' } }, |
| }), |
| 'context_overflow', |
| ); |
| assert.equal( |
| overflow( |
| "503 proxy error: Requested token count exceeds the model's maximum context length", |
| { statusCode: 503 }, |
| ), |
| 'context_overflow', |
| ); |
| assert.equal(overflow('', { statusCode: 413 }), 'context_overflow'); |
| assert.equal( |
| overflow('Please rate limit your requests', { statusCode: 503 }), |
| 'provider_unavailable', |
| ); |
| assert.notEqual(overflow('Failed to generate response', { statusCode: 400 }), 'rate_limit'); |
| assert.equal(overflow('rate_limit_exceeded: slow down'), 'rate_limit'); |
| |
| const vetoedTextCases = [ |
| 'Rate limit reached: too many tokens, please wait', |
| "Too many requests. This endpoint's maximum context length is 262144 tokens.", |
| "ThrottlingException. This endpoint's maximum context length is 262144 tokens.", |
| "Quota exceeded. This endpoint's maximum context length is 262144 tokens.", |
| "Completion has too many tokens. This endpoint's maximum context length is 262144 tokens.", |
| "Too many tokens were requested for the completion. This endpoint's maximum context length is 262144 tokens.", |
| "Output token count of 8192 exceeds the limit. This endpoint's maximum context length is 262144 tokens.", |
| "Too many completion tokens were requested. This endpoint's maximum context length is 262144 tokens.", |
| "Maximum completion tokens exceeded. This endpoint's maximum context length is 262144 tokens.", |
| ]; |
| for (const message of vetoedTextCases) { |
| assert.notEqual(overflow(message, { statusCode: 400 }), 'context_overflow', message); |
| } |
| for (const message of [ |
| 'invalid request: missing required field', |
| 'file size exceeds the limit of 10485760', |
| ]) { |
| assert.notEqual(overflow(message, { statusCode: 400 }), 'context_overflow', message); |
| } |
| |
| assert.equal( |
| overflow( |
| "This model's maximum context length is 8192 tokens. However, you requested 10240 tokens (10140 in the messages, 100 in the completion).", |
| { statusCode: 400 }, |
| ), |
| 'context_overflow', |
| ); |
| assert.equal( |
| overflow('Completion has too many tokens for this model', { |
| statusCode: 400, |
| data: { |
| error: { |
| message: 'Completion has too many tokens for this model', |
| code: 'context_length_exceeded', |
| }, |
| }, |
| }), |
| 'context_overflow', |
| ); |
| }); |
| |
| test('classifies wording retained only in schema-invalid response bodies', async () => { |
| const handler = createJsonErrorResponseHandler({ |
| errorSchema: z.object({ error: z.object({ message: z.string() }) }), |
| errorToMessage: (data) => data.error.message, |
| }); |
| const errorFromBody = async (body: string) => |
| ( |
| await handler({ |
| response: new Response(body, { status: 400, statusText: 'Bad Request' }), |
| url: 'https://api.example.test/v1/chat/completions', |
| requestBodyValues: {}, |
| }) |
| ).value; |
| |
| const overflowError = await errorFromBody( |
| '{"error":"Your input exceeds the context window of this model"}', |
| ); |
| assert.equal(overflowError.message, 'Bad Request'); |
| assert.equal(overflowError.data, undefined); |
| assert.equal(classifyError(overflowError), 'context_overflow'); |
| |
| const outputCapError = await errorFromBody( |
| '{"error":"Too many completion tokens were requested. This endpoint\'s maximum context length is 262144 tokens."}', |
| ); |
| assert.notEqual(classifyError(outputCapError), 'context_overflow'); |
| }); |
| |
| test('preserves provider evidence through the official AI SDK retry wrapper', async () => { |
| const handler = createJsonErrorResponseHandler({ |
| errorSchema: z.object({ |
| error: z.object({ |
| message: z.string(), |
| code: z.string().optional(), |
| }), |
| }), |
| errorToMessage: (data) => data.error.message, |
| }); |
| const apiCallError = async (status: number, body: string) => |
| ( |
| await handler({ |
| response: new Response(body, { status, statusText: `HTTP ${status}` }), |
| url: 'https://api.example.test/v1/chat/completions', |
| requestBodyValues: {}, |
| }) |
| ).value; |
| const retried = ( |
| lastError: unknown, |
| reason: 'maxRetriesExceeded' | 'errorNotRetryable' = 'maxRetriesExceeded', |
| ) => |
| new RetryError({ |
| message: 'Provider request failed after retries', |
| reason, |
| errors: [lastError, lastError, lastError], |
| }); |
| |
| const rateLimit = await apiCallError(429, '{"error":{"message":"Too many requests"}}'); |
| const unavailable = await apiCallError(503, '{"error":{"message":"Service unavailable"}}'); |
| const overflow = await apiCallError( |
| 503, |
| '{"error":{"message":"Service unavailable","code":"context_length_exceeded"}}', |
| ); |
| |
| assert.equal(classifyError(retried(rateLimit)), 'rate_limit'); |
| assert.equal(classifyError(retried(unavailable)), 'provider_unavailable'); |
| assert.equal(classifyError(retried(overflow, 'errorNotRetryable')), 'context_overflow'); |
| assert.equal( |
| classifyError( |
| new RetryError({ |
| message: 'Retry stopped', |
| reason: 'abort', |
| errors: [new Error('transport stopped')], |
| }), |
| ), |
| 'abort', |
| ); |
| assert.equal( |
| classifyError( |
| new RetryError({ |
| message: 'Provider request failed after retries', |
| reason: 'maxRetriesExceeded', |
| errors: [], |
| }), |
| ), |
| 'unknown', |
| ); |
| assert.equal( |
| classifyError( |
| Object.assign(new Error('Provider request failed after retries'), { |
| name: 'AI_RetryError', |
| lastError: rateLimit, |
| }), |
| ), |
| 'unknown', |
| ); |
| }); |
| }); |
| |
| test('auth classification matches authentication without matching authority', () => { |
| assert.equal(classifyError(new Error('OAuth2 token expired')), 'auth'); |
| assert.equal( |
| classifyError(new Error('Conversation copy contains durable runtime authority facts')), |
| 'unknown', |
| ); |
| }); |