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
25 changes: 25 additions & 0 deletions docs/accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,31 @@ Two details are worth knowing if you read the raw headers:
a 5-hour window there. Windows are classified by their stated
`window-minutes`, never by position.

### Free rate-limit reset credits

OpenAI occasionally grants a ChatGPT account a free **rate-limit reset credit**:
redeeming one clears the account's spent windows ahead of their own reset. The
Codex CLI offers it as a manual action only, so a pooled account that runs dry
sits out the rest of its week holding one unless somebody notices it is there.

The count an account holds comes free with the quota probe — it rides on the
same `/wham/usage` payload the quota reading does — and shows up as `RC1` on the
TUI row, a `Reset` line in `teamclaude status`, and a badge on the dashboard
card. It survives a restart, which matters because the probe is off by default:
without that, nothing would say a credit exists until something next happened to
read the usage endpoint.

Only the probe refreshes the count, so a reading can outlive the credit it
describes — redeemed in the Codex CLI, or expired. The `status` line therefore
says how old the reading is (`as of 3h ago`), and every surface drops it once it
is more than 7 days old.

The count is what the account **holds**. Whether a particular credit can be
spent is a separate question — the payload's `applicable_available_count` is
upstream's own view of how many would reset a window right now, and is named on
the `status` line when it is zero — and spending one remains a manual action in
the Codex CLI.

## Third-party backend accounts

Any Anthropic-compatible API can be added as an account alongside your Claude accounts. Give it a higher `priority` value (lower = preferred, so use e.g. `100`) and it will be used as a fallback when all Claude accounts are exhausted.
Expand Down
30 changes: 30 additions & 0 deletions src/account-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ const PERSISTED_QUOTA_FIELDS = [
'unifiedStatus', 'unifiedStatusSeenAt',
'tokensLimit', 'tokensRemaining', 'requestsLimit', 'requestsRemaining', 'resetsAt',
'scopedWeekly',
// Codex free rate-limit reset credits, `{ available, applicable, seenAt }`.
// Worth persisting although it is not a quota: the usage probe is off by
// default, so without this a restart forgets that an account holds a credit
// until something next reads /wham/usage — and the row that says so is the
// only place an operator sees one at all.
'resetCredits',
];

// The family (Fable/Sonnet) weekly buckets and the field holding when each was
Expand All @@ -110,6 +116,23 @@ const FAMILY_WEEKLY_BUCKETS = [
{ key: 'unified7dSonnet', label: 'Sonnet', usageKey: 'sevenDaySonnet' },
];

/**
* The quota fields a Codex account only ever LEARNS — from a `/wham/usage`
* payload, or for `resetCredits` from the state restored off disk — so
* `emptyQuota` below does not seed them. Absent is meaningful for each: it says
* nothing has been read yet, which a seeded null would spell the same way as
* "read, and empty".
*
* Declared here so the one place that writes them can say so (`@type` on the
* local that holds the quota), rather than each one reading as a property that
* does not exist.
*
* @typedef {object} CodexLearnedQuota
* @property {string} [planType] the Codex subscription tier
* @property {{available: number, applicable: number|null, seenAt: number}} [resetCredits] free rate-limit reset credits held, and when that was last seen
* @property {Record<string, {name: string, utilization: number, resetAt: number|null, seenAt: number}>} [codexModelBuckets] model-scoped weekly buckets, keyed by slug
*/

function emptyQuota() {
return {
// Standard API rate limits (API key accounts)
Expand Down Expand Up @@ -3594,6 +3617,9 @@ export class AccountManager {
applyCodexUsageData(accountIndex, usage) {
const account = this.accounts[accountIndex];
if (!account || !usage || usage.error) return;
// The Codex-learned fields below are written here for the first time, so the
// empty-quota shape does not carry them. See CodexLearnedQuota.
/** @type {typeof account.quota & CodexLearnedQuota} */
const q = account.quota;
if (usage.fiveHour) {
q.unified5h = usage.fiveHour.utilization;
Expand All @@ -3604,6 +3630,10 @@ export class AccountManager {
q.unified7dReset = usage.sevenDay.resetAt ?? null;
}
if (usage.planType) q.planType = safeLine(usage.planType, 64);
// Stamped, because nothing else refreshes it: a payload that mentions no
// credits leaves the last reading alone rather than blanking it, so the
// age is the only thing that says how much the number is worth.
if (usage.resetCredits) q.resetCredits = { ...usage.resetCredits, seenAt: Date.now() };
if (Array.isArray(usage.modelBuckets)) {
q.codexModelBuckets = Object.fromEntries(usage.modelBuckets.slice(0, MAX_CODEX_MODEL_BUCKETS)
.filter(bucket => bucket?.slug)
Expand Down
48 changes: 48 additions & 0 deletions src/codex-usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,53 @@ function additionalLimits(additional) {
.map(([key, value]) => ({ slug: key, name: key, rateLimit: value?.rate_limit || value }));
}

/**
* The free rate-limit reset credits this account holds, or null when the
* payload says nothing about them. Two counts, kept apart on purpose:
*
* - `available` is what the account HOLDS, and is the number every display
* surface reports. It says nothing about whether this plan may spend one.
* - `applicable` is upstream's own view of how many would reset something
* right now — 0 whenever no window is currently eligible.
*
* Neither is a verdict on whether a credit could actually be spent: that is
* stated only by the account's own credit rows, which say whether a specific
* credit is both available and supported by the plan.
*
* @param {any} raw the payload's `rate_limit_reset_credits` object
*/
function resetCredits(raw) {
if (!raw || typeof raw !== 'object') return null;
const available = creditCount(raw.available_count);
// A malformed or absent count is dropped rather than read as zero, the same
// way a zeroed window is above: "none" and "we were not told" have different
// consequences, and only one of them is a fact.
if (available == null) return null;
return { available, applicable: creditCount(raw.applicable_available_count) };
}

// The most credits any surface will report. The count is drawn as `RC<n>` on a
// TUI row budgeted to the cell, so it has to stay two digits wide whatever the
// payload says; nobody holds a hundred of something granted one at a time.
const RESET_CREDIT_COUNT_MAX = 99;

/**
* One credit counter from the payload as a whole number in 0..99, or null when
* it is not a count at all (absent, non-numeric, infinite, negative).
*
* Truncated and capped because the value comes from a private endpoint and goes
* straight onto width-budgeted display rows: `1.5` or `1e9` would otherwise be
* drawn verbatim and push the row past its edge.
*
* @param {unknown} value
* @returns {number|null}
*/
function creditCount(value) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return null;
return Math.min(Math.trunc(n), RESET_CREDIT_COUNT_MAX);
}

/**
* Convert the private `/wham/usage` response into TeamClaude quota fields.
*
Expand Down Expand Up @@ -110,6 +157,7 @@ export function normalizeCodexUsagePayload(data) {
sevenDay: shared.sevenDay && { utilization: shared.sevenDay.utilization, resetAt: shared.sevenDay.resetAt },
modelBuckets,
planType: data?.plan_type || null,
resetCredits: resetCredits(data?.rate_limit_reset_credits),
};
}

Expand Down
29 changes: 24 additions & 5 deletions src/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
// operator/OAuth-derived, but they still never reach innerHTML.

import { createHash } from 'node:crypto';
import { UNAVAILABLE_TEXT } from './status-renderer.js';
import { UNAVAILABLE_TEXT, RESET_CREDIT_MAX_AGE_MS } from './status-renderer.js';

export function renderDashboardHtml() {
return PAGE;
Expand Down Expand Up @@ -89,7 +89,13 @@ export function providerLabel(provider) {
return provider || 'Unknown';
}

export function accountBadges(account, current, currentAccounts) {
/**
* @param {Record<string, any>|null|undefined} account
* @param {string|null} [current]
* @param {Record<string, string>|null} [currentAccounts]
* @param {number} [now] ms epoch a reset-credit reading's age is measured from
*/
export function accountBadges(account, current, currentAccounts, now) {
var a = account || {};
var isCurrent = currentAccounts
? currentAccounts[a.provider] === a.name
Expand All @@ -106,6 +112,18 @@ export function accountBadges(account, current, currentAccounts) {
badges.push({ cls: status, text: status });
if (recent) badges.push({ cls: 'sessions', text: recent + ' recent' });
if (known > recent) badges.push({ cls: 'sessions known', text: known + ' known' });
// Free Codex rate-limit reset credits this account holds — what it could
// spend to undo an exhausted window rather than wait one out. The count is
// the account's holdings, not what upstream would apply this instant.
// A reading past RESET_CREDIT_MAX_AGE_MS is dropped, as it is on the status
// screen and the TUI row: only the usage probe refreshes the count, so an old
// one may describe a credit that has since been redeemed or has expired.
var reading = (a.quota || {}).resetCredits || {};
var credits = reading.available;
var stale = Number.isFinite(reading.seenAt) && (now == null ? Date.now() : now) - reading.seenAt > RESET_CREDIT_MAX_AGE_MS;
if (Number.isFinite(credits) && credits > 0 && !stale) {
badges.push({ cls: 'meta', text: credits + ' reset credit' + (credits === 1 ? '' : 's') });
}
return badges;
}

Expand Down Expand Up @@ -344,9 +362,10 @@ const SHARED_HELPERS = [
switchRequest, switchOutcome, routeRows, problems,
].map(fn => fn.toString()).join('\n\n');

// The threshold rides along: `problems` closes over it, so a page without it
// would ReferenceError on first render.
const SHARED_CONSTS = `var STARVED_MIN = ${STARVED_MIN};\nvar STARVED_LIST_MAX = ${STARVED_LIST_MAX};`;
// The constants ride along: `problems` closes over the thresholds and
// `accountBadges` over the reset-credit cut-off, so a page without them would
// ReferenceError on first render.
const SHARED_CONSTS = `var STARVED_MIN = ${STARVED_MIN};\nvar STARVED_LIST_MAX = ${STARVED_LIST_MAX};\nvar RESET_CREDIT_MAX_AGE_MS = ${RESET_CREDIT_MAX_AGE_MS};`;

const PAGE = `<!doctype html>
<html lang="en">
Expand Down
60 changes: 60 additions & 0 deletions src/status-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export function renderStatus(status, { color = process.stdout.isTTY, now = Date.
if (why) lines.push(` ${why}`);
const spend = spendLine(account, paint);
if (spend) lines.push(` ${spend}`);
const resetCredits = resetCreditLine(account, paint, now);
if (resetCredits) lines.push(` ${resetCredits}`);
lines.push(` ${paint.dim('Usage'.padEnd(8))} ${formatUsage(account.usage, now)}`);
lines.push(` ${paint.dim('Probe'.padEnd(8))} ${formatAccountProbe(nameText(account.name), probe, now, paint)}`);
const adaptive = adaptiveFor(status, nameText(account.name));
Expand Down Expand Up @@ -167,6 +169,64 @@ export function spendLine(account, paint) {
return `${paint.dim('Spend'.padEnd(8))} ${paint.yellow(`${amount} spent this month, ${why}`)}`;
}

/**
* The free-reset-credit line, or null when this account holds none.
*
* Its own line rather than another bar: every bar above measures an allowance
* running down, while this counts something the account can spend to put one
* back. `applicable` is named alongside because a credit upstream would
* currently decline to apply is a different situation from one it would honour,
* and the count on its own reads the same either way.
*
* The reading's age rides along ("as of 3h ago"): the count is only refreshed
* by the usage probe, which is off by default, so how old it is says how much
* it is worth. Past RESET_CREDIT_MAX_AGE_MS the line is dropped altogether.
*
* @param {Record<string, any>|null|undefined} account a row of the status payload
* @param {ReturnType<typeof colors>} paint
* @param {number} [now] ms epoch the age is measured from
*/
export function resetCreditLine(account, paint, now = Date.now()) {
const credits = account?.quota?.resetCredits;
const available = heldResetCredits(account?.quota, now);
if (!available) return null;
const noun = `free rate-limit reset ${available === 1 ? 'credit' : 'credits'}`;
const notes = [];
if (credits.applicable === 0) notes.push('none applicable to a window right now');
if (Number.isFinite(credits.seenAt)) notes.push(`as of ${formatAgo(Math.min(credits.seenAt, now), now)}`);
const note = notes.length ? ` — ${notes.join(', ')}` : '';
return `${paint.dim('Reset'.padEnd(8))} ${paint.cyan(`${available} ${noun}`)}${paint.gray(note)}`;
}

// How long a reset-credit reading is worth showing. Nothing refreshes the count
// but the usage probe, and the probe is off by default, so a credit that was
// redeemed or expired would otherwise stay on screen indefinitely. A week is the
// longest Codex window: past it, every window the credit could have reset has
// reset on its own, and the reading describes a situation that is gone.
export const RESET_CREDIT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;

/**
* How many reset credits to REPORT for this quota: the held count while the
* reading is fresh, 0 once it is older than RESET_CREDIT_MAX_AGE_MS or states
* no positive count. One rule for the status screen and the TUI row, so the two
* cannot disagree about whether a credit is there (the dashboard page applies
* the same cut-off in its own serialized helper).
*
* A reading with no `seenAt` is shown: its age is unknown rather than old, and
* every reading the proxy stores itself is stamped.
*
* @param {Record<string, any>|null|undefined} quota
* @param {number} [now] ms epoch the age is measured from
* @returns {number}
*/
export function heldResetCredits(quota, now = Date.now()) {
const credits = quota?.resetCredits;
const available = credits?.available;
if (!Number.isFinite(available) || available <= 0) return 0;
if (Number.isFinite(credits.seenAt) && now - credits.seenAt > RESET_CREDIT_MAX_AGE_MS) return 0;
return available;
}

export function unavailableLine(account, paint) {
const reason = account?.unavailable;
if (!reason) return null;
Expand Down
29 changes: 28 additions & 1 deletion src/tui.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import { configIndexFor, managerAccountFor, markAccountRemoved } from './account-pairing.js';
import { PROVIDERS, providerOf } from './provider.js';
import { mintAccountId } from './account-id.js';
import { formatPercent } from './status-renderer.js';
import { formatPercent, heldResetCredits } from './status-renderer.js';
import { resolveMaxUsage } from './model.js';
import { parseProxyUrl, proxyToUrl, describeProxy, describeSelfProxy, resolveUpstreamProxy, setUpstreamProxy, getUpstreamProxy } from './upstream-proxy.js';
import { sanitizeText, safeLine } from './safe-text.js';
Expand Down Expand Up @@ -249,6 +249,29 @@ export function spendTag(quota) {
return (spend.usedMinor || 0) > 0 ? '$!' : '$';
}

/**
* Short row tag for an account holding free Codex rate-limit reset credits:
* `RC1` for one, `RC2` for two, '' for none. ASCII for the same reason spendTag
* is — the row is budgeted to the cell, and a glyph whose width varies by
* terminal pushes it past the edge.
*
* The number is what the account HOLDS. It is deliberately not the number that
* could be redeemed right now: only the account's own credit rows say whether a
* given credit is supported by the plan, and they cost a request nobody should
* make to draw a badge.
*
* A reading older than RESET_CREDIT_MAX_AGE_MS draws nothing: the row has no
* room to say how old the count is, so past the point where it stops being
* worth anything the honest tag is no tag.
*
* @param {Record<string, any>|null|undefined} quota
* @param {number} [now] ms epoch the reading's age is measured from
*/
export function resetCreditTag(quota, now = Date.now()) {
const available = heldResetCredits(quota, now);
return available ? `RC${available}` : '';
}

export function blockedFamilies(quota, threshold) {
const at = typeof threshold === 'function' ? threshold : () => threshold;
const out = [];
Expand Down Expand Up @@ -1841,6 +1864,10 @@ export class TUI {
// the bars. Red once real money has moved, yellow while it only could.
const money = spendTag(q);
if (money) line += ` ${(money === '$!' ? red : yellow)(money)}`;
// Free reset credits sit beside the money tag: both report what this
// account holds in reserve rather than what it is currently spending.
const credits = resetCreditTag(q);
if (credits) line += ` ${cyan(credits)}`;
return line;
}

Expand Down
Loading
Loading