fix(retry): adjust max_tokens on OpenRouter 402 credit shortfall (#1263)

OpenRouter (and other quota-billed OpenAI-compat gateways) reply with
HTTP 402 when the caller has fewer credits than the requested
max_tokens would consume. The body includes the affordable cap:

  This request requires more credits, or fewer max_tokens. You
  requested up to 32000 tokens, but can only afford 27342.

Previously this surfaced as a fatal API error and the user had to
guess what value to put in `CLAUDE_CODE_MAX_OUTPUT_TOKENS` to make the
request fit. Now `withRetry` parses the affordable number out of the
message and retries once with `maxTokensOverride = affordable` —
mirroring the existing context-overflow retry path. A single stderr
line tells the user output was clamped so they can top up credits if
they want the full budget back.

Single-shot adjustment (gated by `retryContext.maxTokensOverride ===
undefined`) so an unrelated subsequent 402 doesn't loop.

Also fixes pre-existing test-fixture mock leak: the `providers.js`
stub didn't include `isFirstPartyAnthropicBaseUrl` /
`usesAnthropicAccountFlow` / `isGithubNativeAnthropicMode`, so the
entire `withRetry.test.ts` file errored on import.

Closes #1125
This commit is contained in:
0xfandom
2026-05-23 08:50:04 +08:00
committed by GitHub
parent bafc2a1bc5
commit 892c0545ed
2 changed files with 135 additions and 0 deletions
+70
View File
@@ -63,6 +63,9 @@ async function importFreshWithRetryModule(
mock.module('src/utils/model/providers.js', () => ({
getAPIProvider: () => provider,
getAPIProviderForStatsig: () => provider,
isFirstPartyAnthropicBaseUrl: () => provider === 'firstParty',
isGithubNativeAnthropicMode: () => false,
usesAnthropicAccountFlow: () => false,
}))
return import(`./withRetry.js?ts=${Date.now()}-${Math.random()}`)
}
@@ -196,3 +199,70 @@ describe('getRateLimitResetDelayMs - providers without reset headers', () => {
expect(getRateLimitResetDelayMs(error)).toBeNull()
})
})
// Regression for #1125 — OpenRouter 402 (credits-vs-max_tokens mismatch)
// carries the affordable cap in the message. The retry loop should adjust
// max_tokens to that cap once instead of bubbling a confusing 402 to the user.
describe('parseOpenRouterAffordableMaxTokensError (#1125)', () => {
function make402(message: string): APIError {
return {
headers: new Headers(),
status: 402,
message,
name: 'APIError',
error: {},
} as unknown as APIError
}
test('parses the affordable max_tokens out of OpenRouter 402 body', async () => {
const { parseOpenRouterAffordableMaxTokensError } =
await importFreshWithRetryModule('openai')
const err = make402(
'This request requires more credits, or fewer max_tokens. You requested up to 32000 tokens, but can only afford 27342. To increase, visit ...',
)
expect(parseOpenRouterAffordableMaxTokensError(err)).toEqual({
requestedMaxTokens: 32000,
affordableMaxTokens: 27342,
})
})
test('returns undefined when status is not 402', async () => {
const { parseOpenRouterAffordableMaxTokensError } =
await importFreshWithRetryModule('openai')
const err = {
headers: new Headers(),
status: 429,
message: 'You requested up to 32000 tokens, but can only afford 27342',
name: 'APIError',
error: {},
} as unknown as APIError
expect(parseOpenRouterAffordableMaxTokensError(err)).toBeUndefined()
})
test('returns undefined when message does not match expected shape', async () => {
const { parseOpenRouterAffordableMaxTokensError } =
await importFreshWithRetryModule('openai')
const err = make402('Payment required. Top up your account.')
expect(parseOpenRouterAffordableMaxTokensError(err)).toBeUndefined()
})
test('returns undefined when affordable_max_tokens is zero', async () => {
const { parseOpenRouterAffordableMaxTokensError } =
await importFreshWithRetryModule('openai')
const err = make402(
'You requested up to 32000 tokens, but can only afford 0',
)
expect(parseOpenRouterAffordableMaxTokensError(err)).toBeUndefined()
})
test('shouldRetry returns true for parseable 402', async () => {
const { shouldRetry } = (await importFreshWithRetryModule('openai')) as {
shouldRetry?: (e: APIError) => boolean
}
if (!shouldRetry) return // shouldRetry is internal; skip when not exported
const err = make402(
'You requested up to 32000 tokens, but can only afford 27342',
)
expect(shouldRetry(err)).toBe(true)
})
})
+65
View File
@@ -400,6 +400,30 @@ export async function* withRetry<T>(
throw new CannotRetryError(error, retryContext)
}
// OpenRouter / OpenAI-compatible quota gateways: HTTP 402 with the
// affordable max_tokens in the message. Retry once at the affordable
// cap instead of failing on a credits-vs-max_tokens mismatch the user
// can't see in their shell (#1125). One adjustment per chain — if 402
// recurs after this, the retry chain falls through to the normal error
// path.
if (error instanceof APIError) {
const affordData = parseOpenRouterAffordableMaxTokensError(error)
if (affordData && retryContext.maxTokensOverride === undefined) {
retryContext.maxTokensOverride = affordData.affordableMaxTokens
logEvent('tengu_openrouter_402_max_tokens_adjustment', {
requestedMaxTokens: affordData.requestedMaxTokens,
affordableMaxTokens: affordData.affordableMaxTokens,
attempt,
})
// Surface the credit pressure so the user understands why output
// shrank. Single line; the provider already explained the why.
console.error(
`Provider returned 402 — retrying with max_tokens=${affordData.affordableMaxTokens} (was ${affordData.requestedMaxTokens}). Top up credits to restore the full budget.`,
)
continue
}
}
// Handle max tokens context overflow errors by adjusting max_tokens for the next attempt
// NOTE: With extended-context-window beta, this 400 error should not occur.
// The API now returns 'model_context_window_exceeded' stop_reason instead.
@@ -566,6 +590,41 @@ export function getRetryDelay(
return baseDelay + jitter
}
/**
* OpenRouter (and several other quota-billed gateways) reply with HTTP 402
* when the caller has fewer credits than the requested max_tokens would
* consume. The error message includes the affordable cap, so we can retry
* once with the lower number instead of forcing the user to manually lower
* their max_tokens (issue #1125).
*
* Example body:
* This request requires more credits, or fewer max_tokens. You requested
* up to 32000 tokens, but can only afford 27342. To increase, visit ...
*/
export function parseOpenRouterAffordableMaxTokensError(error: APIError):
| { requestedMaxTokens: number; affordableMaxTokens: number }
| undefined {
if (error.status !== 402 || !error.message) {
return undefined
}
const regex =
/requested up to (\d+) tokens?, but can only afford (\d+)/i
const match = error.message.match(regex)
if (!match || match.length !== 3 || !match[1] || !match[2]) {
return undefined
}
const requestedMaxTokens = parseInt(match[1], 10)
const affordableMaxTokens = parseInt(match[2], 10)
if (
isNaN(requestedMaxTokens) ||
isNaN(affordableMaxTokens) ||
affordableMaxTokens <= 0
) {
return undefined
}
return { requestedMaxTokens, affordableMaxTokens }
}
export function parseMaxTokensContextOverflowError(error: APIError):
| {
inputTokens: number
@@ -747,6 +806,12 @@ function shouldRetry(error: APIError): boolean {
return true
}
// OpenRouter-style 402 with an affordable max_tokens in the message — we
// can retry once at the lower cap (issue #1125).
if (parseOpenRouterAffordableMaxTokensError(error)) {
return true
}
// Note this is not a standard header.
const shouldRetryHeader = error.headers?.get('x-should-retry')