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
77 changes: 77 additions & 0 deletions src/codex-quota.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,16 +110,31 @@ function classify(windows) {
return out;
}

/**
* A parsed reading: the fields `account.quota` already uses, each present only
* when the headers stated it.
*
* @typedef {object} CodexQuota
* @property {number} [unified5h] Session-window utilization, 0-1.
* @property {number} [unified5hReset] When that window resets, ms epoch.
* @property {number} [unified7d] Weekly-window utilization, 0-1.
* @property {number} [unified7dReset] When that window resets, ms epoch.
* @property {{slug: string, name: string, utilization: number, resetAt: number|null}[]} [modelBuckets] Model-scoped weekly buckets.
*/

/**
* Parse Codex rate-limit headers into the fields `account.quota` already uses.
*
* Returns only what the headers actually stated, so a caller can assign over
* an existing quota without blanking readings this response did not mention.
* An empty object means "this response carried no quota", which is normal:
* the catalog fetch (`/models`) has none.
*
* @returns {CodexQuota}
*/
export function parseCodexQuota(headers) {
const families = collectFamilies(headers);
/** @type {CodexQuota} */
const quota = {};

const account = classify(families.get('')?.windows || {});
Expand Down Expand Up @@ -172,6 +187,68 @@ export function parseCodexQuota(headers) {
return quota;
}

/**
* Is a window at or past its limit?
*
* Readings are 0-1 fractions here, and the comparison is `>=` because upstream
* keeps counting once a window is past its limit: 104% arrives as 1.04. A
* window the headers did not state is not spent — `classify` has already
* dropped the unparseable and the zeroed.
*
* @param {number} [utilization]
*/
const isSpent = (utilization) => utilization != null && utilization >= 1;

/**
* Which windows these headers report as spent — at or past their limit. Empty
* when none are, including when the response carried no Codex quota at all.
*
* Anthropic names a spent bucket outright, as `…-status: rejected`. This API
* publishes no status at all: it reports how much of each window is gone, and a
* window at its limit is the same fact in the other spelling. That distinction
* is what a 429 handler needs, because a spent window is durable — the account
* cannot serve again until the window resets, so waiting out `retry-after` and
* asking the SAME account again is futile, however momentary the 429 looked.
*
* Every family is read, not just the account-wide one. A subscription states
* its only 5-hour window inside a NAMED family (see parseCodexQuota), so the
* account-wide percentages alone would never show a spent session window; and a
* model-scoped weekly bucket is spent on its own terms, whatever the
* account-wide reading says.
*
* The labels name what is spent, for the log line that follows, and they keep
* the two scopes apart: `5h` and `weekly` are windows the whole account shares,
* while `<name> weekly` is one model family's bucket. The caller needs that
* distinction (see isAccountWideCodexWindow), because only the first kind is a
* reason to take the account out of rotation.
*
* @param {Record<string, string>} headers Rate-limit headers from the response.
* @returns {string[]} One label per spent window, e.g. `['weekly']`.
*/
export function codexSpentWindows(headers) {
const quota = parseCodexQuota(headers);
const spent = [];
if (isSpent(quota.unified5h)) spent.push('5h');
if (isSpent(quota.unified7d)) spent.push('weekly');
for (const bucket of quota.modelBuckets ?? []) {
if (isSpent(bucket.utilization)) spent.push(`${bucket.name} weekly`);
}
return spent;
}

/**
* Is this `codexSpentWindows` label a window the whole account shares?
*
* A model-scoped bucket is always labelled `<name> weekly`, so it can never
* collide with the two bare labels. The scope matters to the 429 handler: a
* spent account-wide window refuses every request, so the account is held; a
* spent model bucket refuses one family only, and holding the account for it
* would park a subscription that still serves every other model.
*
* @param {string} label One label from `codexSpentWindows`.
*/
export const isAccountWideCodexWindow = (label) => label === '5h' || label === 'weekly';

/** The subscription plan upstream reports, for status output. Null when absent. */
export function parseCodexPlanType(headers) {
const value = headers?.['x-codex-plan-type'];
Expand Down
38 changes: 35 additions & 3 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { renderDashboardHtml, dashboardCsp } from './dashboard.js';
import { createUsageRecorder, resolveUsageDimensions, usageDimensionHeaderNames } from './client-usage.js';
import { responsesEventUsage, isResponsesBody, normalizeResponsesUsage } from './responses-usage.js';
import { classificationPath } from './classification-path.js';
import { codexSpentWindows, isAccountWideCodexWindow } from './codex-quota.js';
/** @typedef {import('./types.js').CodedError} CodedError */


Expand Down Expand Up @@ -2379,6 +2380,7 @@ export async function forwardRequest(req, res, body, accountManager, upstream, r
// `anthropic-ratelimit-*` and Codex under `x-codex-*`; `updateQuota` picks
// the parser by provider, so keeping only Anthropic's prefix handed a Codex
// account an empty object and its quota never landed.
/** @type {Record<string, string>} */
const rateLimitHeaders = {};
for (const [key, value] of upstreamRes.headers.entries()) {
if (key.startsWith('anthropic-ratelimit-') || key.startsWith('x-codex-')) {
Expand Down Expand Up @@ -2427,19 +2429,49 @@ export async function forwardRequest(req, res, body, accountManager, upstream, r
// account is futile — switch to another account now (updateQuota above
// already recorded the spent bucket's utilization from the headers).
const rl = rateLimitHeaders;
// A spent Codex window says the same thing as a rejected unified status,
// in the only vocabulary that backend has: a used-percent at its limit.
// Read through the same parser the quota sweep uses, so every family is
// covered — a subscription states its only 5-hour window inside a NAMED
// one. Without this a Codex 429 read as a transient throttle, so the
// account was never held: the pause lapsed, selection handed the spent
// subscription straight back, and the next request paid another refusal.
const spentCodexWindows = codexSpentWindows(rl);
// Only the account-wide windows are a general rejection. A named family's
// weekly bucket is model-scoped, like Anthropic's `7d_oi`: the account
// still serves every other model, so parking it would take a healthy
// account out of rotation for all of them. And nothing gates selection on
// the recorded model buckets, so once the hold lapsed the account would be
// picked again, refuse again and be parked again, for as long as that one
// bucket stayed spent.
const codexAccountSpent = spentCodexWindows.some(isAccountWideCodexWindow);
const generalRejected = rl['anthropic-ratelimit-unified-5h-status'] === 'rejected'
|| rl['anthropic-ratelimit-unified-7d-status'] === 'rejected';
|| rl['anthropic-ratelimit-unified-7d-status'] === 'rejected'
|| codexAccountSpent;
const fableRejected = rl['anthropic-ratelimit-unified-7d_oi-status'] === 'rejected' && !generalRejected;
if ((generalRejected || fableRejected) && retryCount < maxRetries) {
const codexFamilyRejected = spentCodexWindows.length > 0 && !generalRejected;
if ((generalRejected || fableRejected || codexFamilyRejected) && retryCount < maxRetries) {
// A Fable-only rejection leaves the account fine for other models, so we
// do NOT throttle it globally — the recorded Fable utilization makes
// selection skip it for Fable requests only. A general rejection spends a
// shared bucket, so hold the whole account for its reset window.
if (fableRejected) {
console.log(`[TeamClaude] Fable weekly exhausted on "${account.name}" — switching account for this Fable request`);
} else if (codexFamilyRejected) {
// The same shape as the Fable case: this request moves to another
// account and the account itself is left alone. Unlike Fable, selection
// does not yet skip the account for this model family afterwards, so a
// later request for it pays one refusal here before it rotates.
console.log(`[TeamClaude] ${safeLine(spentCodexWindows.join(', '), 80)} spent on "${account.name}" — switching account for this request`);
} else {
const hold = Math.min(Math.max(retryAfter, 1), 3600);
console.log(`[TeamClaude] Quota rejection (429) on "${account.name}" — throttling ${hold}s and switching account`);
// Name the spent window when the headers said which: "which one" is
// the first thing an operator asks of a rejection, and a 5-hour
// window reads very differently from a weekly one. A model-scoped
// label carries upstream's own text, so it goes through safeLine.
const spent = spentCodexWindows.length
? ` (${safeLine(spentCodexWindows.join(', '), 80)} spent)` : '';
console.log(`[TeamClaude] Quota rejection (429) on "${account.name}"${spent} — throttling ${hold}s and switching account`);
accountManager.markRateLimited(account.index, hold);
}
ctx.tried.add(account.index);
Expand Down
Loading
Loading