mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user