From b12c80fa0c8cbed7b70e659ea11195d4e11a252c Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 21 Jun 2026 08:37:58 -0300 Subject: [PATCH] =?UTF-8?q?refactor(chatCore):=20extrai=20n=C3=BAcleo=20pu?= =?UTF-8?q?ro=20de=20persistCodexQuotaState=20(#3501)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move a construção do payload de persistCodexQuotaState para o novo leaf puro open-sse/handlers/chatCore/codexQuota.ts (buildCodexQuotaPersistence): parseia os headers de quota do Codex no snapshot codexQuotaState, faz passthrough do providerSpecificData existente e, num 429 cuja janela (dual-window) passou do threshold de exaustão, registra o cooldown por escopo (codexScopeRateLimitedUntil) + codexExhaustedWindow, retornando a mensagem de debug-log. O handler mantém as partes impuras byte-idênticas e na mesma ordem: emitir o exhaustionLog retornado, invalidateCodexQuotaCache em todo 429 (connectionId é garantido pelo early-return), depois updateProviderConnection + mutação de credentials. Imports órfãos (parseCodexQuotaHeaders/getCodexModelScope/ getCodexDualWindowCooldownMs) migram para o leaf; isCompactResponsesEndpoint permanece (usado no site de passthrough). chatCore.ts 5055->5019 (shrink -36); baseline file-size ratchetado. complexity 1905=1905 (neutro). Coberto por tests/unit/chatcore-codex-quota.test.ts (5 casos: sem-headers null, snapshot+passthrough, 429 cooldown+janela+log, merge do scope-map, 429 abaixo-do-threshold no-op). --- open-sse/handlers/chatCore.ts | 76 +++++----------- open-sse/handlers/chatCore/codexQuota.ts | 85 ++++++++++++++++++ tests/unit/chatcore-codex-quota.test.ts | 106 +++++++++++++++++++++++ 3 files changed, 211 insertions(+), 56 deletions(-) create mode 100644 open-sse/handlers/chatCore/codexQuota.ts create mode 100644 tests/unit/chatcore-codex-quota.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index dc869e87f3..0544a90db3 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -206,12 +206,8 @@ import { shouldDetectLimit, } from "../services/toolLimitDetector.ts"; -import { - parseCodexQuotaHeaders, - getCodexModelScope, - getCodexDualWindowCooldownMs, - isCompactResponsesEndpoint, -} from "../executors/codex.ts"; +import { isCompactResponsesEndpoint } from "../executors/codex.ts"; +import { buildCodexQuotaPersistence } from "./chatCore/codexQuota.ts"; import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts"; import { translateNonStreamingResponse } from "./responseTranslator.ts"; import { extractUsageFromResponse } from "./usageExtractor.ts"; @@ -826,67 +822,35 @@ export async function handleChatCore({ if (provider !== "codex" || !connectionId || !headers) return; try { - const quota = parseCodexQuotaHeaders(headers); - if (!quota) return; - const existingProviderData = credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object" - ? credentials.providerSpecificData + ? (credentials.providerSpecificData as Record) : {}; - const scope = getCodexModelScope(model || requestedModel || ""); - const quotaState = { - usage5h: quota.usage5h, - limit5h: quota.limit5h, - resetAt5h: quota.resetAt5h, - usage7d: quota.usage7d, - limit7d: quota.limit7d, - resetAt7d: quota.resetAt7d, - scope, - updatedAt: new Date().toISOString(), - }; + // Pure payload build extracted to chatCore/codexQuota.ts (#3501). Returns null when the + // response carries no quota headers (nothing to persist). + const built = buildCodexQuotaPersistence({ + headers, + existingProviderData, + modelForScope: model || requestedModel || "", + status, + }); + if (!built) return; - const nextProviderData: Record = { - ...existingProviderData, - codexQuotaState: quotaState, - }; + if (built.exhaustionLog) { + log?.debug?.("CODEX", built.exhaustionLog); + } - // T03/T09: on 429, persist exact reset time per scope to avoid global over-blocking. - // Use dual-window cooldown to distinguish short-term and weekly Codex exhaustion. + // Invalidate the preflight cache for this connection so the next + // isModelAvailable check fetches fresh quota data. if (status === 429) { - const { cooldownMs, window: exhaustedWindow } = getCodexDualWindowCooldownMs(quota); - if (cooldownMs > 0) { - const scopeUntil = new Date(Date.now() + cooldownMs).toISOString(); - const scopeMapRaw = - existingProviderData && - typeof existingProviderData === "object" && - existingProviderData.codexScopeRateLimitedUntil && - typeof existingProviderData.codexScopeRateLimitedUntil === "object" - ? existingProviderData.codexScopeRateLimitedUntil - : {}; - - nextProviderData.codexScopeRateLimitedUntil = { - ...(scopeMapRaw as Record), - [scope]: scopeUntil, - }; - nextProviderData.codexExhaustedWindow = exhaustedWindow; - log?.debug?.( - "CODEX", - `Quota exhaustion on ${exhaustedWindow} window, cooldown until ${scopeUntil}` - ); - } - - // Invalidate the preflight cache for this connection so the next - // isModelAvailable check fetches fresh quota data. - if (connectionId) { - invalidateCodexQuotaCache(connectionId); - } + invalidateCodexQuotaCache(connectionId); } await updateProviderConnection(connectionId, { - providerSpecificData: nextProviderData, + providerSpecificData: built.nextProviderData, }); - credentials.providerSpecificData = nextProviderData; + credentials.providerSpecificData = built.nextProviderData; } catch (err) { const errMessage = err instanceof Error ? err.message : String(err); log?.debug?.("CODEX", `Failed to persist codex quota state: ${errMessage}`); diff --git a/open-sse/handlers/chatCore/codexQuota.ts b/open-sse/handlers/chatCore/codexQuota.ts new file mode 100644 index 0000000000..7bc6eac851 --- /dev/null +++ b/open-sse/handlers/chatCore/codexQuota.ts @@ -0,0 +1,85 @@ +/** + * chatCore Codex quota-persistence builder (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Pure core of handleChatCore's persistCodexQuotaState: turns the upstream Codex quota response + * headers into the next `providerSpecificData` payload (the codexQuotaState snapshot, plus — on a + * 429 whose dual-window usage is past the exhaustion threshold — the per-scope cooldown timestamp, + * the exhausted window, and the debug-log message). The handler keeps the impure parts byte- + * identically: the DB write (updateProviderConnection), the preflight-cache invalidation on every + * 429, the credentials mutation, and emitting the returned log line. + */ + +import { + parseCodexQuotaHeaders, + getCodexModelScope, + getCodexDualWindowCooldownMs, +} from "../../executors/codex.ts"; + +export type CodexQuotaPersistence = { + /** The merged providerSpecificData to persist (existing data + codexQuotaState [+ 429 cooldown]). */ + nextProviderData: Record; + /** The CODEX debug-log message to emit when a 429 exhausted a window, else null. */ + exhaustionLog: string | null; +}; + +/** + * Build the providerSpecificData update for a Codex quota response. Returns null when the response + * carries no quota headers (nothing to persist). Pure: a function of the headers, the existing + * provider data, the model used for scope resolution, and the upstream status. + */ +export function buildCodexQuotaPersistence(opts: { + headers: Record; + existingProviderData: Record; + modelForScope: string; + status: number; +}): CodexQuotaPersistence | null { + const { headers, existingProviderData, modelForScope, status } = opts; + + const quota = parseCodexQuotaHeaders(headers); + if (!quota) return null; + + const scope = getCodexModelScope(modelForScope); + const quotaState = { + usage5h: quota.usage5h, + limit5h: quota.limit5h, + resetAt5h: quota.resetAt5h, + usage7d: quota.usage7d, + limit7d: quota.limit7d, + resetAt7d: quota.resetAt7d, + scope, + updatedAt: new Date().toISOString(), + }; + + const nextProviderData: Record = { + ...existingProviderData, + codexQuotaState: quotaState, + }; + + let exhaustionLog: string | null = null; + + // T03/T09: on 429, persist exact reset time per scope to avoid global over-blocking. + // Use dual-window cooldown to distinguish short-term and weekly Codex exhaustion. + if (status === 429) { + const { cooldownMs, window: exhaustedWindow } = getCodexDualWindowCooldownMs(quota); + if (cooldownMs > 0) { + const scopeUntil = new Date(Date.now() + cooldownMs).toISOString(); + const scopeMapRaw = + existingProviderData && + typeof existingProviderData === "object" && + existingProviderData.codexScopeRateLimitedUntil && + typeof existingProviderData.codexScopeRateLimitedUntil === "object" + ? existingProviderData.codexScopeRateLimitedUntil + : {}; + + nextProviderData.codexScopeRateLimitedUntil = { + ...(scopeMapRaw as Record), + [scope]: scopeUntil, + }; + nextProviderData.codexExhaustedWindow = exhaustedWindow; + exhaustionLog = `Quota exhaustion on ${exhaustedWindow} window, cooldown until ${scopeUntil}`; + } + } + + return { nextProviderData, exhaustionLog }; +} diff --git a/tests/unit/chatcore-codex-quota.test.ts b/tests/unit/chatcore-codex-quota.test.ts new file mode 100644 index 0000000000..04582356b4 --- /dev/null +++ b/tests/unit/chatcore-codex-quota.test.ts @@ -0,0 +1,106 @@ +// tests/unit/chatcore-codex-quota.test.ts +// Characterization of buildCodexQuotaPersistence — the pure core of handleChatCore's +// persistCodexQuotaState, extracted during the chatCore god-file decomposition (#3501). Locks the +// shape of the persisted providerSpecificData: the codexQuotaState snapshot, the existing-data +// passthrough, and the 429 dual-window exhaustion fields (codexScopeRateLimitedUntil / +// codexExhaustedWindow) plus the debug-log message. The handler keeps the DB write, the +// preflight-cache invalidation, and the log emission; this function only builds the data. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildCodexQuotaPersistence } from "../../open-sse/handlers/chatCore/codexQuota.ts"; +import { getCodexModelScope } from "../../open-sse/executors/codex.ts"; + +const MODEL = "gpt-5-codex"; +const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +function quotaHeaders(over: Record = {}) { + return { + "x-codex-5h-usage": "50", + "x-codex-5h-limit": "100", + "x-codex-5h-reset-at": "2999-01-01T00:00:00.000Z", + "x-codex-7d-usage": "10", + "x-codex-7d-limit": "100", + "x-codex-7d-reset-at": "2999-01-08T00:00:00.000Z", + ...over, + }; +} + +test("returns null when the response carries no codex quota headers", () => { + assert.equal( + buildCodexQuotaPersistence({ headers: {}, existingProviderData: {}, modelForScope: MODEL, status: 200 }), + null + ); + assert.equal( + buildCodexQuotaPersistence({ headers: { "content-type": "application/json" }, existingProviderData: {}, modelForScope: MODEL, status: 200 }), + null + ); +}); + +test("builds codexQuotaState (parsed numbers + scope + updatedAt) and preserves existing provider data", () => { + const built = buildCodexQuotaPersistence({ + headers: quotaHeaders(), + existingProviderData: { keepMe: "yes", apiKeyHealth: { primary: {} } }, + modelForScope: MODEL, + status: 200, + }); + assert.ok(built); + const qs = built.nextProviderData.codexQuotaState as Record; + assert.equal(qs.usage5h, 50); + assert.equal(qs.limit5h, 100); + assert.equal(qs.usage7d, 10); + assert.equal(qs.limit7d, 100); + assert.equal(qs.scope, getCodexModelScope(MODEL)); + assert.match(String(qs.updatedAt), ISO); + // existing keys passed through, not dropped + assert.equal(built.nextProviderData.keepMe, "yes"); + assert.deepEqual(built.nextProviderData.apiKeyHealth, { primary: {} }); + // non-429 → no exhaustion fields, no log + assert.equal(built.exhaustionLog, null); + assert.equal(built.nextProviderData.codexScopeRateLimitedUntil, undefined); + assert.equal(built.nextProviderData.codexExhaustedWindow, undefined); +}); + +test("429 with a near-exhausted 5h window records the per-scope cooldown + window + log", () => { + const built = buildCodexQuotaPersistence({ + headers: quotaHeaders({ "x-codex-5h-usage": "100" }), // ratio 1.0 >= 0.95, reset far in the future + existingProviderData: {}, + modelForScope: MODEL, + status: 429, + }); + assert.ok(built); + assert.equal(built.nextProviderData.codexExhaustedWindow, "5h"); + const scope = getCodexModelScope(MODEL); + const scopeMap = built.nextProviderData.codexScopeRateLimitedUntil as Record; + assert.ok(scopeMap[scope]?.startsWith("2999-01-01T00:00:00")); + assert.match( + String(built.exhaustionLog), + /^Quota exhaustion on 5h window, cooldown until 2999-01-01T00:00:00/ + ); +}); + +test("429 merges into an existing codexScopeRateLimitedUntil map without dropping other scopes", () => { + const built = buildCodexQuotaPersistence({ + headers: quotaHeaders({ "x-codex-5h-usage": "100" }), + existingProviderData: { codexScopeRateLimitedUntil: { "other-scope": "2999-12-31T00:00:00.000Z" } }, + modelForScope: MODEL, + status: 429, + }); + assert.ok(built); + const scopeMap = built.nextProviderData.codexScopeRateLimitedUntil as Record; + assert.equal(scopeMap["other-scope"], "2999-12-31T00:00:00.000Z"); + assert.ok(scopeMap[getCodexModelScope(MODEL)]); +}); + +test("429 below the exhaustion threshold builds the snapshot but no cooldown / no log", () => { + const built = buildCodexQuotaPersistence({ + headers: quotaHeaders({ "x-codex-5h-usage": "1", "x-codex-7d-usage": "1" }), // ratios well under 0.95 + existingProviderData: {}, + modelForScope: MODEL, + status: 429, + }); + assert.ok(built); + assert.ok(built.nextProviderData.codexQuotaState); + assert.equal(built.exhaustionLog, null); + assert.equal(built.nextProviderData.codexScopeRateLimitedUntil, undefined); + assert.equal(built.nextProviderData.codexExhaustedWindow, undefined); +});