diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index 38195bb01c..f1a7872356 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -62,6 +62,71 @@ describe('Provider error classification', () => { 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), 'ProviderBilling'); + + const balanceOn403 = Object.assign(new Error('request failed'), { + name: 'AI_APICallError', + statusCode: 403, + data: { error: { code: 'insufficient_balance' } }, + }); + assert.equal(classifyError(balanceOn403), 'ProviderBilling'); + + // 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), 'ProviderBilling'); + assert.equal(providerRetryMetadata(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), 'ProviderBilling'); + assert.equal(providerRetryMetadata(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), 'ProviderBilling'); + }); + + 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'); + }); + 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', diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 30399ed799..9bd1fdbd8e 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -44,6 +44,36 @@ const PROVIDER_UNAVAILABLE_PROVIDER_CODES: ReadonlySet = new Set([ */ const PROVIDER_CAPACITY_CODES: ReadonlySet = new Set(['resource-exhausted']); +/** + * Structured provider error identifiers that mean an ACCOUNT-level usage or + * billing condition — exhausted credits or a closed plan/quota window — + * rather than an invalid credential. Providers disagree on which HTTP status + * travels with them (402, 401/403, even 429); the structured code is the + * stable evidence, so it outranks every numeric fallback below. + */ +const PROVIDER_BILLING_PROVIDER_CODES: ReadonlySet = new Set([ + 'insufficient_quota', // OpenAI & OpenAI-compatible: error.code + 'insufficient_balance', // DeepSeek: error.code + 'quota_exceeded', // OpenAI-compatible variants: error.code +]); + +/** + * Free-text usage/billing wording that overrides a credential-shaped HTTP + * status (401/403): some providers report exhausted plan windows, credits, + * or subscriptions through auth-style statuses for validly signed-in users, + * and "Authentication failed" would send them to re-authenticate (#2516). + * Matched only on that status branch, so genuine throttles keep their + * RateLimit path and plain invalid-key / permission messages — which carry + * none of this vocabulary — still project to Auth. + */ +const USAGE_LIMIT_TEXT_PATTERNS: readonly RegExp[] = [ + /\bquota\b/i, + /usage limit/i, + /plan (?:limit|allowance)/i, + /(?:credit|balance|allowance)[^.]{0,40}(?:exhaust|reached)|exhaust[^.]{0,20}(?:credit|balance)/i, + /subscription/i, +]; + /** * A provider failure normalized into classification evidence. classifyError's * real input domain is NOT just Error instances: a request-level failure is @@ -632,11 +662,24 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { // Structured provider evidence: the parsed error JSON's code/type is the // only unconditional signal for a context overflow. if (structuredCodes.some((c) => CONTEXT_OVERFLOW_PROVIDER_CODES.has(c))) return 'ContextLength'; + if ( + PROVIDER_BILLING_PROVIDER_CODES.has(normalizedCode) || + structuredCodes.some((c) => PROVIDER_BILLING_PROVIDER_CODES.has(c)) + ) { + return 'ProviderBilling'; + } if (text.includes('abort')) return 'Abort'; if (statusCode === '402' || code === '402') return 'ProviderBilling'; if (statusCode === '429' || code === '429') return 'RateLimit'; - if (statusCode === '401' || statusCode === '403' || code === '401' || code === '403') + if (statusCode === '401' || statusCode === '403' || code === '401' || code === '403') { + // Credential-shaped statuses can still carry account-level usage + // evidence: an exhausted plan/credit window for a validly signed-in + // user must not tell them to re-authenticate (#2516). + if (USAGE_LIMIT_TEXT_PATTERNS.some((pattern) => pattern.test(text))) { + return 'ProviderBilling'; + } return 'Auth'; + } if (statusCode === '413' || code === '413') return 'ContextLength'; // Free-text overflow relations on the composite text, veto-first inside. if (isContextOverflowErrorText(text)) return 'ContextLength';