From 73bc8f88493a114ea7014e2591c8d121b72ec6a3 Mon Sep 17 00:00:00 2001 From: luogang Date: Sat, 17 Oct 2026 20:04:01 +0800 Subject: [PATCH 1/2] feat(codex): keep GPT slots in rotation on spendable ChatGPT credits A slot whose 5h/7d window is spent but whose ChatGPT credits are still spendable now stays schedulable instead of parking until the window reset. - Parse `credits` from /wham/usage and `x-codex-credits-*` reply headers (same contract as the Codex CLI; balance kept as a string) into vm.codex.credits, separate from reset_credits, and project it to the pool summary (`codex_credits`) and panel responses. - One predicate, hasSpendableCodexCredits (fresh within 10 min and has_credits or unlimited), waives only window caps in every quota gate: codexQuotaPark, evaluateOpenAIQuotaGate, codexQuotaWindowReason, evaluateCodexQuotaSchedule, and the park derived on successful replies. - An upstream 429 still parks the slot and drops the credits reading; an explicit park, operator disable, auth and proxy blocks are unchanged. --- src/lib/admin/panel-api.mjs | 2 + src/lib/oauth/openai-quota.mjs | 15 +- src/lib/pool/codex-slot-pool.mjs | 25 ++- src/lib/pool/openai-quota-policy.mjs | 9 +- src/lib/protocol/codex-usage.mjs | 70 +++++- src/lib/protocol/handle-codex.mjs | 9 +- src/lib/vm/codex-slot.mjs | 13 +- src/lib/vm/vm-registry.mjs | 11 +- test/unit/codex-credits.test.mjs | 310 +++++++++++++++++++++++++++ 9 files changed, 439 insertions(+), 25 deletions(-) create mode 100644 test/unit/codex-credits.test.mjs diff --git a/src/lib/admin/panel-api.mjs b/src/lib/admin/panel-api.mjs index f416f9bbf..7cf29d47b 100644 --- a/src/lib/admin/panel-api.mjs +++ b/src/lib/admin/panel-api.mjs @@ -1068,6 +1068,7 @@ export async function buildProbeOne({ }, quota: result.quota, reset_credits: result.reset_credits || null, + credits: result.credits || null, windows: result.windows || [], probed_at: result.fetched_at || new Date().toISOString(), ok: true, @@ -1932,6 +1933,7 @@ function enrichVm(v, accountQuota, active, extras = {}) { status_7d_oi: isCodex ? null : q.status_7d_oi, codex_usage: v.codex_usage || null, reset_credits: isCodex ? v.reset_credits || v.codex?.reset_credits || null : null, + codex_credits: isCodex ? v.codex_credits || v.codex?.credits || null : null, claude_reset_credits: isCodex ? null : v.claude_reset_credits || null, plan_type: isCodex ? v.plan_type || null : null, ...(q.weekly_split ? { weekly_split: q.weekly_split } : {}), diff --git a/src/lib/oauth/openai-quota.mjs b/src/lib/oauth/openai-quota.mjs index 65e67a497..91451fc0e 100644 --- a/src/lib/oauth/openai-quota.mjs +++ b/src/lib/oauth/openai-quota.mjs @@ -9,7 +9,12 @@ import { isCodexVm } from '../vm/vm-kind.mjs' import { isLocalEgressProxy } from '../vm/egress.mjs' import { hostProxyUrlForVm } from '../vm/slot-host.mjs' import { readCodexAccounts, upsertCodexAccount, persistCodexQuotaSnapshot } from '../vm/codex-slot.mjs' -import { buildCodexUsageView, extraToCodexSnapshot, normalizeCodexLimits } from '../protocol/codex-usage.mjs' +import { + buildCodexUsageView, + extraToCodexSnapshot, + normalizeCodexCredits, + normalizeCodexLimits, +} from '../protocol/codex-usage.mjs' import { CODEX_OAUTH_ORIGINATOR, makeProxyFetch, refreshCodexAccessToken } from '../protocol/codex-models.mjs' import { openaiIdentityFromJwt } from './openai-jwt.mjs' @@ -301,13 +306,14 @@ async function rotateIfNeeded(slot, projectRoot, vmId, fetchImpl) { return { ...slot, access: tok.access_token, refresh: tok.refresh_token || slot.refresh } } -function publicUsage(extra, resetCredits, usagePayload = {}) { +function publicUsage(extra, resetCredits, credits, usagePayload = {}) { const view = buildCodexUsageView(extraToCodexSnapshot(extra)) return { usage: view, quota: view.quota, windows: view.windows, reset_credits: resetCredits, + credits, plan_type: usagePayload.plan_type || usagePayload.planType || null, email: usagePayload.email || null, } @@ -375,8 +381,10 @@ async function queryUpstream(slot, { fetchImpl, rotate = true, projectRoot, vmId detailCredits, ) const persistable = resetCredits.available_count <= 0 || resetCredits.credits.length > 0 ? resetCredits : null + const credits = normalizeCodexCredits(pack.usage.payload?.credits) persistCodexQuotaSnapshot(projectRoot, vmId, { extra, + credits, planType: pack.usage.payload?.plan_type || pack.usage.payload?.planType, ...(persistable ? { resetCredits: persistable } : {}), }) @@ -385,7 +393,7 @@ async function queryUpstream(slot, { fetchImpl, rotate = true, projectRoot, vmId }) return { ok: true, - ...publicUsage(extra, persistable || resetCredits, pack.usage.payload || {}), + ...publicUsage(extra, persistable || resetCredits, credits, pack.usage.payload || {}), fetched_at: resetCredits.fetched_at, warning: persistable ? undefined : 'reset_credit_cache_refresh_failed', } @@ -447,6 +455,7 @@ export async function resetOpenaiQuota({ projectRoot, vmId, fetchImpl, rotate = code: res.payload?.code || 'ok', windows_reset: int(res.payload?.windows_reset ?? res.payload?.windowsReset) || 0, reset_credits: null, + credits: null, usage: null, quota: null, windows: [], diff --git a/src/lib/pool/codex-slot-pool.mjs b/src/lib/pool/codex-slot-pool.mjs index 1cfecd21b..4e67c5252 100644 --- a/src/lib/pool/codex-slot-pool.mjs +++ b/src/lib/pool/codex-slot-pool.mjs @@ -4,7 +4,12 @@ * quota/auth before any SSE byte is committed. */ import { isCodexVm } from '../vm/vm-kind.mjs' -import { extraToCodexSnapshot, normalizeCodexLimits, codexQuotaPark } from '../protocol/codex-usage.mjs' +import { + extraToCodexSnapshot, + normalizeCodexLimits, + codexQuotaPark, + hasSpendableCodexCredits, +} from '../protocol/codex-usage.mjs' import { isLeftoverQuotaScheduleOff, isQuotaWindowReason } from './availability.mjs' import { orderOpenAIAccounts } from './openai-account-selector.mjs' import { bumpOpenAICursor, openAIRuntimeSignals, readOpenAICursor } from './openai-account-runtime.mjs' @@ -33,8 +38,7 @@ export function isCodexSlotParked(vm, now = Date.now(), policy = null) { } function extraPark(vm, now) { - const extra = extraFromSummary(vm) - return codexQuotaPark(extra, now) + return codexQuotaPark(extraFromSummary(vm), now, { waiveWindowCaps: hasSpendableCodexCredits(vm, now) }) } function extraFromSummary(vm) { @@ -351,26 +355,27 @@ export function isCodexFailoverError(result) { export function codexQuotaWindowReason(vm, now = Date.now()) { const extra = extraFromSummary(vm) + const waived = hasSpendableCodexCredits(vm, now) const limits = normalizeCodexLimits(extraToCodexSnapshot(extra)) const used5 = Number(limits.used_5h_percent) const used7 = Number(limits.used_7d_percent) const reset5 = Date.parse(limits.reset_5h_at || '') const reset7 = Date.parse(limits.reset_7d_at || '') - const live5 = Number.isFinite(used5) && used5 >= 100 && (!Number.isFinite(reset5) || reset5 > now) - const live7 = Number.isFinite(used7) && used7 >= 100 && (!Number.isFinite(reset7) || reset7 > now) + const live5 = !waived && Number.isFinite(used5) && used5 >= 100 && (!Number.isFinite(reset5) || reset5 > now) + const live7 = !waived && Number.isFinite(used7) && used7 >= 100 && (!Number.isFinite(reset7) || reset7 > now) if (live5) return 'quota_5h_header' if (live7) return 'quota_7d_header' - if (codexQuotaPark(extra, now).limited) return 'quota_5h_header' + if (codexQuotaPark(extra, now, { waiveWindowCaps: waived }).limited) return 'quota_5h_header' return null } function codexRestrictionUntil(vm, now) { const extra = extraFromSummary(vm) const limits = normalizeCodexLimits(extraToCodexSnapshot(extra)) - const reset5 = Date.parse(limits.reset_5h_at || '') - const reset7 = Date.parse(limits.reset_7d_at || '') - const park = Date.parse(extra.codex_limited_until || '') - const futures = [reset5, reset7, park].filter((value) => Number.isFinite(value) && value > now) + const windowResets = hasSpendableCodexCredits(vm, now) ? [] : [limits.reset_5h_at, limits.reset_7d_at] + const futures = [...windowResets, extra.codex_limited_until] + .map((value) => Date.parse(value || '')) + .filter((value) => Number.isFinite(value) && value > now) return futures.length ? Math.min(...futures) : now + 5 * 60_000 } diff --git a/src/lib/pool/openai-quota-policy.mjs b/src/lib/pool/openai-quota-policy.mjs index dd0e0fbaf..784d32f4b 100644 --- a/src/lib/pool/openai-quota-policy.mjs +++ b/src/lib/pool/openai-quota-policy.mjs @@ -5,7 +5,7 @@ * resolver is used by config, admission, persistence and panel projections so * a value cannot silently acquire a different meaning at another boundary. */ -import { buildCodexUsageView } from '../protocol/codex-usage.mjs' +import { buildCodexUsageView, hasSpendableCodexCredits } from '../protocol/codex-usage.mjs' export const OPENAI_QUOTA_DEFAULTS = Object.freeze({ limit_5h: 1, @@ -115,7 +115,10 @@ function resetIsLive(value, now) { return Number.isFinite(at) ? at > now : null } +const OPEN_GATE = Object.freeze({ limited: false, reason: null, window: null, used: null, limit: null, until: null }) + export function evaluateOpenAIQuotaGate(vm, policy = OPENAI_QUOTA_DEFAULTS, now = Date.now()) { + if (hasSpendableCodexCredits(vm, now)) return OPEN_GATE const effective = normalizeOpenAIQuotaPolicy(policy) const extra = vm?.codex?.extra || vm?.codex_extra || {} const usage = Object.keys(extra).length @@ -133,9 +136,7 @@ export function evaluateOpenAIQuotaGate(vm, policy = OPENAI_QUOTA_DEFAULTS, now if (Number.isFinite(at) && at > now) until = until == null ? at : Math.min(until, at) first ||= { limited: true, reason: `quota_${key}_local`, window: key, used, limit: effective[`limit_${key}`] } } - return first - ? { ...first, until } - : { limited: false, reason: null, window: null, used: null, limit: null, until: null } + return first ? { ...first, until } : OPEN_GATE } export function effectiveOpenAIPolicy(vm, globalPolicy = OPENAI_QUOTA_DEFAULTS) { diff --git a/src/lib/protocol/codex-usage.mjs b/src/lib/protocol/codex-usage.mjs index 9468296e1..8a9c77639 100644 --- a/src/lib/protocol/codex-usage.mjs +++ b/src/lib/protocol/codex-usage.mjs @@ -66,13 +66,14 @@ export const CODEX_DEFAULT_PARK_MS = 60_000 /** * Park verdict for a slot: a window at its cap with a future reset, or an * explicit `usage_limit_reached`, means the credential is spent until the - * soonest window reset. + * soonest window reset. `waiveWindowCaps` (spendable credits) ignores the + * window caps; an explicit park still holds. */ -export function codexQuotaPark(extra = {}, now = Date.now()) { +export function codexQuotaPark(extra = {}, now = Date.now(), { waiveWindowCaps = false } = {}) { const limits = normalizeCodexLimits(extraToCodexSnapshot(extra)) let until = null let liveCap = false - for (const key of ['5h', '7d']) { + for (const key of waiveWindowCaps ? [] : ['5h', '7d']) { const used = num(limits[`used_${key}_percent`]) const resetMs = Date.parse(limits[`reset_${key}_at`] || '') if (used == null || used < 100) continue @@ -87,6 +88,69 @@ export function codexQuotaPark(extra = {}, now = Date.now()) { return { limited: until != null, until } } +/** + * How long a credits reading may waive the window caps. Each Responses reply + * refreshes it, so only an idle or parked slot ages out. + */ +export const CODEX_CREDITS_TTL_MS = 10 * 60_000 + +function flag(value) { + if (typeof value === 'boolean') return value + const text = String(value ?? '') + .trim() + .toLowerCase() + if (text === 'true' || text === '1') return true + if (text === 'false' || text === '0') return false + return null +} + +function creditBalance(value) { + if (typeof value === 'string') return value.trim() || null + return typeof value === 'number' && Number.isFinite(value) ? String(value) : null +} + +/** + * ChatGPT credits (`/wham/usage` `credits`, or `x-codex-credits-*` headers). + * Both flags are required, as in the Codex CLI. `balance` stays a string so a + * long decimal is never rounded through a float. + */ +export function normalizeCodexCredits(raw, now = Date.now()) { + if (!raw || typeof raw !== 'object') return null + const hasCredits = flag(raw.has_credits ?? raw.hasCredits) + const unlimited = flag(raw.unlimited) + if (hasCredits == null || unlimited == null) return null + return { + has_credits: hasCredits, + unlimited, + balance: creditBalance(raw.balance), + fetched_at: new Date(now).toISOString(), + } +} + +export function codexCreditsFromHeaders(headers = {}, now = Date.now()) { + const h = lowerHeaders(headers) + if (!h) return null + return normalizeCodexCredits( + { + has_credits: h['x-codex-credits-has-credits'], + unlimited: h['x-codex-credits-unlimited'], + balance: h['x-codex-credits-balance'], + }, + now, + ) +} + +/** + * A fresh reading that says the account can still buy past its 5h/7d windows. + * It only earns the slot another try; the upstream 429 stays the authority. + */ +export function hasSpendableCodexCredits(vm, now = Date.now()) { + const credits = vm?.codex?.credits ?? vm?.codex_credits + const fetchedAt = Date.parse(credits?.fetched_at || '') + if (!Number.isFinite(fetchedAt) || now - fetchedAt > CODEX_CREDITS_TTL_MS) return false + return credits.unlimited === true || credits.has_credits === true +} + function lowerHeaders(headers) { if (!headers || typeof headers !== 'object') return null const out = {} diff --git a/src/lib/protocol/handle-codex.mjs b/src/lib/protocol/handle-codex.mjs index 019023662..a72d0d5c1 100644 --- a/src/lib/protocol/handle-codex.mjs +++ b/src/lib/protocol/handle-codex.mjs @@ -18,7 +18,12 @@ import { isToolArgumentsError, toCodexResponses, } from './codex-convert.mjs' -import { extraFromCodexHeaders, codexQuotaPark, CODEX_DEFAULT_PARK_MS } from './codex-usage.mjs' +import { + extraFromCodexHeaders, + codexCreditsFromHeaders, + codexQuotaPark, + CODEX_DEFAULT_PARK_MS, +} from './codex-usage.mjs' import { extractOpenaiUsage, openaiAnthropicUsageFromExtract, disjointOpenaiUsage } from './openai-usage.mjs' import { streamCodexKernel } from '../transport/codex-kernel-client.mjs' import { ensureCodexKernel, writeCodexKernelConfig } from '../transport/codex-kernel-supervisor.mjs' @@ -228,7 +233,7 @@ function ingestCodexHop(projectRoot, vmId, result, now = Date.now(), policy = nu limitedUntil = park.until || now + CODEX_DEFAULT_PARK_MS quotaExceeded = String(result?.body?.error?.code || result?.error_code || '') === 'usage_limit_reached' } - if (!extra && !limitedUntil) return null + if (!extra && !limitedUntil && !codexCreditsFromHeaders(headers, now)) return null return persistCodexUsage(projectRoot, vmId, { headers, extra, limitedUntil, quotaExceeded, now, policy }) } diff --git a/src/lib/vm/codex-slot.mjs b/src/lib/vm/codex-slot.mjs index 984613a2e..6e3ec144f 100644 --- a/src/lib/vm/codex-slot.mjs +++ b/src/lib/vm/codex-slot.mjs @@ -5,7 +5,7 @@ import fs from 'node:fs' import path from 'node:path' import { atomicWriteJson, readJsonCached } from './vm-file.mjs' import { isCodexVm } from '../protocol/codex-route.mjs' -import { buildCodexUsageView, extraToCodexSnapshot } from '../protocol/codex-usage.mjs' +import { buildCodexUsageView, extraToCodexSnapshot, hasSpendableCodexCredits } from '../protocol/codex-usage.mjs' import { openaiIdentityFromOauthTokens } from '../oauth/openai-jwt.mjs' export { isCodexVm } @@ -284,7 +284,7 @@ export function upsertCodexAccount(projectRoot, vmId, patch = {}, { replaceCrede return cur } -export function persistCodexQuotaSnapshot(projectRoot, vmId, { extra, resetCredits, planType } = {}) { +export function persistCodexQuotaSnapshot(projectRoot, vmId, { extra, resetCredits, credits, planType } = {}) { if (!projectRoot || !vmId) throw new Error('projectRoot and vmId required') const file = path.join(projectRoot, 'vms', `${vmId}.json`) let vm @@ -299,6 +299,14 @@ export function persistCodexQuotaSnapshot(projectRoot, vmId, { extra, resetCredi vm.codex.extra = { ...(vm.codex.extra || {}), ...extra } vm.codex.usage = buildCodexUsageView(extraToCodexSnapshot(vm.codex.extra)) } + if (credits) { + vm.codex.credits = credits + // A fresh read that credits are spendable supersedes an older park; the next 429 parks again. + if (hasSpendableCodexCredits(vm) && vm.codex.extra?.codex_limited_until) { + vm.codex.extra = { ...vm.codex.extra } + delete vm.codex.extra.codex_limited_until + } + } const plan = String(planType || '').trim() if (plan) vm.codex.plan_type = plan if (resetCredits && typeof resetCredits === 'object') { @@ -336,6 +344,7 @@ export function summarizeCodexSlot(projectRoot, vm = {}) { account_count: accounts.length, usage, reset_credits: vm.codex?.reset_credits || null, + credits: vm.codex?.credits || null, plan_type: vm.codex?.plan_type || null, } } diff --git a/src/lib/vm/vm-registry.mjs b/src/lib/vm/vm-registry.mjs index d74c232f9..631dbd42f 100644 --- a/src/lib/vm/vm-registry.mjs +++ b/src/lib/vm/vm-registry.mjs @@ -26,7 +26,9 @@ import { extraFromCodexHeaders, extraToCodexSnapshot, buildCodexUsageView, + codexCreditsFromHeaders, codexQuotaPark, + hasSpendableCodexCredits, } from '../protocol/codex-usage.mjs' import { summarizeCodexSlot } from './codex-slot.mjs' @@ -164,6 +166,7 @@ export function summarizeVm(vm, projectRoot = null, routing = null) { } : null, reset_credits: codex?.reset_credits || null, + codex_credits: codex?.credits || null, plan_type: codex?.plan_type || null, claude_reset_credits: kind.kind === 'codex' ? null : vm.claude_reset_credits || null, } @@ -223,8 +226,14 @@ export function persistCodexUsage( const merged = { ...cached, ...(fromHeaders || {}), ...(extra || {}) } if (limitedUntil) { merged.codex_limited_until = new Date(limitedUntil).toISOString() + // The refusal outranks any credits reading until a newer one arrives. + delete vm.codex.credits } else { - const parked = codexQuotaPark({ ...merged, codex_limited_until: null }, now) + const credits = codexCreditsFromHeaders(headers || {}, now) + if (credits) vm.codex.credits = credits + const parked = codexQuotaPark({ ...merged, codex_limited_until: null }, now, { + waiveWindowCaps: hasSpendableCodexCredits(vm, now), + }) if (parked.limited) merged.codex_limited_until = new Date(parked.until).toISOString() else delete merged.codex_limited_until } diff --git a/test/unit/codex-credits.test.mjs b/test/unit/codex-credits.test.mjs new file mode 100644 index 000000000..653d295fc --- /dev/null +++ b/test/unit/codex-credits.test.mjs @@ -0,0 +1,310 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { handleCodexProtocol } from '../../src/lib/protocol/handle-codex.mjs' +import { getVm, listVms } from '../../src/lib/vm/vm-registry.mjs' +import { persistCodexQuotaSnapshot } from '../../src/lib/vm/codex-slot.mjs' +import { queryOpenaiQuota, CHATGPT_USAGE_URL, CHATGPT_RESET_CREDITS_URL } from '../../src/lib/oauth/openai-quota.mjs' +import { + CODEX_CREDITS_TTL_MS, + codexCreditsFromHeaders, + hasSpendableCodexCredits, + normalizeCodexCredits, +} from '../../src/lib/protocol/codex-usage.mjs' +import { + codexAccountStatus, + codexQuotaWindowReason, + evaluateCodexQuotaSchedule, + isCodexSlotParked, + pickCodexSlots, +} from '../../src/lib/pool/codex-slot-pool.mjs' +import { evaluateOpenAIQuotaGate } from '../../src/lib/pool/openai-quota-policy.mjs' + +const NOW = Date.parse('2026-10-17T12:00:00Z') +const WEEK_RESET = new Date(NOW + 3 * 86_400_000).toISOString() + +// 7d window spent, 5h window healthy: only credits can keep this slot serving. +const WEEK_SPENT = { + codex_primary_used_percent: 100, + codex_primary_window_minutes: 10080, + codex_primary_reset_at: WEEK_RESET, + codex_secondary_used_percent: 20, + codex_secondary_window_minutes: 300, + codex_secondary_reset_at: new Date(NOW + 3_600_000).toISOString(), +} + +function credits(patch = {}, fetchedAt = NOW) { + return { + has_credits: true, + unlimited: false, + balance: '12.5', + fetched_at: new Date(fetchedAt).toISOString(), + ...patch, + } +} + +function slot(codex = {}) { + return { id: 'vm-a', platform: 'openai', family: 'codex', status: 'running', has_token: true, codex } +} + +function fixture(t, codex = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-credits-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + fs.mkdirSync(path.join(root, 'vms', 'vm-a'), { recursive: true }) + fs.writeFileSync( + path.join(root, 'vms', 'vm-a.json'), + JSON.stringify({ + id: 'vm-a', + platform: 'openai', + family: 'codex', + status: 'running', + schedulable: true, + proxy: { url: 'socks5h://127.0.0.1:1080' }, + codex: { has_access: true, has_refresh: true, chatgpt_account_id: 'acc-1', ...codex }, + }), + ) + fs.writeFileSync( + path.join(root, 'vms', 'vm-a', 'codex-credentials.json'), + JSON.stringify({ + accounts: [{ id: 'codex', access_token: 'at', refresh_token: 'rt', chatgpt_account_id: 'acc-1' }], + }), + ) + return root +} + +async function hop(root, { status = 200, headers = {}, code = 'usage_limit_reached' } = {}) { + await handleCodexProtocol({ + req: { headers: {}, apiKeyKind: 'user' }, + res: { headersSent: false, write() {}, end() {} }, + protocol: 'openai.responses', + ctx: { body: { model: 'gpt-5.4', input: 'fixture', stream: false } }, + inbound: { stream: false }, + logBag: {}, + stats: { errors: 0, requests: 0, by_route: {} }, + json: (_res, _status, body) => body, + writeSSEHeaders() {}, + routing: {}, + projectRoot: root, + ops: { + writeCodexKernelConfig() {}, + ensureCodexKernel: async () => ({ ok: true }), + streamCodexKernel: async () => ({ + ok: status === 200, + status, + committed: false, + headers, + body: status === 200 ? { id: 'fixture-response' } : { error: { code, type: 'api_error' } }, + }), + }, + }) +} + +function usageFetch(payload) { + return async (url) => { + if (String(url).startsWith(CHATGPT_USAGE_URL)) return { status: 200, json: async () => payload } + if (String(url).startsWith(CHATGPT_RESET_CREDITS_URL)) return { status: 200, json: async () => [] } + throw new Error(`unexpected ${url}`) + } +} + +const WEEK_SPENT_RATE_LIMIT = { + primary_window: { used_percent: 100, limit_window_seconds: 604800, reset_after_seconds: 3 * 86_400 }, + secondary_window: { used_percent: 20, limit_window_seconds: 18000, reset_after_seconds: 3600 }, +} + +const WEEK_SPENT_HEADERS = { + 'x-codex-primary-used-percent': '100', + 'x-codex-primary-window-minutes': '10080', + 'x-codex-primary-reset-after-seconds': String(3 * 86_400), + 'x-codex-secondary-used-percent': '20', + 'x-codex-secondary-window-minutes': '300', + 'x-codex-secondary-reset-after-seconds': '3600', +} + +test('credits normalize like the Codex CLI and keep the balance as a string', () => { + const long = '123456789012345678901234.000000000000000000000001' + assert.deepEqual(normalizeCodexCredits({ has_credits: true, unlimited: false, balance: long }, NOW), { + has_credits: true, + unlimited: false, + balance: long, + fetched_at: new Date(NOW).toISOString(), + }) + assert.equal(normalizeCodexCredits({ has_credits: true, unlimited: false, balance: ' ' }, NOW).balance, null) + assert.equal(normalizeCodexCredits({ has_credits: true }, NOW), null, 'both flags are required') + assert.equal(normalizeCodexCredits({ has_credits: 'maybe', unlimited: false }, NOW), null) + assert.equal(normalizeCodexCredits(null, NOW), null) + + const fromHeaders = codexCreditsFromHeaders( + { 'X-Codex-Credits-Has-Credits': '1', 'x-codex-credits-unlimited': 'FALSE', 'x-codex-credits-balance': ' 7.25 ' }, + NOW, + ) + assert.deepEqual(fromHeaders, credits({ balance: '7.25' })) + assert.equal(codexCreditsFromHeaders({ 'x-codex-credits-has-credits': 'true' }, NOW), null) +}) + +test('spendable means fresh and either has_credits or unlimited; a hidden balance still counts', () => { + assert.equal(hasSpendableCodexCredits(slot({ credits: credits({ balance: null }) }), NOW), true) + assert.equal(hasSpendableCodexCredits(slot({ credits: credits({ has_credits: false, unlimited: true }) }), NOW), true) + assert.equal(hasSpendableCodexCredits(slot({ credits: credits({ has_credits: false, balance: '0' }) }), NOW), false) + assert.equal(hasSpendableCodexCredits({ codex_credits: credits() }, NOW), true, 'summary projection') + assert.equal(hasSpendableCodexCredits(slot({ credits: credits({}, NOW - CODEX_CREDITS_TTL_MS) }), NOW), true) + assert.equal(hasSpendableCodexCredits(slot({ credits: credits({}, NOW - CODEX_CREDITS_TTL_MS - 1) }), NOW), false) + assert.equal(hasSpendableCodexCredits(slot({ credits: credits({ fetched_at: 'garbage' }) }), NOW), false) + assert.equal(hasSpendableCodexCredits(slot(), NOW), false) +}) + +test('a spent window with fresh credits passes every local quota gate', () => { + const vm = slot({ extra: WEEK_SPENT, credits: credits() }) + for (const policy of [null, { limit_5h: 1, limit_7d: 1 }, { limit_5h: 0.9, limit_7d: 0.9 }]) { + assert.equal(isCodexSlotParked(vm, NOW, policy), false) + assert.equal(evaluateOpenAIQuotaGate(vm, policy || undefined, NOW).limited, false) + assert.equal(codexAccountStatus(vm, NOW, policy), 'normal') + assert.equal(evaluateCodexQuotaSchedule(vm, NOW, policy).reason, null) + } + assert.equal(codexQuotaWindowReason(vm, NOW), null) + assert.deepEqual(pickCodexSlots([vm], { now: NOW }).ready, ['vm-a']) +}) + +test('credits never reopen an operator-disabled or credential-less slot', () => { + const vm = slot({ extra: WEEK_SPENT, credits: credits() }) + assert.equal(codexAccountStatus({ ...vm, schedulable: false, schedule_manual: true }, NOW), 'disabled') + assert.equal(codexAccountStatus({ ...vm, status: 'disabled' }, NOW), 'disabled') + assert.equal(codexAccountStatus({ ...vm, has_token: false }, NOW), 'error') + assert.equal(pickCodexSlots([{ ...vm, has_token: false }], { now: NOW }).error, 'no_codex_vm') +}) + +test('a fresh reading clears a restriction that the spent window wrote earlier', () => { + const restricted = { temp_unschedulable_reason: 'quota_7d_header', temp_unschedulable_until: Date.parse(WEEK_RESET) } + assert.equal(evaluateCodexQuotaSchedule({ ...slot({ extra: WEEK_SPENT }), ...restricted }, NOW).action, 'restrict') + assert.equal( + evaluateCodexQuotaSchedule({ ...slot({ extra: WEEK_SPENT, credits: credits() }), ...restricted }, NOW).action, + 'clear', + ) +}) + +test('no credits, stale credits, or an explicit park keep the existing parking', () => { + const cases = { + missing: slot({ extra: WEEK_SPENT }), + exhausted: slot({ extra: WEEK_SPENT, credits: credits({ has_credits: false, unlimited: false, balance: '0' }) }), + stale: slot({ extra: WEEK_SPENT, credits: credits({}, NOW - CODEX_CREDITS_TTL_MS - 1) }), + } + for (const [name, vm] of Object.entries(cases)) { + assert.equal(isCodexSlotParked(vm, NOW), true, name) + assert.equal(evaluateOpenAIQuotaGate(vm, undefined, NOW).reason, 'quota_7d_local', name) + assert.equal(codexQuotaWindowReason(vm, NOW), 'quota_7d_header', name) + assert.equal(codexAccountStatus(vm, NOW), 'quota_exhausted', name) + assert.deepEqual(pickCodexSlots([vm], { now: NOW }).parked, ['vm-a'], name) + } + + const parkedUntil = new Date(NOW + 60_000).toISOString() + const refused = slot({ extra: { ...WEEK_SPENT, codex_limited_until: parkedUntil }, credits: credits() }) + assert.equal(isCodexSlotParked(refused, NOW), true, 'an explicit park outranks credits') + assert.equal(codexQuotaWindowReason(refused, NOW), 'quota_5h_header') + assert.equal(evaluateCodexQuotaSchedule(refused, NOW).until, Date.parse(parkedUntil)) +}) + +test('/wham/usage credits are persisted next to, not inside, reset credits and reach the pool summary', async (t) => { + const root = fixture(t, { extra: WEEK_SPENT }) + const long = '987654321987654321.123456789123456789' + const result = await queryOpenaiQuota({ + projectRoot: root, + vmId: 'vm-a', + fetchImpl: usageFetch({ + rate_limit: WEEK_SPENT_RATE_LIMIT, + rate_limit_reset_credits: { available_count: 0 }, + credits: { has_credits: true, unlimited: false, balance: long }, + }), + }) + assert.equal(result.ok, true) + assert.equal(result.credits.balance, long) + assert.equal(result.reset_credits.available_count, 0) + + const vm = getVm(root, 'vm-a') + assert.equal(vm.codex.credits.balance, long) + assert.equal(vm.codex.credits.has_credits, true) + assert.deepEqual(vm.codex.reset_credits.credits, []) + + const summary = listVms(root).find((item) => item.id === 'vm-a') + assert.equal(summary.codex_credits.balance, long) + assert.equal(summary.utilization_7d, 1) + assert.deepEqual(pickCodexSlots([summary]).ready, ['vm-a']) +}) + +test('/wham/usage without credits leaves the reset-credit path unchanged', async (t) => { + const root = fixture(t) + const result = await queryOpenaiQuota({ + projectRoot: root, + vmId: 'vm-a', + fetchImpl: usageFetch({ rate_limit: WEEK_SPENT_RATE_LIMIT, rate_limit_reset_credits: { available_count: 0 } }), + }) + assert.equal(result.credits, null) + assert.equal(getVm(root, 'vm-a').codex.credits, undefined) + const summary = listVms(root).find((item) => item.id === 'vm-a') + assert.equal(summary.codex_credits, null) + assert.deepEqual(pickCodexSlots([summary]).parked, ['vm-a']) +}) + +test('a spendable refresh supersedes an older park; an empty balance does not', (t) => { + const parkedUntil = new Date(Date.now() + 86_400_000).toISOString() + const root = fixture(t, { extra: { ...WEEK_SPENT, codex_limited_until: parkedUntil } }) + persistCodexQuotaSnapshot(root, 'vm-a', { credits: normalizeCodexCredits({ has_credits: false, unlimited: false }) }) + assert.equal(getVm(root, 'vm-a').codex.extra.codex_limited_until, parkedUntil) + persistCodexQuotaSnapshot(root, 'vm-a', { credits: normalizeCodexCredits({ has_credits: true, unlimited: false }) }) + assert.equal(getVm(root, 'vm-a').codex.extra.codex_limited_until, undefined) +}) + +test('a successful reply past the weekly cap keeps the slot schedulable while credits last', async (t) => { + const root = fixture(t) + await hop(root, { + headers: { + ...WEEK_SPENT_HEADERS, + 'x-codex-credits-has-credits': 'true', + 'x-codex-credits-unlimited': 'false', + 'x-codex-credits-balance': '3.14', + }, + }) + const vm = getVm(root, 'vm-a') + assert.equal(vm.codex.credits.balance, '3.14') + assert.equal(vm.codex.usage.quota.utilization_7d, 1) + assert.equal(vm.codex.extra.codex_limited_until, undefined, 'no park derived from a waived window') + assert.equal(vm.temp_unschedulable_reason, undefined) + const summary = listVms(root).find((item) => item.id === 'vm-a') + assert.deepEqual(pickCodexSlots([summary]).ready, ['vm-a']) +}) + +test('without credit headers a successful reply past the cap still parks as before', async (t) => { + const root = fixture(t) + await hop(root, { headers: WEEK_SPENT_HEADERS }) + const vm = getVm(root, 'vm-a') + assert.ok(Date.parse(vm.codex.extra.codex_limited_until) > Date.now()) + assert.equal(vm.temp_unschedulable_reason, 'quota_7d_header') +}) + +test('an upstream 429 drops the credits reading and parks the slot', async (t) => { + const root = fixture(t, { extra: WEEK_SPENT, credits: credits({}, Date.now()) }) + assert.deepEqual(pickCodexSlots(listVms(root)).ready, ['vm-a']) + await hop(root, { + status: 429, + headers: { + ...WEEK_SPENT_HEADERS, + 'x-codex-credits-has-credits': 'true', + 'x-codex-credits-unlimited': 'false', + }, + }) + const vm = getVm(root, 'vm-a') + assert.equal(vm.codex.credits, undefined) + assert.ok(Date.parse(vm.codex.extra.codex_limited_until) > Date.now() + 86_400_000) + assert.equal(vm.temp_unschedulable_reason, 'quota_7d_header') + assert.deepEqual(pickCodexSlots(listVms(root)).parked, ['vm-a']) +}) + +test('credit-only reply headers refresh the reading', async (t) => { + // A quota-exhausted slot gets no traffic; only a slot still in rotation can refresh from replies. + const root = fixture(t) + await hop(root, { headers: { 'x-codex-credits-has-credits': 'false', 'x-codex-credits-unlimited': 'true' } }) + const summary = listVms(root).find((item) => item.id === 'vm-a') + assert.equal(summary.codex_credits.unlimited, true) + assert.equal(summary.codex_credits.has_credits, false) +}) From f0d284fbaee45d7c845a21c3f939dd96e01a3279 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 12:14:59 +0000 Subject: [PATCH 2/2] docs(changelog): note #508 spendable credits window-cap waiver Co-authored-by: ted --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c014d1bb..6c5bb28a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 未发布 + +- GPT 槽在 TTL(10 分钟)内有可用 credits(`has_credits || unlimited`)时仅豁免窗口 cap;显式 park 仍优先;上游 429 清除 credits 并 park。(#508) + ## 1.3.187 — 2026-10-17 - 更新 Claude kernel:保留未知消息块,以及已知类型中的扩展字段(如 thinking 绑定、引用和工具调用来源),不再在传给 CLI 时静默丢弃;扩展 tool_result 仍参与 continuation ID 校验和大小限制。