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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 校验和大小限制。
Expand Down
2 changes: 2 additions & 0 deletions src/lib/admin/panel-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 } : {}),
Expand Down
15 changes: 12 additions & 3 deletions src/lib/oauth/openai-quota.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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 } : {}),
})
Expand All @@ -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',
}
Expand Down Expand Up @@ -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: [],
Expand Down
25 changes: 15 additions & 10 deletions src/lib/pool/codex-slot-pool.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}

Expand Down
9 changes: 5 additions & 4 deletions src/lib/pool/openai-quota-policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
70 changes: 67 additions & 3 deletions src/lib/protocol/codex-usage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {}
Expand Down
9 changes: 7 additions & 2 deletions src/lib/protocol/handle-codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 })
}

Expand Down
13 changes: 11 additions & 2 deletions src/lib/vm/codex-slot.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand All @@ -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') {
Expand Down Expand Up @@ -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,
}
}
11 changes: 10 additions & 1 deletion src/lib/vm/vm-registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ import {
extraFromCodexHeaders,
extraToCodexSnapshot,
buildCodexUsageView,
codexCreditsFromHeaders,
codexQuotaPark,
hasSpendableCodexCredits,
} from '../protocol/codex-usage.mjs'

import { summarizeCodexSlot } from './codex-slot.mjs'
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading