Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
45 changes: 44 additions & 1 deletion packages/runtime/src/provider-error-classification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,36 @@ const PROVIDER_UNAVAILABLE_PROVIDER_CODES: ReadonlySet<string> = new Set([
*/
const PROVIDER_CAPACITY_CODES: ReadonlySet<string> = 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<string> = 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
Expand Down Expand Up @@ -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';
Expand Down