Files
2026-07-13 13:39:12 +08:00

524 lines
20 KiB
TypeScript

/**
* codexQuotaFetcher.ts — Codex Dual-Window Quota Fetcher
*
* Implements QuotaFetcher for the Codex provider (quotaPreflight.ts + quotaMonitor.ts).
*
* Codex has TWO independent quota windows:
* - Primary (5h): short-term rate limit, resets every 5 hours
* - Secondary (7d): weekly limit, resets every 7 days
*
* We return percentUsed = max(5h%, 7d%) so the system switches accounts when
* EITHER window approaches exhaustion (95% threshold).
*
* Cache: in-memory TTL (60s) to avoid hammering the usage API on every request.
* The connection pool is keyed by connectionId (providerConnection.id from DB).
*
* Registration: call registerCodexQuotaFetcher() once at server startup.
*/
import {
CODEX_SPARK_QUOTA_SESSION,
CODEX_SPARK_QUOTA_WEEKLY,
getCodexModelScope,
isCodexSparkLimitDescriptor,
} from "../config/codexQuotaScopes.ts";
import { registerQuotaFetcher, registerQuotaWindows, type QuotaInfo } from "./quotaPreflight.ts";
import { registerMonitorFetcher } from "./quotaMonitor.ts";
import { throttleQuotaFetch } from "./quotaFetchThrottle.ts";
/**
* Stable identifiers for Codex's quota windows. These match the quota keys
* surfaced by `getCodexUsage` (in usage.ts) and rendered by the dashboard,
* so per-window thresholds set in the UI line up with the keys persisted
* in `provider_connections.quota_window_thresholds_json`. The dedicated
* Codex fetcher exposes only session + weekly today; the plan-dependent
* code_review window is surfaced by the generic path when present.
*/
export const CODEX_WINDOW_SESSION = "session"; // primary 5-hour window
export const CODEX_WINDOW_WEEKLY = "weekly"; // secondary 7-day window
// Codex usage endpoint (same as usage.ts CODEX_CONFIG)
const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
// Cache TTL — short enough to be reactive, long enough to avoid rate limits
const CACHE_TTL_MS = 60_000; // 60 seconds
// Per-account quota window info (richer than QuotaInfo — includes both windows)
export interface CodexDualWindowQuota extends QuotaInfo {
window5h: { percentUsed: number; resetAt: string | null };
window7d: { percentUsed: number; resetAt: string | null };
limitReached: boolean;
/** All known Codex quota windows, including Spark when the upstream exposes it. */
allWindows?: Record<string, { percentUsed: number; resetAt: string | null }>;
/**
* Banked reset credits available on the account (display-only, issue #5199).
* Eligibility-gated: absent for most accounts. Never throws when missing.
*/
bankedResetCredits?: number;
/** Which window is currently reported as blocking, when the upstream exposes it. */
rateLimitReachedType?: string;
}
interface CacheEntry {
quota: CodexDualWindowQuota;
fetchedAt: number;
}
// In-memory cache: connectionId → { quota, fetchedAt }
const quotaCache = new Map<string, CacheEntry>();
// Auto-cleanup stale entries every 5 minutes
const _cacheCleanup = setInterval(() => {
const now = Date.now();
for (const [key, entry] of quotaCache) {
if (now - entry.fetchedAt > CACHE_TTL_MS * 5) {
quotaCache.delete(key);
}
}
}, 5 * 60_000);
if (typeof _cacheCleanup === "object" && "unref" in _cacheCleanup) {
(_cacheCleanup as { unref?: () => void }).unref?.();
}
// ─── Connection registry ─────────────────────────────────────────────────────
// We need the accessToken + workspaceId to call the API.
// chatCore.ts registers connection metadata here before requests.
interface CodexConnectionMeta {
accessToken: string;
workspaceId?: string;
}
const MAX_CONNECTIONS = 100;
const connectionRegistry = new Map<string, CodexConnectionMeta>();
const MAX_QUOTA_CACHE_ENTRIES = 200;
/**
* Register Codex connection metadata for quota fetching.
* Called by chatCore.ts when a Codex connection is resolved.
*
* @param connectionId - The connection ID from the DB (providerConnection.id)
* @param meta - Access token and optional workspace ID
*/
export function registerCodexConnection(connectionId: string, meta: CodexConnectionMeta): void {
if (!connectionRegistry.has(connectionId) && connectionRegistry.size >= MAX_CONNECTIONS) {
const oldestKey = connectionRegistry.keys().next().value;
if (oldestKey !== undefined) {
deleteQuotaCacheForConnection(oldestKey);
connectionRegistry.delete(oldestKey);
}
}
connectionRegistry.set(connectionId, meta);
}
function getQuotaCacheKey(connectionId: string, requestedModel?: string | null): string {
return `${connectionId}:${getCodexModelScope(requestedModel)}`;
}
function deleteQuotaCacheForConnection(connectionId: string): void {
quotaCache.delete(connectionId);
const scopedKeys = Array.from(quotaCache.keys()).filter((key) =>
key.startsWith(`${connectionId}:`)
);
for (const key of scopedKeys) quotaCache.delete(key);
}
export function unregisterCodexConnection(connectionId: string): void {
deleteQuotaCacheForConnection(connectionId);
connectionRegistry.delete(connectionId);
}
function getRequestedModel(connection?: Record<string, unknown>): string | null {
if (!connection || typeof connection !== "object") return null;
const directModel = connection.requestedModel ?? connection.model;
return typeof directModel === "string" && directModel.trim().length > 0
? directModel.trim()
: null;
}
function getCodexConnectionMeta(
connectionId: string,
connection?: Record<string, unknown>
): CodexConnectionMeta | null {
if (connection && typeof connection === "object") {
const providerSpecificData =
connection.providerSpecificData &&
typeof connection.providerSpecificData === "object" &&
!Array.isArray(connection.providerSpecificData)
? (connection.providerSpecificData as Record<string, unknown>)
: {};
const accessToken =
typeof connection.accessToken === "string" && connection.accessToken.trim().length > 0
? connection.accessToken
: null;
const workspaceId =
typeof providerSpecificData.workspaceId === "string" &&
providerSpecificData.workspaceId.trim().length > 0
? providerSpecificData.workspaceId
: undefined;
if (accessToken) {
const meta = { accessToken, ...(workspaceId ? { workspaceId } : {}) };
if (!connectionRegistry.has(connectionId) && connectionRegistry.size >= MAX_CONNECTIONS) {
const oldestKey = connectionRegistry.keys().next().value;
if (oldestKey !== undefined) {
deleteQuotaCacheForConnection(oldestKey);
connectionRegistry.delete(oldestKey);
}
}
connectionRegistry.set(connectionId, meta);
return meta;
}
}
return connectionRegistry.get(connectionId) || null;
}
function getDominantResetAt(quota: {
window5h: { percentUsed: number; resetAt: string | null };
window7d: { percentUsed: number; resetAt: string | null };
}): string | null {
if (quota.window7d.percentUsed > quota.window5h.percentUsed) {
return quota.window7d.resetAt || quota.window5h.resetAt;
}
if (quota.window5h.percentUsed > quota.window7d.percentUsed) {
return quota.window5h.resetAt || quota.window7d.resetAt;
}
return quota.window7d.resetAt || quota.window5h.resetAt;
}
// ─── Core Fetcher ────────────────────────────────────────────────────────────
/**
* Fetch current quota for a Codex connection.
* Returns percentUsed = max(5h%, 7d%) — worst-case across both windows.
*
* @param connectionId - Connection ID from the DB (used to look up credentials)
* @returns QuotaInfo or null if fetch fails / no credentials
*/
export async function fetchCodexQuota(
connectionId: string,
connection?: Record<string, unknown>
): Promise<CodexDualWindowQuota | null> {
const requestedModel = getRequestedModel(connection);
const cacheKey = getQuotaCacheKey(connectionId, requestedModel);
// Check cache first
const cached = quotaCache.get(cacheKey);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
return cached.quota;
}
// Look up credentials
const meta = getCodexConnectionMeta(connectionId, connection);
if (!meta?.accessToken) {
// No credentials registered — skip preflight gracefully
return null;
}
try {
const headers: Record<string, string> = {
Authorization: `Bearer ${meta.accessToken}`,
"Content-Type": "application/json",
Accept: "application/json",
};
if (meta.workspaceId) {
headers["chatgpt-account-id"] = meta.workspaceId;
}
// #6009: space concurrent upstream quota fetches so N accounts on one IP do
// not all hit the provider in the same second (anti-fingerprint / avoids the
// Codex OAuth revocation reported in router-for-me/CLIProxyAPI#2385). Cache
// hits above never reach here; this only paces genuine network calls and is
// configurable (OMNIROUTE_QUOTA_FETCH_MIN_INTERVAL_MS, 0 = disabled).
await throttleQuotaFetch();
const response = await fetch(CODEX_USAGE_URL, {
method: "GET",
headers,
signal: AbortSignal.timeout(8_000),
});
if (!response.ok) {
// Non-2xx: could be token expired or quota API down.
// Return null to proceed (fail-open — don't block on API errors).
if (response.status === 401 || response.status === 403) {
// Token expired — remove from cache so next call re-fetches
deleteQuotaCacheForConnection(connectionId);
connectionRegistry.delete(connectionId);
}
return null;
}
const data = await response.json();
const quota = parseCodexUsageResponse(data, requestedModel);
if (!quota) return null;
// Store in cache
if (!quotaCache.has(connectionId) && quotaCache.size >= MAX_QUOTA_CACHE_ENTRIES) {
const oldestCacheKey = quotaCache.keys().next().value;
if (oldestCacheKey !== undefined) quotaCache.delete(oldestCacheKey);
}
quotaCache.set(cacheKey, { quota, fetchedAt: Date.now() });
return quota;
} catch {
// Network error, timeout, etc. — fail open
return null;
}
}
// ─── Response Parser ─────────────────────────────────────────────────────────
function toNumber(value: unknown, fallback = 0): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = parseFloat(value);
if (Number.isFinite(parsed)) return parsed;
}
return fallback;
}
function toRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function parseWindowReset(window: Record<string, unknown>): string | null {
const resetAt = toNumber(window["reset_at"] ?? window["resetAt"], 0);
if (resetAt > 0) {
return new Date(resetAt * 1000).toISOString();
}
const resetAfterSeconds = toNumber(
window["reset_after_seconds"] ?? window["resetAfterSeconds"],
0
);
if (resetAfterSeconds > 0) {
return new Date(Date.now() + resetAfterSeconds * 1000).toISOString();
}
return null;
}
function parseCodexWindow(
window: Record<string, unknown> | null | undefined
): { percentUsed: number; resetAt: string | null } | null {
if (!window || Object.keys(window).length === 0) return null;
const percentUsed = toNumber(window["used_percent"] ?? window["usedPercent"], 0) / 100;
return { percentUsed, resetAt: parseWindowReset(window) };
}
/**
* Codex "banked reset credits" — same eligibility-gated field parsed in
* codexUsageQuotas.ts (kept in sync manually; the two parsers read the same
* /wham/usage payload independently). DISPLAY ONLY, never throws.
*/
function parseBankedResetCredits(data: Record<string, unknown>): number | undefined {
const resetCredits = toRecord(data["rate_limit_reset_credits"] ?? data["rateLimitResetCredits"]);
const availableCount = resetCredits["available_count"] ?? resetCredits["availableCount"];
const count = toNumber(availableCount, NaN);
return Number.isFinite(count) ? count : undefined;
}
function parseRateLimitReachedType(data: Record<string, unknown>): string | undefined {
const reachedType = data["rate_limit_reached_type"] ?? data["rateLimitReachedType"];
if (typeof reachedType === "string" && reachedType.trim().length > 0) return reachedType.trim();
const reachedTypeObj = toRecord(reachedType);
const type = reachedTypeObj["type"];
return typeof type === "string" && type.trim().length > 0 ? type.trim() : undefined;
}
function findSparkRateLimit(data: Record<string, unknown>): Record<string, unknown> | null {
const additional = data["additional_rate_limits"] ?? data["additionalRateLimits"];
if (!Array.isArray(additional)) return null;
for (const entryValue of additional) {
const entry = toRecord(entryValue);
if (
!isCodexSparkLimitDescriptor(
entry["limit_name"],
entry["limitName"],
entry["metered_feature"],
entry["meteredFeature"],
entry["limit_id"],
entry["limitId"],
entry["id"],
entry["name"],
entry["title"],
entry["model"],
entry["model_id"],
entry["modelId"]
)
) {
continue;
}
return toRecord(entry["rate_limit"] ?? entry["rateLimit"]);
}
return null;
}
function getCodexRateLimitWindows(rateLimit: Record<string, unknown>): {
primary: { percentUsed: number; resetAt: string | null } | null;
secondary: { percentUsed: number; resetAt: string | null } | null;
} {
return {
primary: parseCodexWindow(toRecord(rateLimit["primary_window"] ?? rateLimit["primaryWindow"])),
secondary: parseCodexWindow(
toRecord(rateLimit["secondary_window"] ?? rateLimit["secondaryWindow"])
),
};
}
function assignCodexWindows(
target: Record<string, { percentUsed: number; resetAt: string | null }>,
rateLimit: Record<string, unknown>,
names: { primary: string; secondary: string }
): void {
const { primary, secondary } = getCodexRateLimitWindows(rateLimit);
if (primary) target[names.primary] = primary;
if (secondary) target[names.secondary] = secondary;
}
function getSelectedCodexRateLimit(
normalRateLimit: Record<string, unknown>,
sparkRateLimit: Record<string, unknown> | null,
useSparkWindows: boolean
): Record<string, unknown> | null {
if (useSparkWindows) return sparkRateLimit;
return normalRateLimit;
}
function parseCodexUsageResponse(
data: unknown,
requestedModel?: string | null
): CodexDualWindowQuota | null {
const obj = toRecord(data);
const normalRateLimit = toRecord(obj["rate_limit"] ?? obj["rateLimit"]);
const sparkRateLimit = findSparkRateLimit(obj);
const useSparkWindows = getCodexModelScope(requestedModel) === "spark";
const selectedRateLimit = getSelectedCodexRateLimit(
normalRateLimit,
sparkRateLimit,
useSparkWindows
);
if (!selectedRateLimit) return null;
// Require at least one window to be present for the requested scope.
const { primary: parsedPrimary, secondary: parsedSecondary } =
getCodexRateLimitWindows(selectedRateLimit);
if (!parsedPrimary && !parsedSecondary) return null;
const window5h = parsedPrimary ?? { percentUsed: 0, resetAt: null };
const window7d = parsedSecondary ?? { percentUsed: 0, resetAt: null };
const worstPercentUsed = Math.max(window5h.percentUsed, window7d.percentUsed);
const limitReached = Boolean(
selectedRateLimit["limit_reached"] ?? selectedRateLimit["limitReached"]
);
const windows: Record<string, { percentUsed: number; resetAt: string | null }> = {};
assignCodexWindows(windows, selectedRateLimit, {
primary: useSparkWindows ? CODEX_SPARK_QUOTA_SESSION : CODEX_WINDOW_SESSION,
secondary: useSparkWindows ? CODEX_SPARK_QUOTA_WEEKLY : CODEX_WINDOW_WEEKLY,
});
const allWindows: Record<string, { percentUsed: number; resetAt: string | null }> = {
...windows,
};
if (sparkRateLimit) {
assignCodexWindows(allWindows, sparkRateLimit, {
primary: CODEX_SPARK_QUOTA_SESSION,
secondary: CODEX_SPARK_QUOTA_WEEKLY,
});
}
assignCodexWindows(allWindows, normalRateLimit, {
primary: CODEX_WINDOW_SESSION,
secondary: CODEX_WINDOW_WEEKLY,
});
const bankedResetCredits = parseBankedResetCredits(obj);
const rateLimitReachedType = parseRateLimitReachedType(obj);
return {
used: Math.round(worstPercentUsed * 100),
total: 100,
percentUsed: worstPercentUsed,
resetAt: getDominantResetAt({ window5h, window7d }),
// Per-window breakdown for the preflight evaluator. For Spark requests this
// intentionally contains ONLY Spark windows, so Spark exhaustion does not
// preflight-block normal Codex requests (and vice versa).
windows,
allWindows,
// Legacy fields preserved for existing consumers (quotaMonitor, cooldown
// computation in accountFallback). These mirror the selected scope entries
// but keep the historical names — do not remove without checking callers.
window5h,
window7d,
limitReached,
// Banked reset credits (display-only, eligibility-gated — issue #5199).
...(bankedResetCredits !== undefined ? { bankedResetCredits } : {}),
...(rateLimitReachedType !== undefined ? { rateLimitReachedType } : {}),
};
}
// ─── Quota-Aware Reset Time ───────────────────────────────────────────────────
/**
* Get the cooldown duration (ms) for a Codex account based on its quota state.
*
* Logic:
* - If 7d window >= threshold → cooldown until 7d reset (longer)
* - If 5h window >= threshold → cooldown until 5h reset (shorter)
* - Otherwise → 0 (no cooldown)
*
* @param quota - The dual-window quota snapshot
* @param threshold - The fraction (0-1) that triggers a switch (default: 0.95)
* @returns Cooldown duration in milliseconds
*/
export function getCodexQuotaCooldownMs(quota: CodexDualWindowQuota, threshold = 0.95): number {
const now = Date.now();
// 7d window takes priority (if exhausted, must wait longer)
if (quota.window7d.percentUsed >= threshold && quota.window7d.resetAt) {
const resetTime = new Date(quota.window7d.resetAt).getTime();
if (resetTime > now) return resetTime - now;
}
// 5h window
if (quota.window5h.percentUsed >= threshold && quota.window5h.resetAt) {
const resetTime = new Date(quota.window5h.resetAt).getTime();
if (resetTime > now) return resetTime - now;
}
return 0;
}
// ─── Invalidation ────────────────────────────────────────────────────────────
/**
* Force-invalidate the cache for a connection (e.g., after receiving quota headers).
* Ensures the next preflight call fetches fresh data.
*/
export function invalidateCodexQuotaCache(connectionId: string): void {
deleteQuotaCacheForConnection(connectionId);
}
// ─── Registration ─────────────────────────────────────────────────────────────
/**
* Register the Codex quota fetcher with the preflight and monitor systems.
* Call this once at server startup (in chatCore.ts or app entry point).
*/
export function registerCodexQuotaFetcher(): void {
registerQuotaFetcher("codex", fetchCodexQuota);
registerMonitorFetcher("codex", fetchCodexQuota);
registerQuotaWindows("codex", [
CODEX_WINDOW_SESSION,
CODEX_WINDOW_WEEKLY,
CODEX_SPARK_QUOTA_SESSION,
CODEX_SPARK_QUOTA_WEEKLY,
]);
}