diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 12af8ae216..274158873d 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -37,6 +37,14 @@ import { CORS_HEADERS } from "../utils/cors.ts"; import { normalizeCodexResponsesInput } from "../utils/responsesInputNormalization.ts"; import * as prl from "../utils/providerRequestLogging.ts"; import { createRequire } from "module"; +// Quota parsing/scheduling extracted to a pure leaf; re-exported for external +// importers (handlers/chatCore/codexQuota.ts + tests). +export { + type CodexQuotaSnapshot, + parseCodexQuotaHeaders, + getCodexResetTime, + getCodexDualWindowCooldownMs, +} from "./codex/quota.ts"; // ─── wreq-js lazy loader ─────────────────────────────────────────────────── // wreq-js is a Rust-native module that requires platform-specific .node binaries. @@ -104,119 +112,6 @@ function codexWebSocketUnavailableResponse(): Response { // Ref: sub2api PR #1129 (feat(openai): split codex spark rate limiting from codex) export { getCodexModelScope, getCodexRateLimitKey, type CodexQuotaScope }; -/** - * T03: Parsed quota snapshot from Codex response headers. - * Codex includes per-account usage windows that allow precise reset scheduling. - * Ref: sub2api PR #357 (feat(oauth): persist usage snapshots and window cooldown) - */ -export interface CodexQuotaSnapshot { - usage5h: number; // tokens used in 5h window - limit5h: number; // token limit for 5h window - resetAt5h: string | null; // ISO timestamp when 5h window resets - usage7d: number; // tokens used in 7d window - limit7d: number; // token limit for 7d window - resetAt7d: string | null; // ISO timestamp when 7d window resets -} - -/** - * T03: Parse Codex-specific quota headers from a provider response. - * Returns null if none of the relevant headers are present. - * - * Extracts: - * x-codex-5h-usage / x-codex-5h-limit / x-codex-5h-reset-at - * x-codex-7d-usage / x-codex-7d-limit / x-codex-7d-reset-at - */ -export function parseCodexQuotaHeaders(headers: Record): CodexQuotaSnapshot | null { - const usage5h = headers["x-codex-5h-usage"] ?? null; - const limit5h = headers["x-codex-5h-limit"] ?? null; - const resetAt5h = headers["x-codex-5h-reset-at"] ?? null; - const usage7d = headers["x-codex-7d-usage"] ?? null; - const limit7d = headers["x-codex-7d-limit"] ?? null; - const resetAt7d = headers["x-codex-7d-reset-at"] ?? null; - - // Return null if none of the quota headers are present (not a quota-aware response) - if (!usage5h && !limit5h && !resetAt5h && !usage7d && !limit7d && !resetAt7d) { - return null; - } - - return { - usage5h: usage5h ? parseFloat(usage5h) : 0, - limit5h: limit5h ? parseFloat(limit5h) : Infinity, - resetAt5h: resetAt5h ?? null, - usage7d: usage7d ? parseFloat(usage7d) : 0, - limit7d: limit7d ? parseFloat(limit7d) : Infinity, - resetAt7d: resetAt7d ?? null, - }; -} - -/** - * T03: Get the soonest quota reset time from a CodexQuotaSnapshot. - * 7d window takes priority (wider window, harder limit) but we use whichever - * is further in the future to avoid releasing the block too early. - * - * @returns Unix timestamp (ms) of the soonest effective reset, or null - */ -export function getCodexResetTime(quota: CodexQuotaSnapshot): number | null { - const times: number[] = []; - if (quota.resetAt7d) { - const t = new Date(quota.resetAt7d).getTime(); - if (!isNaN(t) && t > Date.now()) times.push(t); - } - if (quota.resetAt5h) { - const t = new Date(quota.resetAt5h).getTime(); - if (!isNaN(t) && t > Date.now()) times.push(t); - } - if (times.length === 0) return null; - return Math.max(...times); // Use furthest-out reset to avoid premature unblock -} - -/** - * T03 (Item 3): Compute the minimum-necessary cooldown based on which window - * is actually exhausted. Prevents over-blocking the account: - * - * - If 7d window >= threshold: cooldown until 7d reset (weekly window exhausted) - * - If 5h window >= threshold: cooldown until 5h reset only (short-term limit) - * - Otherwise: 0 (account is healthy, no cooldown needed) - * - * Called after parsing quota headers from a successful/429 response to - * mark the account accordingly without overly long cooldowns. - * - * @param quota - Parsed quota snapshot from response headers - * @param threshold - Fraction (0-1) that triggers cooldown (default: 0.95) - * @returns Cooldown duration in milliseconds (0 = no cooldown needed) - */ -export function getCodexDualWindowCooldownMs( - quota: CodexQuotaSnapshot, - threshold = 0.95 -): { cooldownMs: number; window: "7d" | "5h" | "none" } { - const now = Date.now(); - - // Compute per-window usage ratios (0..1) - const ratio7d = - quota.limit7d > 0 && Number.isFinite(quota.limit7d) ? quota.usage7d / quota.limit7d : 0; - const ratio5h = - quota.limit5h > 0 && Number.isFinite(quota.limit5h) ? quota.usage5h / quota.limit5h : 0; - - // 7d window takes priority — if the weekly budget is near-exhausted, - // we must wait until the weekly reset (not just 5h). - if (ratio7d >= threshold && quota.resetAt7d) { - const resetTime = new Date(quota.resetAt7d).getTime(); - if (resetTime > now) { - return { cooldownMs: resetTime - now, window: "7d" }; - } - } - - // 5h window (primary short-term rate limit) - if (ratio5h >= threshold && quota.resetAt5h) { - const resetTime = new Date(quota.resetAt5h).getTime(); - if (resetTime > now) { - return { cooldownMs: resetTime - now, window: "5h" }; - } - } - - return { cooldownMs: 0, window: "none" }; -} - // Ordered list of effort levels from lowest to highest const EFFORT_ORDER = ["none", "low", "medium", "high", "xhigh"] as const; type EffortLevel = (typeof EFFORT_ORDER)[number]; @@ -1159,9 +1054,7 @@ export class CodexExecutor extends BaseExecutor { headers["chatgpt-account-id"] = workspaceId; } const clientIdentity = credentials?.providerSpecificData?.codexClientIdentity as - | CodexClientIdentity - | null - | undefined; + CodexClientIdentity | null | undefined; // Originator header — identifies the client type to the Codex backend. // Ref: openai/codex login/src/auth/default_client.rs DEFAULT_ORIGINATOR = "codex_cli_rs" @@ -1481,9 +1374,7 @@ export class CodexExecutor extends BaseExecutor { applyCodexClientMetadata( body, credentials?.providerSpecificData?.codexClientIdentity as - | CodexClientIdentity - | null - | undefined + CodexClientIdentity | null | undefined ); } diff --git a/open-sse/executors/codex/quota.ts b/open-sse/executors/codex/quota.ts new file mode 100644 index 0000000000..977f794dde --- /dev/null +++ b/open-sse/executors/codex/quota.ts @@ -0,0 +1,114 @@ +// Codex quota-snapshot parsing + reset/cooldown scheduling (pure). Verbatim from codex.ts. + +/** + * T03: Parsed quota snapshot from Codex response headers. + * Codex includes per-account usage windows that allow precise reset scheduling. + * Ref: sub2api PR #357 (feat(oauth): persist usage snapshots and window cooldown) + */ +export interface CodexQuotaSnapshot { + usage5h: number; // tokens used in 5h window + limit5h: number; // token limit for 5h window + resetAt5h: string | null; // ISO timestamp when 5h window resets + usage7d: number; // tokens used in 7d window + limit7d: number; // token limit for 7d window + resetAt7d: string | null; // ISO timestamp when 7d window resets +} + +/** + * T03: Parse Codex-specific quota headers from a provider response. + * Returns null if none of the relevant headers are present. + * + * Extracts: + * x-codex-5h-usage / x-codex-5h-limit / x-codex-5h-reset-at + * x-codex-7d-usage / x-codex-7d-limit / x-codex-7d-reset-at + */ +export function parseCodexQuotaHeaders(headers: Record): CodexQuotaSnapshot | null { + const usage5h = headers["x-codex-5h-usage"] ?? null; + const limit5h = headers["x-codex-5h-limit"] ?? null; + const resetAt5h = headers["x-codex-5h-reset-at"] ?? null; + const usage7d = headers["x-codex-7d-usage"] ?? null; + const limit7d = headers["x-codex-7d-limit"] ?? null; + const resetAt7d = headers["x-codex-7d-reset-at"] ?? null; + + // Return null if none of the quota headers are present (not a quota-aware response) + if (!usage5h && !limit5h && !resetAt5h && !usage7d && !limit7d && !resetAt7d) { + return null; + } + + return { + usage5h: usage5h ? parseFloat(usage5h) : 0, + limit5h: limit5h ? parseFloat(limit5h) : Infinity, + resetAt5h: resetAt5h ?? null, + usage7d: usage7d ? parseFloat(usage7d) : 0, + limit7d: limit7d ? parseFloat(limit7d) : Infinity, + resetAt7d: resetAt7d ?? null, + }; +} + +/** + * T03: Get the soonest quota reset time from a CodexQuotaSnapshot. + * 7d window takes priority (wider window, harder limit) but we use whichever + * is further in the future to avoid releasing the block too early. + * + * @returns Unix timestamp (ms) of the soonest effective reset, or null + */ +export function getCodexResetTime(quota: CodexQuotaSnapshot): number | null { + const times: number[] = []; + if (quota.resetAt7d) { + const t = new Date(quota.resetAt7d).getTime(); + if (!isNaN(t) && t > Date.now()) times.push(t); + } + if (quota.resetAt5h) { + const t = new Date(quota.resetAt5h).getTime(); + if (!isNaN(t) && t > Date.now()) times.push(t); + } + if (times.length === 0) return null; + return Math.max(...times); // Use furthest-out reset to avoid premature unblock +} + +/** + * T03 (Item 3): Compute the minimum-necessary cooldown based on which window + * is actually exhausted. Prevents over-blocking the account: + * + * - If 7d window >= threshold: cooldown until 7d reset (weekly window exhausted) + * - If 5h window >= threshold: cooldown until 5h reset only (short-term limit) + * - Otherwise: 0 (account is healthy, no cooldown needed) + * + * Called after parsing quota headers from a successful/429 response to + * mark the account accordingly without overly long cooldowns. + * + * @param quota - Parsed quota snapshot from response headers + * @param threshold - Fraction (0-1) that triggers cooldown (default: 0.95) + * @returns Cooldown duration in milliseconds (0 = no cooldown needed) + */ +export function getCodexDualWindowCooldownMs( + quota: CodexQuotaSnapshot, + threshold = 0.95 +): { cooldownMs: number; window: "7d" | "5h" | "none" } { + const now = Date.now(); + + // Compute per-window usage ratios (0..1) + const ratio7d = + quota.limit7d > 0 && Number.isFinite(quota.limit7d) ? quota.usage7d / quota.limit7d : 0; + const ratio5h = + quota.limit5h > 0 && Number.isFinite(quota.limit5h) ? quota.usage5h / quota.limit5h : 0; + + // 7d window takes priority — if the weekly budget is near-exhausted, + // we must wait until the weekly reset (not just 5h). + if (ratio7d >= threshold && quota.resetAt7d) { + const resetTime = new Date(quota.resetAt7d).getTime(); + if (resetTime > now) { + return { cooldownMs: resetTime - now, window: "7d" }; + } + } + + // 5h window (primary short-term rate limit) + if (ratio5h >= threshold && quota.resetAt5h) { + const resetTime = new Date(quota.resetAt5h).getTime(); + if (resetTime > now) { + return { cooldownMs: resetTime - now, window: "5h" }; + } + } + + return { cooldownMs: 0, window: "none" }; +} diff --git a/tests/unit/codex-executor-split.test.ts b/tests/unit/codex-executor-split.test.ts new file mode 100644 index 0000000000..eac6b2eea0 --- /dev/null +++ b/tests/unit/codex-executor-split.test.ts @@ -0,0 +1,36 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +// Split-guard for the codex executor quota extraction. +// The pure quota-snapshot parsing + reset/cooldown scheduling lives in codex/quota.ts. +// Host re-exports the 4 public symbols (chatCore/codexQuota.ts + tests import them). +const HERE = dirname(fileURLToPath(import.meta.url)); +const EXE = join(HERE, "../../open-sse/executors"); +const HOST = join(EXE, "codex.ts"); +const LEAF = join(EXE, "codex/quota.ts"); + +test("leaf hosts the quota helpers and does not import the host", () => { + const src = readFileSync(LEAF, "utf8"); + for (const sym of [ + "parseCodexQuotaHeaders", + "getCodexResetTime", + "getCodexDualWindowCooldownMs", + "CodexQuotaSnapshot", + ]) { + assert.match(src, new RegExp(`export (function|interface) ${sym}\\b`)); + } + assert.doesNotMatch(src, /from "\.\.\/codex\.ts"/); +}); + +test("host re-exports the quota symbols for external importers", () => { + const host = readFileSync(HOST, "utf8"); + assert.match(host, /from "\.\/codex\/quota\.ts"/); +}); + +test("parseCodexQuotaHeaders returns null without quota headers", async () => { + const { parseCodexQuotaHeaders } = await import("../../open-sse/executors/codex/quota.ts"); + assert.equal(parseCodexQuotaHeaders({}), null); +});