diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 60e3db08e6b..8a8547c02e2 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -207,6 +207,51 @@ order. Weights and `stickyLimit` do not affect this strategy. This ranking and provider exclusion before dispatch require fresh model-inference limits that apply to the current single API key as a whole. OAuth/current-account summaries, caller-forward routes, multiple keys, and snapshots with changed credentials or destinations are display-only for this early decision. The same applies when `Authorization`, `x-api-key`, or `x-goog-api-key` headers override credentials; search-only and MCP-only windows are excluded. If no eligible target has an applicable reset, configuration order wins. Account selection and retries still enforce their normal limits. +### Advisory quota signals for JEV + +Quota evidence is separately opt-in **per JEV Combo**, for all three decision methods: + +```bash +ocx combo set coding --decision-quota-signals on --json +ocx combo set coding --decision-quota-tiers '{"moderate":40}' --json +``` + +Only enable this on an existing `strategy: "jev"` Combo. `decisionQuotaSignals: true` adds +subscription quota tiers to the decision request, not a hard eligibility rule. The decision +backend is asked to prefer healthier quota among adequate options; it still chooses only from +configured eligible targets and effort allowlists. Unknown is not exhausted, and ordinary +fallback, cooldown, account selection and provider limits remain unchanged. + +Defaults are `limited: 70` and `nearlyExhausted: 90`, inclusive percentages used. There is no +moderate tier unless you set it explicitly (for example, `moderate: 40`). Partial overrides merge +with those defaults, then all thresholds must be finite, within 0–100 and strictly ascending. + +Evidence comes only from memory snapshots published by existing quota collectors, accepted +response headers and credential/account owners. Reading advisory quota evidence never resolves +credentials, loads auth stores or quota files, reads a keychain, probes upstream, or starts a +refresher. Normal decision-backend credential resolution is unchanged. Cold, missing, stale (over +30 minutes), future, invalid or reset-expired evidence is unknown. Each account uses its worst valid +relevant five-hour, weekly, monthly or structurally model-scoped window. An enabled usable +Anthropic/Codex pool uses its healthiest account tier; unknown ties healthy. A pool is nearly +exhausted only if every usable account has known nearly-exhausted evidence. Reserve models +without separately attributable bars remain unknown. Active-account summaries, multiple-key +display reports, credits and API/Spark/USD meters are not quota evidence for this decision. +The separate Anthropic · Pool 2 (`anthropic2`) account pool is not a source of this evidence, so its targets stay unknown. + +:::caution[Additional decision data] +Opting in sends quota tiers, and available used percentage/window/reset delay, to your selected +decision backend, including hosted TypeSafe. No account ids, tokens or credential fingerprints +are sent or logged by this feature. Local decision records retain only bounded tier counts and +the selected tier. If quota would exceed the 64 KiB request cap, it is removed before retrying +serialization; an ordinary oversized decision still fails open. +::: + +`--decision-quota-signals off` disables evidence while retaining configured tiers; `-` clears +the switch. `--decision-quota-tiers -` clears overrides, restoring defaults. API `null` clears; +API `false` disables the switch. Omitted fields survive JEV updates, target replacement and +rename, including saves through the existing editor. Switching away from JEV removes both +quota settings. No new editor controls or background quota work are enabled. + ### Decision method `strategy: "jev"` asks a decision backend to choose the first eligible target and a compatible diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 7c93eb7a21a..ddc9254eab5 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -264,6 +264,13 @@ opencodex route (for example `ollama/qwen3:4b`) that answers the same choice as this combo or any JEV combo. Omitting both uses TypeSafe. `--decision-timeout ` sets the decision deadline (1000–120000, default 4000); `-` clears any of the three. +`ocx combo set --decision-quota-signals on|off|-` controls per-Combo advisory evidence. +`--decision-quota-tiers '{"limited":80}'` sets partial threshold overrides; `-` clears them. +Both are JEV-only. Omitted policy survives `--targets-file` replacement, `--targets` with `--strategy jev`, and rename (`--targets` alone keeps the existing failover default). Off retains thresholds; +switching away from JEV removes both settings. This adds quota data to the selected decision +backend, not probes, new account selection or hard exclusions. See +[quota defaults, freshness and privacy](/guides/combos/#advisory-quota-signals-for-jev). + `ocx combo test [--combo ] [--decision-provider | --decision-model ] [--decision-timeout ]` sends one synthetic decision probe through a saved combo's method or an unsaved selection and reports the gate, backend, and latency; it may spend one decision call. diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index e0d0535b850..339eeabaaa8 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -154,6 +154,13 @@ namespace, and cannot use reserved bare native families such as `gpt-*`, `o1-*`, | `decisionProvider?` | `string` | `"jev"` | `strategy: "jev"` only. `"jev"` (the same as omitting it, and stored as omission) is the TypeSafe decision service, valid without a provider row; any other value must name a configured provider with `adapter: "jev-decision"` and a full HTTPS decision `baseUrl` (any path) or a local HTTP `/systemone` endpoint. Userinfo, query strings, and fragments are refused. | | `decisionModel?` | `string` | unset | `strategy: "jev"` only, mutually exclusive with `decisionProvider`. An ordinary opencodex route (for example `ollama/qwen3:4b`) asked to pick one offered option as JSON. It runs with the selected provider's stored credentials, never the caller's, and cannot resolve to this combo, any JEV combo, or a `jev-decision` row. | | `decisionTimeoutMs?` | `number` | `4000` | `strategy: "jev"` only. Decision deadline before failing open, 1000–120000 ms. | +| `decisionQuotaSignals?` | `boolean` | `false` | JEV only. Explicit `true` sends loaded advisory quota evidence to the selected backend; never changes hard eligibility or account selection. | +| `decisionQuotaTiers?` | `{ moderate?: number; limited?: number; nearlyExhausted?: number }` | `{ limited: 70, nearlyExhausted: 90 }` | JEV only. Partial overrides merge with defaults; finite 0–100 values must be strictly ascending. `moderate` is absent unless explicit. Does not enable signals by itself. | + +Quota settings, privacy and unknown-pool semantics are described in +[Advisory quota signals for JEV](/guides/combos/#advisory-quota-signals-for-jev). +Omitted settings survive JEV API updates/rename; `null` clears, `false` disables signals without +clearing tiers, and switching to another strategy removes both settings. ```json { diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 3ff37c9e98b..718bd8a8705 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -539,7 +539,7 @@ "codex-account-label.test.ts": "codex-integration", "codex-account-mode-state.test.ts": "gui", "codex-account-namespaces.test.ts": "codex-integration", - "codex-account-selection-preferences.test.ts": "codex-integration", + "codex-account-selection-preferences.test.ts": "codex-integration", "codex-account-store-quota-isolation.test.ts": "codex-integration", "codex-account-store-refresh-classification.test.ts": "codex-integration", "codex-account-store.test.ts": "codex-integration", "codex-account-threshold-api.test.ts": "codex-integration", @@ -851,7 +851,7 @@ "debug-settings.test.ts": "server", "debug.test.ts": "lib", "decision-discovery.test.ts": "server", - "server-jev-model-decision-e2e.test.ts": "server", + "server-jev-model-decision-e2e.test.ts": "server", "server-jev-model-quota-budget.test.ts": "server", "decision-routes.test.ts": "server", "deepinfra-provider.test.ts": "providers", "deepseek-inbound-wire.test.ts": "providers", @@ -1057,7 +1057,7 @@ "key-login-preserves-model-costs.test.ts": "oauth", "jev-decision.test.ts": "routing", "jev-decision-destination.test.ts": "routing", "jev-service-exchange.test.ts": "routing", "jev-decision-provider-combo.test.ts": "routing", - "jev-typesafe-golden.test.ts": "routing", + "jev-typesafe-golden.test.ts": "routing", "jev-quota-signals.test.ts": "routing", "jev-quota-config.test.ts": "routing", "jev-quota-publication.test.ts": "routing", "jev-quota-codex-reset.test.ts": "routing", "jev-quota-anthropic2.test.ts": "routing", "jev-model-backend.test.ts": "routing", "jev-provider.test.ts": "providers", "keyring-smoke.test.ts": "ci-workflows", @@ -1307,7 +1307,7 @@ "oauth-refresh-lock-multiprocess.test.ts": "oauth", "oauth-refresh.test.ts": "oauth", "oauth-status-privacy.test.ts": "oauth", - "oauth-store-multi.test.ts": "oauth", + "oauth-store-multi.test.ts": "oauth", "oauth-store-quota-isolation.test.ts": "oauth", "oauth-tos-warning.test.ts": "gui", "oauth-upsert-preserves-api-key.test.ts": "oauth", "ocx-launcher-runtime.test.ts": "cli", diff --git a/skills/ocx/references/01_surface_agents-routing.md b/skills/ocx/references/01_surface_agents-routing.md index 85d352391b5..643b45d760a 100644 --- a/skills/ocx/references/01_surface_agents-routing.md +++ b/skills/ocx/references/01_surface_agents-routing.md @@ -747,7 +747,7 @@ JSON mode: `payload`. ### `ocx combo set` -Usage: `ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] [--effort ] [--effort-mode ] [--alias ] [--native-alias [on|off]] [--display-name ] [--decision-provider | --decision-model ] [--decision-timeout ] [--rename-from ] [--image-input ] [--reasoning-effort-mode ] [--json]` +Usage: `ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] [--effort ] [--effort-mode ] [--alias ] [--native-alias [on|off]] [--display-name ] [--decision-provider | --decision-model ] [--decision-timeout ] [--decision-quota-signals ] [--decision-quota-tiers ] [--rename-from ] [--image-input ] [--reasoning-effort-mode ] [--json]` Create, update or rename a combo. @@ -770,6 +770,8 @@ State-changing: yes. | `--decision-provider` | string | JEV provider; - clears. | | `--decision-model` | string | JEV route; - clears; exclusive with provider. | | `--decision-timeout` | string | JEV deadline 1000-120000 ms; - clears. | +| `--decision-quota-signals` | string | JEV advisory quota on/off; - clears. | +| `--decision-quota-tiers` | string | Partial JSON thresholds; - clears. | | `--rename-from` | string | Existing combo ID to rename. | | `--json` | boolean | Emit structured JSON. | | `--native-alias` | string | Bare legacy flag means on; explicit on/off preserves presence, including false. | diff --git a/src/cli/capabilities-agents-routing.ts b/src/cli/capabilities-agents-routing.ts index f043fd33fdf..f861912f611 100644 --- a/src/cli/capabilities-agents-routing.ts +++ b/src/cli/capabilities-agents-routing.ts @@ -277,10 +277,10 @@ export const AGENT_ROUTING_CAPABILITIES: readonly Capability[] = [ }, { command: ["combo","set"], - usage: "ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] [--effort ] [--effort-mode ] [--alias ] [--native-alias [on|off]] [--display-name ] [--decision-provider | --decision-model ] [--decision-timeout ] [--rename-from ] [--image-input ] [--reasoning-effort-mode ] [--json]", + usage: "ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] [--effort ] [--effort-mode ] [--alias ] [--native-alias [on|off]] [--display-name ] [--decision-provider | --decision-model ] [--decision-timeout ] [--decision-quota-signals ] [--decision-quota-tiers ] [--rename-from ] [--image-input ] [--reasoning-effort-mode ] [--json]", summary: "Create, update or rename a combo.", routes: [{"method":"GET","path":"/api/combos"},{"method":"PUT","path":"/api/combos"}], - flags: [{"name":"--targets","value":"string","summary":"Ordered provider/model[:weight] list; required for creation."},{"name":"--strategy","value":"string","summary":"Routing strategy."},{"name":"--sticky","value":"string","summary":"Round-robin limit 1-100; - resets."},{"name":"--effort","value":"string","summary":"Default effort; - clears."},{"name":"--effort-mode","value":"string","summary":"fallback or force; - resets to fallback."},{"name":"--alias","value":"string","summary":"Alias; - clears."},{"name":"--display-name","value":"string","summary":"Label; - clears."},{"name":"--decision-provider","value":"string","summary":"JEV provider; - clears."},{"name":"--decision-model","value":"string","summary":"JEV route; - clears; exclusive with provider."},{"name":"--decision-timeout","value":"string","summary":"JEV deadline 1000-120000 ms; - clears."},{"name":"--rename-from","value":"string","summary":"Existing combo ID to rename."},{"name":"--json","value":"boolean","summary":"Emit structured JSON."},{"name":"--native-alias","value":"string","summary":"Bare legacy flag means on; explicit on/off preserves presence, including false."},{"name":"--targets-file","value":"string","summary":"Bounded ordered target JSON array, exclusive with --targets; preserves effort/profile/last-resort metadata."},{"name":"--image-input","value":"string","summary":"auto or disabled; explicit auto clears the previous disabled choice."},{"name":"--reasoning-effort-mode","value":"string","summary":"strict or adaptive, distinct from --effort-mode fallback/force."}], + flags: [{"name":"--targets","value":"string","summary":"Ordered provider/model[:weight] list; required for creation."},{"name":"--strategy","value":"string","summary":"Routing strategy."},{"name":"--sticky","value":"string","summary":"Round-robin limit 1-100; - resets."},{"name":"--effort","value":"string","summary":"Default effort; - clears."},{"name":"--effort-mode","value":"string","summary":"fallback or force; - resets to fallback."},{"name":"--alias","value":"string","summary":"Alias; - clears."},{"name":"--display-name","value":"string","summary":"Label; - clears."},{"name":"--decision-provider","value":"string","summary":"JEV provider; - clears."},{"name":"--decision-model","value":"string","summary":"JEV route; - clears; exclusive with provider."},{"name":"--decision-timeout","value":"string","summary":"JEV deadline 1000-120000 ms; - clears."},{"name":"--decision-quota-signals","value":"string","summary":"JEV advisory quota on/off; - clears."},{"name":"--decision-quota-tiers","value":"string","summary":"Partial JSON thresholds; - clears."},{"name":"--rename-from","value":"string","summary":"Existing combo ID to rename."},{"name":"--json","value":"boolean","summary":"Emit structured JSON."},{"name":"--native-alias","value":"string","summary":"Bare legacy flag means on; explicit on/off preserves presence, including false."},{"name":"--targets-file","value":"string","summary":"Bounded ordered target JSON array, exclusive with --targets; preserves effort/profile/last-resort metadata."},{"name":"--image-input","value":"string","summary":"auto or disabled; explicit auto clears the previous disabled choice."},{"name":"--reasoning-effort-mode","value":"string","summary":"strict or adaptive, distinct from --effort-mode fallback/force."}], mutates: true, json: "payload", details: ["create and update are aliases of set. Existing fields are read before writing; --targets replaces targets.","force overrides valid client effort and may increase cost and latency.","Only the implemented target grammar is supported; full GUI target metadata and routing-profile writes are separate tasks.","New file/flag conflicts refuse before transport. Partial edits preserve target metadata; explicit false/default values override server carry. Turning a native alias off may also require clearing its incompatible alias.","Read/merge/write uses one target and remains an upsert without revision CAS. Saved-but-incomplete catalog outcomes return nonzero with their receipt."], diff --git a/src/cli/combo-result.ts b/src/cli/combo-result.ts index 0b90b0b5a61..ed632c15f32 100644 --- a/src/cli/combo-result.ts +++ b/src/cli/combo-result.ts @@ -1,3 +1,4 @@ +import { normalizeJevQuotaTiers } from "../combos/jev-quota-config"; import { parseComboTargets } from "./combo-input"; import { printCatalogResult } from "./catalog-command-result"; @@ -33,7 +34,12 @@ function publicCombo(value: unknown): Record { } else if (Object.hasOwn(RANGES, key)) { const [min, max] = RANGES[key]!; if (typeof child !== "number" || !Number.isSafeInteger(child) || child < min || child > max) invalid(); - } else if (key === "nativeAlias") { + } else if (key === "decisionQuotaTiers") { + const tiers = normalizeJevQuotaTiers(child); + if (!tiers) invalid(); + result[key] = tiers; + continue; + } else if (key === "nativeAlias" || key === "decisionQuotaSignals") { if (typeof child !== "boolean") invalid(); } else invalid(); result[key] = child; diff --git a/src/cli/combo.ts b/src/cli/combo.ts index ad4c1e52e75..05caa9b72aa 100644 --- a/src/cli/combo.ts +++ b/src/cli/combo.ts @@ -1,3 +1,4 @@ +import { normalizeJevQuotaTiers } from "../combos/jev-quota-config"; import { JEV_DECISION_TIMEOUT_MAX_MS, JEV_DECISION_TIMEOUT_MIN_MS } from "../combos/types"; import { CliUsageError, @@ -26,6 +27,7 @@ const USAGE = `Usage: [--native-alias [on|off]] [--display-name ] [--image-input ] [--reasoning-effort-mode ] [--decision-provider | --decision-model ] [--decision-timeout ] + [--decision-quota-signals ] [--decision-quota-tiers ] (jev only; the provider must be a configured jev-decision row) [--rename-from ] [--json] ocx combo stats [--range <7d|30d|all>] [--json] @@ -80,6 +82,7 @@ async function show(argv: string[], deps: RuntimeApiDeps): Promise { printData(combo, wantsJson); } +/** Handle `ocx combo set`: parse the combo id and flags (including `--decision-quota-signals` and `--decision-quota-tiers`), then create or update the combo through the management API. */ async function set(argv: string[], deps: RuntimeApiDeps): Promise { const args = [...argv]; const id = args.shift()?.trim(); @@ -129,6 +132,13 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise { if (decisionProvider !== undefined && decisionProvider !== "-" && strategy !== "jev") { throw new CliUsageError("--decision-provider applies only to the jev strategy", USAGE); } + const quotaSignals = takeOption(args, "--decision-quota-signals"); + const quotaTiers = takeOption(args, "--decision-quota-tiers"); + if (quotaSignals !== undefined && !["on", "off", "-"].includes(quotaSignals)) throw new CliUsageError("--decision-quota-signals must be on, off or -", USAGE); + let parsedQuotaTiers: ReturnType | null; + try { parsedQuotaTiers = quotaTiers === undefined || quotaTiers === "-" ? null : normalizeJevQuotaTiers(JSON.parse(quotaTiers)); } catch { throw new CliUsageError("invalid --decision-quota-tiers JSON", USAGE); } + if (quotaTiers !== undefined && quotaTiers !== "-" && !parsedQuotaTiers) throw new CliUsageError("quota tiers must be finite 0..100 and strictly ascending", USAGE); + if (strategy !== "jev" && (quotaSignals !== undefined && quotaSignals !== "-" || quotaTiers !== undefined && quotaTiers !== "-")) throw new CliUsageError("quota settings apply only to jev", USAGE); const decisionTimeout = takeOption(args, "--decision-timeout"); let decisionTimeoutMs: number | null | undefined; if (decisionTimeout !== undefined) { @@ -168,10 +178,14 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise { if (decisionProvider !== undefined && decisionProvider !== "-") combo.decisionModel = null; if (decisionModel !== undefined && decisionModel !== "-") combo.decisionProvider = null; if (decisionTimeoutMs !== undefined) combo.decisionTimeoutMs = decisionTimeoutMs; + if (quotaSignals !== undefined) combo.decisionQuotaSignals = quotaSignals === "-" ? null : quotaSignals === "on"; + if (quotaTiers !== undefined) combo.decisionQuotaTiers = parsedQuotaTiers; if (strategy !== "jev") { delete combo.decisionProvider; delete combo.decisionModel; delete combo.decisionTimeoutMs; + delete combo.decisionQuotaSignals; + delete combo.decisionQuotaTiers; } const current = partialCurrent ?? await runtimeRequest<{ combos?: ComboRow[] }>("/api/combos", { redirect: "error" }, deps); const existing = (current.combos ?? []).find(row => row.id === (renameFrom ?? id)); diff --git a/src/codex/account-runtime-state.ts b/src/codex/account-runtime-state.ts index e30bb507c6e..a727022c8da 100644 --- a/src/codex/account-runtime-state.ts +++ b/src/codex/account-runtime-state.ts @@ -1,3 +1,4 @@ +import { setDecisionAccountUsable } from "../providers/quota-decision-snapshot"; import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper"; import { isCodexAccountGenerationLive } from "./account-store"; @@ -39,12 +40,14 @@ export function clearMainRefreshGrantRejection(key: string): void { } +/** Quarantine a Codex account (or one credential generation) after an auth failure, and mark its advisory decision-quota row unusable so routing evidence cannot treat it as healthy. */ export function markAccountNeedsReauth( id: string, writerGeneration = captureConfigGeneration(), credentialGeneration?: number, ): void { if (writerGeneration < lastReconciledGeneration && !liveAccountIds.has(id)) return; + setDecisionAccountUsable(id === "__main__" ? "codex-main" : "codex", id, false, credentialGeneration); // An account-wide mark supersedes a generation-scoped one: it is the stronger claim. if (credentialGeneration === undefined || !reauthAccounts.has(id)) { reauthAccounts.set(id, credentialGeneration); @@ -81,6 +84,7 @@ export function isAccountNeedsReauth(id: string): boolean { return true; } +/** Lift a reauth quarantine only when the proof matches the quarantined credential generation, and restore the advisory decision-quota row's usability. */ export function clearAccountNeedsReauth(id: string, credentialGeneration?: number): void { // A model response proves only the credential it used. Keep account-wide // quarantine and evidence from another generation intact. @@ -88,4 +92,5 @@ export function clearAccountNeedsReauth(id: string, credentialGeneration?: numbe && (reauthAccounts.get(id) !== credentialGeneration || !isCodexAccountGenerationLive(id, credentialGeneration))) return; reauthAccounts.delete(id); + setDecisionAccountUsable(id === "__main__" ? "codex-main" : "codex", id, true, credentialGeneration); } diff --git a/src/codex/account-store.ts b/src/codex/account-store.ts index 6fceeee8d8f..580000c6cac 100644 --- a/src/codex/account-store.ts +++ b/src/codex/account-store.ts @@ -14,6 +14,7 @@ import { hardenExistingSecret, withConfigMutationLockSync, } from "../config"; +import { publishCodexDecisionQuotaRoster } from "./store-decision-quota"; import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "../types"; import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch"; @@ -113,30 +114,36 @@ function normalizeRecord(value: CodexAccountCredentials | CodexAccountCredential return undefined; } +/** Read the Codex account record store from disk with hardened permissions, and republish the secret-free decision-quota roster derived from it. */ function loadCodexAccountRecordStore(): CodexAccountStore { const path = codexAccountsPath(); hardenConfigDir(); hardenExistingSecret(path); - if (!existsSync(path)) return {}; + if (!existsSync(path)) { publishCodexDecisionQuotaRoster({}); return {}; } + const normalized: CodexAccountStore = {}; try { const raw = JSON.parse(readFileSync(path, "utf-8")) as RawCodexAccountStore; - const normalized: CodexAccountStore = {}; for (const [id, value] of Object.entries(raw)) { const record = normalizeRecord(value); if (record) normalized[id] = record; } - return normalized; } catch { backupInvalidConfig(path); + publishCodexDecisionQuotaRoster({}); return {}; } + // Outside the parse try: roster publication is advisory and must never route a loaded store into the invalid-config backup path. + publishCodexDecisionQuotaRoster(normalized); + return normalized; } +/** Atomically write the Codex account store and republish the decision-quota roster so published evidence tracks the persisted credentials. */ function persist(store: CodexAccountStore): void { const dir = getConfigDir(); assertNotRealHomeUnderTest(dir); if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); atomicWriteFile(codexAccountsPath(), JSON.stringify(store, null, 2) + "\n"); + publishCodexDecisionQuotaRoster(store); } function persistCredentialMutation(store: CodexAccountStore): void { diff --git a/src/codex/auth-api/main-account-probe.ts b/src/codex/auth-api/main-account-probe.ts index 5e208e5e1ec..a8ae999f06f 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -6,7 +6,7 @@ import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../. import { providerCodexAccountMode } from "../../providers/registry"; import { isSelectableCodexPoolAccount } from "../account-id"; import type { OcxConfig } from "../../types"; -import { parseMainPolicyUsageQuota, parseUsageQuota, setAccountQuotaFromParsed } from "../quota"; +import { isValidWhamHistoryObservation, parseMainPolicyUsageQuota, parseUsageQuota, setAccountQuotaFromParsed } from "../quota"; import type { StoredAccountQuota, WhamUsageResponse } from "../quota"; import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle"; import { getMainChatgptAccountId, readCodexTokensResult } from "../auth-collision"; @@ -278,6 +278,7 @@ export async function fetchMainAccountInfoWhileOwned( let quotaPhase: "request" | "body" | "decode" | "publish" = "request"; let quotaRefreshGeneration = captureMainAccountIdentityGeneration(); const readState: { owner?: CodexUsageOwner; usable: boolean } = { usable: false }; + /** Perform one single-flight main-account usage read: classify auth failures and publish parsed quota and decision evidence only while the dispatch and credential are still current. */ const read = async (): Promise => { try { let dispatchSequence = 0; @@ -398,7 +399,7 @@ export async function fetchMainAccountInfoWhileOwned( // score and auto-switch the main account exactly like a pool account (Option A). setMainAccountPlan(result.plan); if (result.quota) { - setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration, mainQuotaWriter, policyQuota); + setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration, mainQuotaWriter, policyQuota, undefined, false, false, isValidWhamHistoryObservation(usage) ? quota : null); } publishQuotaDispatch(dispatchSequence); readState.usable = quota !== null; diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 3903e295fde..fc76be65ecf 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -66,7 +66,7 @@ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "./cata import type { CodexCooldownSource, CodexQuotaScope } from "./routing"; import { maskAccountId } from "../lib/privacy"; import { formatErrorResponse } from "../bridge"; -import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, getMainPolicyQuota, parseUsageQuota, parseMainPolicyUsageQuota, setAccountQuotaFromParsed } from "./quota"; +import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, getMainPolicyQuota, isValidWhamHistoryObservation, parseUsageQuota, parseMainPolicyUsageQuota, setAccountQuotaFromParsed } from "./quota"; import { codexAccountUsesCreditsAfterLimit, codexUsageLimitResetAt } from "./account-credit-use"; import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types"; import { FORWARD_HEADERS } from "../adapters/openai-responses"; @@ -654,6 +654,7 @@ function assertReserveAdmission(config: CodexAuthPolicyConfig): void { } } +/** Authorize a reserve request for the main credential, re-checking admission and writer liveness, and publish the ordinary quota observed during the capability read. */ async function authorizeReserveCredential( token: { accessToken: string; chatgptAccountId: string }, writer: MainQuotaWriter | undefined, @@ -670,9 +671,11 @@ async function authorizeReserveCredential( const authorization = isMainReserveAuthorizationLive(existing, token) ? existing : await getMainReserveAuthorization({ token, writer, signal, + /** Publish the ordinary usage observed during the reserve capability read: parse it once and reuse that observation as the advisory decision evidence when it is a valid history observation. */ observeOrdinaryQuota(data, capturedWriter) { - setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, parseUsageQuota(data), writerGeneration, - capturedWriter, parseMainPolicyUsageQuota(data)); + const parsed = parseUsageQuota(data); + setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, parsed, writerGeneration, + capturedWriter, parseMainPolicyUsageQuota(data), undefined, false, false, isValidWhamHistoryObservation(data) ? parsed : null); }, }); // The capability read also publishes ordinary quota. A new 99% reading or cooldown wins. diff --git a/src/codex/main-account-cache.ts b/src/codex/main-account-cache.ts index 3a46180e672..f207168a7e7 100644 --- a/src/codex/main-account-cache.ts +++ b/src/codex/main-account-cache.ts @@ -1,3 +1,4 @@ +import { publishDecisionQuotaRoster, withdrawDecisionQuotaRoster } from "../providers/quota-decision-snapshot"; import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { codexCredentialMutationEpoch } from "./credential-mutation-epoch"; import type { StoredAccountQuota } from "./quota-types"; @@ -22,11 +23,23 @@ let observedMainQuotaIdentityKey: string | undefined; const mainQuotaCredentialKey = randomBytes(32); let mainQuotaCredential: { bearerHmac: Buffer; writer: MainQuotaWriter } | undefined; let mainQuotaCredentialGeneration = 0; +let mainDecisionCredentialUsable = true; +/** Existing physical credential owner supplies terminal grant/usability observations. */ +export function observeMainDecisionCredentialUsable(usable: boolean): void { + mainDecisionCredentialUsable = usable; + publishMainDecisionRoster(); +} /** Process-local transition fence; no credential material or persisted identity. */ export function getMainQuotaCredentialGeneration(): number { return mainQuotaCredentialGeneration; } export type MainQuotaWriter = Readonly<{ identityKey: string; identityGeneration: number }>; +const decisionWriterGenerations = new WeakMap(); +/** Return the credential generation a main quota writer was captured under, or undefined once that writer is stale or its credential generation has moved. */ +export function mainDecisionQuotaWriterGeneration(writer: MainQuotaWriter): number | undefined { + const generation = decisionWriterGenerations.get(writer); + return generation === mainQuotaCredentialGeneration && isMainQuotaWriterLive(writer) ? generation : undefined; +} function mainQuotaIdentityKey(accountId: string): string { return createHash("sha256").update("opencodex-main-quota-v1\0").update(accountId).digest("hex"); @@ -38,16 +51,21 @@ export function observeMainQuotaIdentity(accountId: string): void { const identityKey = mainQuotaIdentityKey(accountId); if (identityKey === observedMainQuotaIdentityKey) return; observedMainQuotaIdentityKey = identityKey; + mainDecisionCredentialUsable = true; mainAccountIdentityGeneration += 1; mainQuotaCredential = undefined; mainQuotaCredentialGeneration += 1; + publishMainDecisionRoster(); } +/** Capture a main quota writer for an account id only when it matches the observed physical identity, remembering the credential generation it was captured under. */ export function captureMainQuotaWriter(accountId: string): MainQuotaWriter | undefined { if (!accountId) return undefined; const identityKey = mainQuotaIdentityKey(accountId); if (identityKey !== observedMainQuotaIdentityKey) return undefined; - return { identityKey, identityGeneration: mainAccountIdentityGeneration }; + const writer = { identityKey, identityGeneration: mainAccountIdentityGeneration }; + if (mainQuotaCredential) decisionWriterGenerations.set(writer, mainQuotaCredentialGeneration); + return writer; } /** Credential material must come from an already-owned read, never an incoming request. */ @@ -58,7 +76,10 @@ export function observeMainQuotaCredential(accessToken: string, accountId: strin if (!mainQuotaCredential || !isMainQuotaWriterLive(mainQuotaCredential.writer) || !timingSafeEqual(bearerHmac, mainQuotaCredential.bearerHmac)) mainQuotaCredentialGeneration += 1; mainQuotaCredential = { bearerHmac, writer }; - return { ...writer }; + publishMainDecisionRoster(); + const result = { ...writer }; + decisionWriterGenerations.set(result, mainQuotaCredentialGeneration); + return result; } export function matchesMainQuotaCredential(accessToken: string, effectiveAccountId: string | undefined): boolean { @@ -140,11 +161,13 @@ export function setMainAccountInfoCache(value: CachedMainAccountInfo): void { }; } +/** Drop the cached main-account info and invalidate the observed identity and credential generation, then republish the main decision roster as unusable. */ export function clearMainAccountInfoCache(): void { cachedMainAccountInfo = null; mainAccountIdentityGeneration += 1; mainQuotaCredential = undefined; mainQuotaCredentialGeneration += 1; + publishMainDecisionRoster(); } /** Last physical credential presence observed while native-main ownership was held. */ @@ -152,10 +175,29 @@ export function getMainAccountCredentialPresence(): boolean | null { return cachedMainCredentialPresence; } +/** Record whether the main credential is physically present; a transition to absent invalidates the observed credential generation. Republishes the main decision roster. */ export function setMainAccountCredentialPresence(present: boolean): void { + if (!present && cachedMainCredentialPresence !== false) { + mainQuotaCredential = undefined; mainQuotaCredentialGeneration += 1; + } cachedMainCredentialPresence = present; + publishMainDecisionRoster(); } +/** Forget the last observed main credential presence and invalidate the observed credential generation, then republish the main decision roster. */ export function clearMainAccountCredentialPresence(): void { cachedMainCredentialPresence = null; + mainQuotaCredential = undefined; mainQuotaCredentialGeneration += 1; + publishMainDecisionRoster(); +} + +/** Publish the single `__main__` roster row for advisory decision quota; it is usable only while a credential is observed and present. */ +function publishMainDecisionRoster(): void { + try { + publishDecisionQuotaRoster("codex-main", [{ id: "__main__", generation: mainQuotaCredentialGeneration, + usable: mainDecisionCredentialUsable && cachedMainCredentialPresence !== false && mainQuotaCredential !== undefined }]); + } catch { + // Advisory evidence must never alter main-credential cache state transitions; on failure the main roster becomes unknown. + try { withdrawDecisionQuotaRoster("codex-main"); } catch { /* best-effort */ } + } } diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index 2fa128737ae..d297a05375b 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -1,3 +1,4 @@ +import { observeMainDecisionCredentialUsable } from "./main-account-cache"; import { classifyChatgptRefreshFailure, noteChatgptRefreshFailure } from "./chatgpt-refresh-failure"; import { readBoundedResponseBody } from "../lib/bounded-body"; import { createHash } from "node:crypto"; @@ -239,10 +240,12 @@ export function getMainAccountCredentialStatus(now = Date.now()): MainAccountCre const current = readMainAuthJsonCredential(); const refreshGrantRejected = credentialRefreshGrantRejected(current); const hasRefreshGrant = !!current?.refreshToken && !refreshGrantRejected; + const usable = !refreshGrantRejected && (hasRefreshGrant || mainAccessTokenFresh(current?.accessToken, now, 0)); + observeMainDecisionCredentialUsable(usable); return { refreshGrantRejected, hasRefreshGrant, - usable: !refreshGrantRejected && (hasRefreshGrant || mainAccessTokenFresh(current?.accessToken, now, 0)), + usable, }; } @@ -441,6 +444,7 @@ export function beginNativeMainReauth(): { }; } +/** Resolve the main account access token from the owned auth.json read, refreshing through the token endpoint when needed, or return null when no usable credential exists. */ async function resolveMainAccountToken( dependencies: NativeMainRefreshDependencies = {}, rejectedAccessToken?: string, @@ -532,7 +536,7 @@ async function resolveMainAccountToken( if (signal.aborted) throw signal.reason; if (reason === "reauth") { assertMainAuthJsonSnapshotUnchanged(locked); - markMainRefreshGrantRejected(mainRefreshGrantKey(locked)!); + markMainRefreshGrantRejected(mainRefreshGrantKey(locked)!); observeMainDecisionCredentialUsable(false); } throw new MainAccountTokenRefreshError(reason, { cause }); } diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 4c737036595..ade1ba2bdcc 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -1,3 +1,5 @@ +import { bindRawCodexDecisionResets, publishCodexDecisionQuota } from "../providers/quota-decision-publication"; +import { clearDecisionAccountQuotas } from "../providers/quota-decision-snapshot"; import { parseCodexCredits } from "./credits"; import { forgetMainAccountUsage, observeMainAccountUsage, type FreshWindow } from "./main-account-external-usage"; import { closeSync, constants as fsConstants, existsSync, fstatSync, openSync, readSync, unlinkSync } from "node:fs"; @@ -8,7 +10,7 @@ import { isThirtyDayOnlyCodexPlan } from "./plan"; import { stampCodexQuotaUsageObservation } from "./quota-observation-freshness"; import { observeCodexLowQuota } from "./low-quota-observer"; import { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; -import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaWriter } from "./main-account-cache"; +import { getObservedMainQuotaIdentityKey, mainDecisionQuotaWriterGeneration, isMainQuotaWriterLive, type MainQuotaWriter } from "./main-account-cache"; import { CodexQuotaHistory, QUOTA_HISTORY_LIMITS, type QuotaHistoryWindow } from "./quota-history"; import { isPoolQuotaWriterLive, poolQuotaHistoryIdentity } from "./account-store"; @@ -301,6 +303,9 @@ export function setAccountQuotaFromParsed( // writer: when capture failed before dispatch, an absent writer must fail closed instead // of reading as a writer-free legacy/login observation. poolRequest = false, + decisionObservationPartial = true, + // Advisory publication is explicit opt-in evidence: display or policy data never becomes it by default. + decisionRaw: Omit | null = null, ): void { quota = withoutRetiredCodexQuota(quota); policyQuota = withoutRetiredCodexQuota(policyQuota); @@ -315,12 +320,16 @@ export function setAccountQuotaFromParsed( ? historyEvidence.writer.accountId === accountId && isPoolQuotaWriterLive(historyEvidence.writer) : !poolRequest; if (historyEvidence && livePoolEvidence) { + publishCodexDecisionQuota(accountId, historyEvidence.writer.credentialGeneration, historyEvidence.raw, historyEvidence.observedAt, "codex", historyEvidence.source === "response-header"); quotaHistory.append(historyEvidence.writer, { observedAt: historyEvidence.observedAt, source: historyEvidence.source, credentialGeneration: historyEvidence.writer.credentialGeneration, windows: historyWindows(historyEvidence.raw), }, updatedAt); } // Legacy rotation keeps its existing carry behavior, but never inherits policy-only // evidence that outlived its disk TTL. Policy has a separate, identity-checked base. + const mainDecisionGeneration = mainWriter && mainDecisionQuotaWriterGeneration(mainWriter); + if (isMain && mainDecisionGeneration !== undefined && decisionRaw) + publishCodexDecisionQuota(accountId, mainDecisionGeneration, decisionRaw, updatedAt, "codex-main", decisionObservationPartial); const next = mergeAccountQuota(quota, legacyExisting, updatedAt); accountQuota.set(accountId, next); if (isMain) { @@ -541,6 +550,7 @@ export function withoutRetiredCodexQuota | null { if (isRetiredCodexSparkModel(options?.modelId)) return null; const primaryRaw = headers.get("x-codex-primary-used-percent"); @@ -607,9 +617,15 @@ export function parseUpstreamQuotaHeaders(headers: Headers, options?: { modelId? if (tertiaryResetAt !== undefined) quota.monthlyResetAt = tertiaryResetAt; } + bindRawCodexDecisionResets(quota, { + short: primaryResetRaw, + weekly: primaryIsMonthly || primaryIsShort || primaryPercent === undefined ? secondaryResetRaw : primaryResetRaw, + monthly: primaryIsMonthly && primaryPercent !== undefined ? primaryResetRaw : tertiaryResetRaw, + }, Date.now()); return hasKnownQuotaValue(quota) ? quota : null; } +/** Apply upstream rate-limit headers for an account: publish display and policy quota and, only for a valid observation, the advisory decision evidence. */ export function applyAccountQuotaFromUpstreamHeaders( accountId: string, headers: Headers, @@ -626,7 +642,7 @@ export function applyAccountQuotaFromUpstreamHeaders( .some(name => isInvalidPolicyUsagePercent(headers.get(name))); setAccountQuotaFromParsed(accountId, quota, writerGeneration, mainWriter, policyQuota, options?.poolWriter && validHistory ? { writer: options.poolWriter, observedAt: Date.now(), source: "response-header", raw: quota } : undefined, - options?.poolResponse === true); + options?.poolResponse === true, true, validHistory ? quota : null); } export function updateAccountQuota( @@ -819,7 +835,10 @@ function forgetCodexQuotaBaseline(accountId?: string): void { }); } +/** Clear stored quota, advisory decision evidence and history for one account, or for all accounts when no id is given. */ export function clearAccountQuota(accountId?: string): void { + clearDecisionAccountQuotas("codex", accountId); + if (!accountId || accountId === MAIN_CODEX_ACCOUNT_ID) clearDecisionAccountQuotas("codex-main"); if (!accountId || accountId === MAIN_CODEX_ACCOUNT_ID) forgetMainAccountUsage(); if (accountId) hydrateAccountQuotasFromDisk(); quotaHistory.clear(accountId); @@ -1002,6 +1021,11 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit): void { + try { projectCodexDecisionQuotaRoster(store); } + catch { + // Advisory evidence must never alter Codex account-store loading or persistence. + try { withdrawDecisionQuotaRoster("codex"); } catch { /* best-effort; the store result is already decided */ } + } +} +/** Project a loaded store into the roster that advisory decision evidence is bound to; may throw, so callers go through the guarded publisher. */ +function projectCodexDecisionQuotaRoster(store: Record): void { + publishDecisionQuotaRoster("codex", Object.entries(store).map(([id, row]) => ({ + id, generation: row.generation, + usable: !!row.credential && row.deletedAt == null && !row.codexValidationPending && !row.lastCodexValidationTerminal, + }))); +} diff --git a/src/combos/jev-model-backend.ts b/src/combos/jev-model-backend.ts index 9937018f3d4..64c813cf271 100644 --- a/src/combos/jev-model-backend.ts +++ b/src/combos/jev-model-backend.ts @@ -1,3 +1,6 @@ +import { boundedJevQuotaPayload, jevQuotaCandidates, withJevQuotaSummary } from "./jev-quota-route"; +import { JEV_QUOTA_INSTRUCTION } from "./jev-quota"; +import { serializeJevModelRequest, type JevModelRequestBody } from "./jev-model-request"; import { buildJevState, candidatesFitRequestBounds, @@ -25,11 +28,14 @@ export interface JevModelInvokeRequest { instructions: string; input: string; signal: AbortSignal; + /** Budget-only fallback, selected by the detached invoker before admission or dispatch. */ + withoutQuota?: Pick; } export interface JevModelInvokeResult { text: string; usage?: Record; + quotaOmitted?: true; } export type JevModelInvoke = (request: JevModelInvokeRequest) => Promise; @@ -69,29 +75,45 @@ export function parseJevModelChoice(text: string, allowed: ReadonlySet): return choice; } +/** Resolve a JEV decision through the decision model, falling back with a gate reason on any failure; quota tiers are advisory input only and never widen the allowlist. */ export async function resolveJevModelDecision( options: ResolveJevDecisionOptions & { decisionModel: string; invokeModel: JevModelInvoke }, ): Promise { const now = options.now ?? Date.now; const startedAt = now(); + let candidates = options.candidates; + /** Build the fallback decision for a failure gate, attaching the quota summary for the candidates in scope. */ const failed = (gate: Exclude): JevDecision => - fallbackDecision(options.fallback, gate, Math.max(0, now() - startedAt), "model"); + withJevQuotaSummary(fallbackDecision(options.fallback, gate, Math.max(0, now() - startedAt), "model"), candidates); if (options.signal?.aborted) throw options.signal.reason; if (options.candidates.length === 0) return failed("no_choices"); if (!candidatesFitRequestBounds(options.candidates)) return failed("invalid"); let routeOptions: JevRouteOptionDescriptor[]; - let input: string; + let input = ""; + let instructions = JEV_MODEL_INSTRUCTIONS; + let withoutQuota: JevModelInvokeRequest["withoutQuota"]; try { routeOptions = jevRouteOptions(options.candidates); if (routeOptions.length > JEV_MODEL_MAX_OPTIONS) return failed("invalid"); const state = buildJevState(options.body, options.candidates); if (!hasJevDecisionState(state)) return failed("no_state"); - input = buildJevModelPrompt(state, options.candidates); - if (new TextEncoder().encode(JEV_MODEL_INSTRUCTIONS + input).byteLength > JEV_MAX_REQUEST_BYTES) { + const payload = boundedJevQuotaPayload(jevQuotaCandidates(options), rows => { + instructions = JEV_MODEL_INSTRUCTIONS + (rows.some(row => row.quota) ? " " + JEV_QUOTA_INSTRUCTION : ""); + input = buildJevModelPrompt(state, rows); + if (options.decisionQuotaSignals !== true) return instructions + input; + const request = { model: options.decisionModel, instructions, input }; + return serializeJevModelRequest(request); + }, JEV_MAX_REQUEST_BYTES); + candidates = payload.candidates; + if (new TextEncoder().encode(payload.body).byteLength > JEV_MAX_REQUEST_BYTES) { return failed("invalid"); } + if (candidates.some(row => row.quota)) { + withoutQuota = { instructions: JEV_MODEL_INSTRUCTIONS, + input: buildJevModelPrompt(state, candidates.map(({ quota: _quota, ...row }) => row)) }; + } } catch { return failed("invalid"); } @@ -99,7 +121,8 @@ export async function resolveJevModelDecision( const timeoutSignal = AbortSignal.timeout(jevDecisionTimeoutMs(options.timeoutMs)); const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal; try { - const result = await options.invokeModel({ model: options.decisionModel, instructions: JEV_MODEL_INSTRUCTIONS, input, signal }); + const result = await options.invokeModel({ model: options.decisionModel, instructions, input, signal, ...(withoutQuota ? { withoutQuota } : {}) }); + if (result.quotaOmitted) candidates = candidates.map(({ quota: _quota, ...row }) => row); if (options.signal?.aborted) throw options.signal.reason; if (timeoutSignal.aborted) return failed("timeout"); let choice: string; @@ -111,14 +134,14 @@ export async function resolveJevModelDecision( const selected = routeOptions.find(option => option.key === choice)!; const usage = jevUsage({ usage: result.usage }); if (options.signal?.aborted) throw options.signal.reason; - return { + return withJevQuotaSummary({ backend: "model", targetKey: selected.targetKey, effort: selected.effort, gate: "apply", latencyMs: Math.max(0, now() - startedAt), ...(usage ? { usage } : {}), - }; + }, candidates); } catch (error) { if (options.signal?.aborted) throw options.signal.reason; if (timeoutSignal.aborted || (error instanceof Error && error.name === "TimeoutError")) return failed("timeout"); diff --git a/src/combos/jev-model-request.ts b/src/combos/jev-model-request.ts new file mode 100644 index 00000000000..a10a1b224e0 --- /dev/null +++ b/src/combos/jev-model-request.ts @@ -0,0 +1,20 @@ +/** The exact detached Responses envelope, shared by quota sizing and the invoker. */ +export const JEV_MODEL_MAX_OUTPUT_TOKENS = 1024; +export interface JevModelRequestBody { + model: string; + instructions: string; + input: string; +} +/** Serialize the streaming decision-model request body, adding the reasoning effort only when one is given. */ +export function serializeJevModelRequest(request: JevModelRequestBody, effort?: string): string { + return JSON.stringify({ + model: request.model, + stream: true, + store: false, + instructions: request.instructions, + input: [{ role: "user", content: [{ type: "input_text", text: request.input }] }], + tools: [], + max_output_tokens: JEV_MODEL_MAX_OUTPUT_TOKENS, + ...(effort ? { reasoning: { effort } } : {}), + }); +} diff --git a/src/combos/jev-quota-config.ts b/src/combos/jev-quota-config.ts new file mode 100644 index 00000000000..7d7eea4777e --- /dev/null +++ b/src/combos/jev-quota-config.ts @@ -0,0 +1,62 @@ +/** Import-free quota policy shared by config, CLI and telemetry. */ +export interface JevQuotaTiers { + moderate?: number; + limited?: number; + nearlyExhausted?: number; +} +export interface ResolvedJevQuotaTiers { + moderate?: number; + limited: number; + nearlyExhausted: number; +} +export const JEV_QUOTA_TIERS = ["unknown", "healthy", "moderate", "limited", "nearly_exhausted"] as const; +export type JevQuotaTier = typeof JEV_QUOTA_TIERS[number]; +export const JEV_QUOTA_DEFAULT_TIERS: ResolvedJevQuotaTiers = { limited: 70, nearlyExhausted: 90 }; +/** Validate quota tier overrides and merge them with the defaults; return undefined for unknown keys, non-finite or out-of-range values, or thresholds that are not strictly ascending. */ +export function normalizeJevQuotaTiers(value: unknown): ResolvedJevQuotaTiers | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const raw = value as Record; + if (Object.keys(raw).some(key => !["moderate", "limited", "nearlyExhausted"].includes(key))) return undefined; + if (Object.keys(raw).some(key => raw[key] === undefined)) return undefined; + const result = { ...JEV_QUOTA_DEFAULT_TIERS, ...raw }; + const values = [result.moderate, result.limited, result.nearlyExhausted].filter(value => value !== undefined); + if (values.some(value => typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100)) return undefined; + if (Object.hasOwn(raw, "moderate") && raw.moderate === undefined) return undefined; + if (values.some((value, i) => i > 0 && value! <= values[i - 1]!)) return undefined; + return result as ResolvedJevQuotaTiers; +} +/** Map a used percentage to a quota tier using the thresholds; a non-finite or out-of-range percentage is unknown. */ +export function jevQuotaTier(percent: number, tiers: ResolvedJevQuotaTiers = JEV_QUOTA_DEFAULT_TIERS): JevQuotaTier { + if (!Number.isFinite(percent) || percent < 0 || percent > 100) return "unknown"; + if (percent >= tiers.nearlyExhausted) return "nearly_exhausted"; + if (percent >= tiers.limited) return "limited"; + if (tiers.moderate !== undefined && percent >= tiers.moderate) return "moderate"; + return "healthy"; +} +/** Rank a quota tier for ordering; unknown ties healthy so missing evidence is never treated as exhausted. */ +export function jevQuotaRank(tier: JevQuotaTier): number { + return tier === "unknown" || tier === "healthy" ? 0 : tier === "moderate" ? 1 : tier === "limited" ? 2 : 3; +} +export interface JevQuotaDecisionSummary { + unknown: number; + healthy: number; + moderate: number; + limited: number; + nearly_exhausted: number; + selected?: JevQuotaTier; +} +/** Validate a persisted decision quota summary (bounded per-tier counts and an optional selected tier), returning undefined when it is malformed. */ +export function normalizeJevQuotaSummary(value: unknown): JevQuotaDecisionSummary | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const raw = value as Record; + const result = { unknown: 0, healthy: 0, moderate: 0, limited: 0, nearly_exhausted: 0 }; + let total = 0; + for (const tier of JEV_QUOTA_TIERS) { + const count = raw[tier]; + if (typeof count !== "number" || !Number.isInteger(count) || count < 0 || count > 64) return undefined; + result[tier] = count; total += count; + } + if (total > 64) return undefined; + const selected = JEV_QUOTA_TIERS.find(tier => tier === raw.selected); + return { ...result, ...(selected ? { selected } : {}) }; +} diff --git a/src/combos/jev-quota-route.ts b/src/combos/jev-quota-route.ts new file mode 100644 index 00000000000..a1754123abe --- /dev/null +++ b/src/combos/jev-quota-route.ts @@ -0,0 +1,26 @@ +import { jevQuotaDecisionSummary, jevQuotaSignalForTarget } from "./jev-quota"; +import { JEV_QUOTA_DEFAULT_TIERS, normalizeJevQuotaTiers } from "./jev-quota-config"; +import type { JevCandidate, JevDecision, ResolveJevDecisionOptions } from "./jev"; +/** Attach a loaded-only quota signal to each candidate when the combo opted in; otherwise return the candidates unchanged. */ +export function jevQuotaCandidates(options: ResolveJevDecisionOptions): readonly JevCandidate[] { + if (options.decisionQuotaSignals !== true) return options.candidates; + const tiers = normalizeJevQuotaTiers(options.decisionQuotaTiers) ?? JEV_QUOTA_DEFAULT_TIERS; + const now = (options.now ?? Date.now)(); + return options.candidates.map(candidate => ({ ...candidate, + quota: jevQuotaSignalForTarget(options.config, candidate.provider, candidate.model, now, tiers) })); +} +/** Attach the per-tier quota summary to a decision when any candidate carried quota evidence. */ +export function withJevQuotaSummary(decision: JevDecision, candidates: readonly JevCandidate[]): JevDecision { + const quota = jevQuotaDecisionSummary(candidates, decision.targetKey); + return quota ? { ...decision, quota } : decision; +} +/** Retries serialization without quota, never truncates ordinary evidence or the allowlist. */ +export function boundedJevQuotaPayload(candidates: readonly JevCandidate[], build: (rows: readonly JevCandidate[]) => string, limit: number): { body: string; candidates: readonly JevCandidate[] } { + let rows = candidates; + let body = build(rows); + if (new TextEncoder().encode(body).byteLength > limit && rows.some(row => row.quota)) { + rows = rows.map(({ quota: _quota, ...row }) => row); + body = build(rows); + } + return { body, candidates: rows }; +} diff --git a/src/combos/jev-quota.ts b/src/combos/jev-quota.ts new file mode 100644 index 00000000000..cd985e60b8c --- /dev/null +++ b/src/combos/jev-quota.ts @@ -0,0 +1,87 @@ +import { anthropicModelFamily } from "../oauth/anthropic-model-family"; +/** Advisory quota only: loaded snapshots, never serving-account selection or hard eligibility. */ +import { resolveAnthropicModelRoute, routeCandidates } from "../oauth/anthropic-model-routes"; +import type { OcxConfig } from "../types"; +import { readLoadedDecisionKeyQuota, readLoadedDecisionQuotaPool, type DecisionQuotaWindow } from "../providers/quota-decision-snapshot"; +import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers-destination"; +import { providerCodexAccountMode } from "../providers/registry"; +import { NATIVE_RESERVE_MODEL } from "../codex/catalog/native-models"; +import { isSelectableCodexPoolAccount, MAIN_CODEX_ACCOUNT_ID } from "../codex/account-id"; +import { JEV_QUOTA_DEFAULT_TIERS, jevQuotaRank, jevQuotaTier, type JevQuotaTier, type ResolvedJevQuotaTiers, type JevQuotaDecisionSummary } from "./jev-quota-config"; +export const JEV_QUOTA_SIGNAL_MAX_AGE_MS = 30 * 60_000; +export interface JevQuotaSignal { + tier: JevQuotaTier; + usedPercent?: number; + window?: DecisionQuotaWindow["window"]; + resetsInSeconds?: number; +} +/** Build the signal used when evidence is missing, stale or invalid; it ties healthy. */ +const unknown = (): JevQuotaSignal => ({ tier: "unknown" }); +/** Reduce decision quota windows to the worst applicable window for a model, ignoring cold, stale, future, invalid or reset-expired rows. */ +export function jevQuotaSignalFromWindows(windows: readonly DecisionQuotaWindow[] | undefined, model: string, now: number, tiers = JEV_QUOTA_DEFAULT_TIERS): JevQuotaSignal { + let worst: DecisionQuotaWindow | undefined; + for (const row of windows ?? []) { + if (!["5h", "weekly", "monthly"].includes(row.window) && anthropicModelFamily(model)?.toLowerCase() !== row.window) continue; + if (!Number.isFinite(now) || now < 0 || !Number.isFinite(row.observedAt) || row.observedAt < 0 || row.observedAt > now + || now - row.observedAt > JEV_QUOTA_SIGNAL_MAX_AGE_MS || !Number.isFinite(row.percent) || row.percent < 0 || row.percent > 100 + || row.resetAt !== undefined && (!Number.isFinite(row.resetAt) || row.resetAt < 0 || row.resetAt <= now)) continue; + if (!worst || row.percent > worst.percent) worst = row; + } + if (!worst) return unknown(); + return { tier: jevQuotaTier(worst.percent, tiers), usedPercent: worst.percent, window: worst.window, + ...(worst.resetAt !== undefined ? { resetsInSeconds: Math.min(31_536_000, Math.ceil((worst.resetAt - now) / 1000)) } : {}) }; +} +/** Unknown ties healthy; stable order within tiers. Exhaustion requires EVERY usable row known. */ +export function jevQuotaPoolSignal(signals: readonly JevQuotaSignal[]): JevQuotaSignal { + return signals.reduce((best, signal) => !best || jevQuotaRank(signal.tier) < jevQuotaRank(best.tier) ? signal : best, undefined) ?? unknown(); +} +/** Read only already-loaded evidence for a target provider and model and return its quota signal; never resolves credentials or probes upstream. */ +export function jevQuotaSignalForTarget(config: OcxConfig, provider: string, model: string, now = Date.now(), tiers: ResolvedJevQuotaTiers = JEV_QUOTA_DEFAULT_TIERS): JevQuotaSignal { + const row = config.providers[provider]; + if (!row || row.disabled) return unknown(); + if (provider === "anthropic" && row.authMode === "oauth" && config.anthropicAccountPool?.enabled === true) { + const route = resolveAnthropicModelRoute(config, model); + if (route.error) return unknown(); + const usable = (readLoadedDecisionQuotaPool("anthropic") ?? []).filter(account => account.usable); + const ids = new Set(routeCandidates(usable.map(account => account.id), route.decision)); + return jevQuotaPoolSignal(usable.filter(account => ids.has(account.id)).map(account => jevQuotaSignalFromWindows(account.windows, model, now, tiers))); + } + if (isCanonicalOpenAiForwardProvider(row) && providerCodexAccountMode(provider, row) === "pool") { + if (model.toLowerCase().split("/").at(-1) === NATIVE_RESERVE_MODEL) return unknown(); + const roster = readLoadedDecisionQuotaPool("codex"); + if (!roster) return unknown(); + const paused = new Set(config.pausedCodexAccountIds ?? []); + const ids = new Set((config.codexAccounts ?? []).filter(isSelectableCodexPoolAccount).map(account => account.id)); + const loadedIds = new Set(roster.map(account => account.id)); + const signals = roster.filter(account => account.usable && ids.has(account.id) && !paused.has(account.id)) + .map(account => jevQuotaSignalFromWindows(account.windows, model, now, tiers)); + for (const id of ids) if (!loadedIds.has(id) && !paused.has(id)) signals.push(unknown()); + if (!paused.has(MAIN_CODEX_ACCOUNT_ID)) { + const main = readLoadedDecisionQuotaPool("codex-main")?.[0]; + if (!main || main.usable) signals.push(jevQuotaSignalFromWindows(main?.windows, model, now, tiers)); + } + return jevQuotaPoolSignal(signals); + } + return jevQuotaSignalFromWindows(readLoadedDecisionKeyQuota(provider, row), model, now, tiers); +} +/** Render the short natural-language quota clause appended to an option description. */ +export function jevQuotaClause(signal: JevQuotaSignal): string { + return ` Quota ${signal.tier}${signal.usedPercent !== undefined ? ` (${signal.usedPercent}% of ${signal.window} used)` : ""}.`; +} +/** Project a quota signal onto the outbound criterion object: tier, used percentage, window and reset delay only. */ +export function jevQuotaCriterion(signal: JevQuotaSignal): Record { + return { tier: signal.tier, ...(signal.usedPercent !== undefined ? { used_percent: signal.usedPercent, window: signal.window } : {}), + ...(signal.resetsInSeconds !== undefined ? { resets_in_seconds: signal.resetsInSeconds } : {}) }; +} +export const JEV_QUOTA_INSTRUCTION = "Remaining subscription quota is advisory: among adequate options prefer healthier quota; unknown is not exhausted. Never change the target or effort allowlist."; +/** Count candidate quota tiers and record the selected candidate's tier; undefined when no candidate carried quota evidence. */ +export function jevQuotaDecisionSummary(candidates: readonly { key: string; quota?: JevQuotaSignal }[], selectedKey: string): JevQuotaDecisionSummary | undefined { + if (!candidates.some(candidate => candidate.quota)) return undefined; + const summary: JevQuotaDecisionSummary = { unknown: 0, healthy: 0, moderate: 0, limited: 0, nearly_exhausted: 0 }; + for (const candidate of candidates) { + const tier = candidate.quota?.tier ?? "unknown"; + summary[tier]++; + if (candidate.key === selectedKey) summary.selected = tier; + } + return summary; +} diff --git a/src/combos/jev.ts b/src/combos/jev.ts index ddc37b517d6..272612cb1e6 100644 --- a/src/combos/jev.ts +++ b/src/combos/jev.ts @@ -1,3 +1,7 @@ +import { jevQuotaClause, jevQuotaCriterion, JEV_QUOTA_INSTRUCTION, type JevQuotaSignal } from "./jev-quota"; +import type { JevQuotaTiers, JevQuotaDecisionSummary } from "./jev-quota-config"; +import { boundedJevQuotaPayload, jevQuotaCandidates, withJevQuotaSummary } from "./jev-quota-route"; +import { JEV_MAX_REQUEST_BYTES as REQUEST_LIMIT } from "./jev-service-exchange"; import type { providerOutboundPost } from "../lib/provider-outbound"; import type { OcxComboDefaultEffort, OcxConfig } from "../types"; import { @@ -75,6 +79,7 @@ export interface JevCandidate { reasoningEfforts: readonly OcxComboDefaultEffort[]; /** Optional operator note sent as decision evidence for this target only. */ modelProfile?: string; + quota?: JevQuotaSignal; } /** @@ -98,6 +103,7 @@ export interface JevDecision { effort: OcxComboDefaultEffort | null; gate: "apply" | "missing_key" | "no_choices" | "no_state" | "timeout" | "network" | "redirect" | "http" | "malformed" | "invalid"; latencyMs: number; + quota?: JevQuotaDecisionSummary; confidence?: number; chosenProbability?: number; usage?: Record; @@ -113,6 +119,8 @@ export interface ResolveJevDecisionOptions { * any other id names a configured `jev-decision` row (for example a self-hosted Ollama `tev1`). */ decisionProvider?: string; + decisionQuotaSignals?: boolean; + decisionQuotaTiers?: JevQuotaTiers; /** Request-local authorization of the concrete decision destination, before credential access. */ isDestinationAllowed?: (providerName: string, modelId: string) => boolean; /** Decision deadline; values outside 1000..120000 ms keep the four-second default. */ @@ -477,11 +485,12 @@ function modelProfile(candidate: JevCandidate): string { ?? "Configured target with capability unspecified by JEV; judge it only from the supplied request evidence."; } -function criterionDescription(criterion: JevRouteOption["criterion"]): string { +/** Describe one allowlisted target and effort option for a decision backend, including the optional advisory quota clause. */ +function criterionDescription(criterion: JevRouteOption["criterion"], quota?: JevQuotaSignal): string { const effort = criterion.reasoning_effort ? `${criterion.reasoning_effort} reasoning effort` : "no reasoning-effort control"; - return `Target ${criterion.target} (provider ${criterion.provider}, model ${criterion.model}) with ${effort}.`; + return `Target ${criterion.target} (provider ${criterion.provider}, model ${criterion.model}) with ${effort}.${quota ? jevQuotaClause(quota) : ""}`; } /** One allowlisted target/effort option, shared by every decision backend. */ @@ -498,7 +507,7 @@ export function jevRouteOptions(candidates: readonly JevCandidate[]): JevRouteOp key, targetKey: option.targetKey, effort: option.effort, - description: criterionDescription(option.criterion), + description: criterionDescription(option.criterion, candidates.find(candidate => candidate.key === option.targetKey)?.quota), })); } @@ -516,7 +525,8 @@ export function buildJevRouteQuestion( const routeOptions = candidateOptions(candidates); const criteria: Record = {}; for (const [choice, option] of routeOptions) { - criteria[choice] = options.descriptiveCriteria ? criterionDescription(option.criterion) : option.criterion; + const quota = candidates.find(candidate => candidate.key === option.targetKey)?.quota; + criteria[choice] = options.descriptiveCriteria ? criterionDescription(option.criterion, quota) : { ...option.criterion, ...(quota ? { quota: jevQuotaCriterion(quota) } : {}) }; } const modelProfiles: Record = {}; for (const candidate of candidates) modelProfiles[candidate.key] = modelProfile(candidate); @@ -531,6 +541,7 @@ export function buildJevRouteQuestion( model_profiles: modelProfiles, effort_profiles: EFFORT_PROFILES, speed: "Every option uses standard speed. Fast mode is unavailable.", + ...(candidates.some(candidate => candidate.quota) ? { quota: JEV_QUOTA_INSTRUCTION } : {}), }, criteria, }, @@ -628,6 +639,7 @@ export async function resolveJevDecision(options: ResolveJevDecisionOptions): Pr if (options.candidates.length === 0) return failed("no_choices"); if (!candidatesFitRequestBounds(options.candidates)) return failed("invalid"); + let candidates = options.candidates; const exchanged = await exchangeJevDecision(options, (endpoint) => { if (endpoint.descriptiveCriteria) { // Self-hosted choice questions accept 2..26 options; decide locally instead of spending a @@ -638,19 +650,20 @@ export async function resolveJevDecision(options: ResolveJevDecisionOptions): Pr } const state = buildJevState(options.body, options.candidates); if (!hasJevDecisionState(state)) return "no_state"; - return { body: JSON.stringify({ - model: endpoint.model, - state, - questions: buildJevRouteQuestion(options.candidates, { descriptiveCriteria: endpoint.descriptiveCriteria }), - }) }; + const payload = boundedJevQuotaPayload(jevQuotaCandidates(options), rows => JSON.stringify({ + model: endpoint.model, state, + questions: buildJevRouteQuestion(rows, { descriptiveCriteria: endpoint.descriptiveCriteria }), + }), REQUEST_LIMIT); + candidates = payload.candidates; + return { body: payload.body }; }, (payload) => parseJevDecision(payload, options.candidates)); - if ("gate" in exchanged) return failed(exchanged.gate); - return { + if ("gate" in exchanged) return withJevQuotaSummary(failed(exchanged.gate), candidates); + return withJevQuotaSummary({ backend, ...exchanged.value, gate: "apply", latencyMs: Math.max(0, now() - startedAt), - }; + }, candidates); } export type JevDecisionProbeResult = diff --git a/src/combos/types.ts b/src/combos/types.ts index 6353db3b7a5..f199420bfb8 100644 --- a/src/combos/types.ts +++ b/src/combos/types.ts @@ -1,3 +1,4 @@ +import { normalizeJevQuotaTiers, type ResolvedJevQuotaTiers } from "./jev-quota-config"; import { isCodexReasoningEffort, isDeclaredReasoningEffort } from "../reasoning-effort"; import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models"; import type { OcxComboConfig, OcxComboCooldownWaitPolicy, OcxComboDefaultEffort, OcxComboDefaultEffortMode, OcxComboReasoningEffortMode, OcxComboStrategy, OcxComboTarget, OcxProviderConfig } from "../types"; @@ -70,6 +71,8 @@ export interface NormalizedComboConfig { decisionModel?: string; /** JEV decision deadline override; absent keeps the default four-second deadline. */ decisionTimeoutMs?: number; + decisionQuotaSignals?: boolean; + decisionQuotaTiers?: ResolvedJevQuotaTiers; targets: NormalizedComboTarget[]; } @@ -133,6 +136,7 @@ export interface ComboValidationOptions { excludeComboId?: string; } +/** Validate a raw combo definition against the configured providers and return every issue found, without mutating the input. */ export function comboConfigIssues( id: string, raw: unknown, @@ -281,6 +285,12 @@ export function comboConfigIssues( if (nativeAlias && (typeof body.displayName !== "string" || body.displayName.trim().length === 0)) { issues.push({ path: ["displayName"], message: "displayName is required for native aliases" }); } + for (const field of ["decisionQuotaSignals", "decisionQuotaTiers"] as const) { + if (body[field] === undefined || body[field] === null) continue; + if (body.strategy !== "jev") issues.push({ path: [field], message: `${field} is only valid with strategy "jev"` }); + if (field === "decisionQuotaSignals" ? typeof body[field] !== "boolean" : !normalizeJevQuotaTiers(body[field])) + issues.push({ path: [field], message: field === "decisionQuotaSignals" ? "decisionQuotaSignals must be a boolean" : "decisionQuotaTiers must contain finite 0..100 strictly ascending thresholds" }); + } if (body.decisionProvider !== undefined && body.decisionProvider !== null) { const decisionProvider = typeof body.decisionProvider === "string" ? body.decisionProvider.trim() : ""; if (!decisionProvider) { @@ -467,6 +477,7 @@ export function comboConfigError( return comboConfigIssues(id, raw, providers, options)[0]?.message ?? null; } +/** Normalize a validated combo definition into its canonical form: trimmed strings, defaults and the opt-in quota signal fields. */ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig { const alias = typeof raw.alias === "string" ? raw.alias.trim() : ""; const displayName = typeof raw.displayName === "string" ? raw.displayName.trim() : ""; @@ -492,6 +503,8 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig ...(decisionProvider && decisionProvider !== CANONICAL_JEV_DECISION_PROVIDER ? { decisionProvider } : {}), ...(decisionModel ? { decisionModel } : {}), ...(typeof raw.decisionTimeoutMs === "number" ? { decisionTimeoutMs: raw.decisionTimeoutMs } : {}), + ...(raw.strategy === "jev" && raw.decisionQuotaSignals === true ? { decisionQuotaSignals: true } : {}), + ...(raw.strategy === "jev" && normalizeJevQuotaTiers(raw.decisionQuotaTiers) ? { decisionQuotaTiers: normalizeJevQuotaTiers(raw.decisionQuotaTiers)! } : {}), targets: raw.targets.map(target => ({ provider: target.provider.trim(), model: target.model.trim(), diff --git a/src/oauth/anthropic-model-family.ts b/src/oauth/anthropic-model-family.ts new file mode 100644 index 00000000000..b5f42de0adf --- /dev/null +++ b/src/oauth/anthropic-model-family.ts @@ -0,0 +1,8 @@ +/** Pure family identity shared by existing hard policy and loaded-only advisory evidence. */ +export type AnthropicModelFamily = "Fable" | "Opus" | "Sonnet"; +/** Return the Anthropic model family (Fable, Opus or Sonnet) named by a model id, or undefined for other models. */ +export function anthropicModelFamily(model?: string): AnthropicModelFamily | undefined { + return /(?:^|[/])claude-fable-5(?:-|$)/i.test(model ?? "") ? "Fable" + : /(?:^|[/])claude-opus-/i.test(model ?? "") ? "Opus" + : /(?:^|[/])claude-sonnet-/i.test(model ?? "") ? "Sonnet" : undefined; +} diff --git a/src/oauth/anthropic-model-quota.ts b/src/oauth/anthropic-model-quota.ts index 67430f30ca7..330a6d624d7 100644 --- a/src/oauth/anthropic-model-quota.ts +++ b/src/oauth/anthropic-model-quota.ts @@ -6,12 +6,8 @@ import type { AnthropicInstanceId } from "../providers/anthropic-instance-id"; import type { GenerationContext } from "../lib/state-store-sweeper"; export const ANTHROPIC_PASSIVE_FAMILY_MAX_AGE_MS = 30 * 60_000; -export type AnthropicModelFamily = "Fable" | "Opus" | "Sonnet"; -export function anthropicModelFamily(model?: string): AnthropicModelFamily | undefined { - return /(?:^|[/])claude-fable-5(?:-|$)/i.test(model ?? "") ? "Fable" - : /(?:^|[/])claude-opus-/i.test(model ?? "") ? "Opus" - : /(?:^|[/])claude-sonnet-/i.test(model ?? "") ? "Sonnet" : undefined; -} +import { anthropicModelFamily } from "./anthropic-model-family"; +export { anthropicModelFamily, type AnthropicModelFamily } from "./anthropic-model-family"; export function anthropicModelPercents(quota: ProviderQuota | null, model?: string): number[] { const family = anthropicModelFamily(model); diff --git a/src/oauth/store-decision-quota.ts b/src/oauth/store-decision-quota.ts new file mode 100644 index 00000000000..2f321945816 --- /dev/null +++ b/src/oauth/store-decision-quota.ts @@ -0,0 +1,20 @@ +/** Auth owner projects a loaded store; JEV never imports the auth-store reader. */ +import { createHash } from "node:crypto"; +import { publishDecisionQuotaRoster, withdrawDecisionQuotaRoster } from "../providers/quota-decision-snapshot"; +import type { AuthStore } from "./store"; +/** Guarded publisher for the secret-free Anthropic pool roster, never throws; the generation is a one-way digest of the credential, never the credential itself. */ +export function publishAuthDecisionQuotaRoster(store: AuthStore): void { + try { projectAuthDecisionQuotaRoster(store); } + catch { + // Advisory evidence must never alter auth-store loading or persistence; on failure the pool becomes unknown. + try { withdrawDecisionQuotaRoster("anthropic"); } catch { /* best-effort; the auth store result is already decided */ } + } +} +/** Project a loaded store into the secret-free roster; may throw, so callers go through the guarded publisher. */ +function projectAuthDecisionQuotaRoster(store: AuthStore): void { + publishDecisionQuotaRoster("anthropic", (store.anthropic?.accounts ?? []).map(row => ({ + id: row.id, + generation: createHash("sha256").update(JSON.stringify([row.credential.refresh, row.credential.access, row.credential.expires])).digest("hex"), + usable: row.paused !== true && row.needsReauth !== true && Boolean(row.credential.access || row.credential.refresh), + }))); +} diff --git a/src/oauth/store.ts b/src/oauth/store.ts index ef952bd9d40..a07d953b874 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -19,6 +19,7 @@ * distinct one. Kimi extracts JWT `user_id`/`sub` as accountId; Cursor extracts JWT `sub` — * both append distinct identified accounts under multiauth. */ +import { publishAuthDecisionQuotaRoster } from "./store-decision-quota"; import { normalizeAnthropicIdentity, matchesAnthropicImport, mergeAnthropicIdentity } from "./anthropic-identity"; import { isAnthropicInstanceId, type AnthropicInstanceId } from "../providers/anthropic-instance-id"; import { assertNoCrossAnthropicRegistration, assertAnthropicCredentialSource, assertAnthropicInstanceLoginConfig } from "./store-anthropic-instance"; @@ -352,17 +353,23 @@ export function credentialGeneration(cred: OAuthCredentials): string { return createHash("sha256").update(JSON.stringify([cred.refresh, cred.access, cred.expires])).digest("hex"); } +/** Load and normalize the OAuth auth store with hardened permissions, and republish the Anthropic decision-quota roster derived from it. */ function loadAuthStoreInternal(): { store: AuthStore; hadLegacy: boolean } { const path = getAuthStorePath(); hardenConfigDir(); hardenExistingSecret(path); - if (!existsSync(path)) return { store: {}, hadLegacy: false }; + if (!existsSync(path)) { publishAuthDecisionQuotaRoster({}); return { store: {}, hadLegacy: false }; } + let result: { store: AuthStore; hadLegacy: boolean }; try { - return normalizeAuthStore(JSON.parse(readFileSync(path, "utf-8"))); + result = normalizeAuthStore(JSON.parse(readFileSync(path, "utf-8"))); } catch { backupInvalidConfig(path); + publishAuthDecisionQuotaRoster({}); return { store: {}, hadLegacy: false }; } + // Outside the parse try: roster publication is advisory and must never route a loaded store into the invalid-config backup path. + publishAuthDecisionQuotaRoster(result.store); + return result; } export function loadAuthStore(): AuthStore { @@ -401,6 +408,7 @@ export function peekAuthStore(): AuthStore { return snapshot.kind === "ready" ? snapshot.store : {}; } +/** Atomically write the auth store with owner-only permissions and republish the Anthropic decision-quota roster. */ function persist(store: AuthStore): void { const dir = getConfigDir(); assertNotRealHomeUnderTest(dir); @@ -411,6 +419,7 @@ function persist(store: AuthStore): void { } hardenConfigDir(); atomicWriteFile(getAuthStorePath(), JSON.stringify(store, null, 2) + "\n"); + publishAuthDecisionQuotaRoster(store); } export class OAuthFileLockError extends Error { readonly code = "OAUTH_FILE_LOCK_UNAVAILABLE"; constructor(message: string, options?: { cause?: unknown }) { super(message, options); this.name = "OAuthFileLockError"; } } diff --git a/src/providers/api-key-resolve.ts b/src/providers/api-key-resolve.ts index 9bdfd3deb8c..4ca721002b4 100644 --- a/src/providers/api-key-resolve.ts +++ b/src/providers/api-key-resolve.ts @@ -16,6 +16,7 @@ * Policy: a reference that cannot be resolved fails closed (no key) and is warned once per * account; nothing ever rewrites plaintext into config or its backups. */ +import { invalidateDecisionKeyQuotas, observeDecisionKeyCredential } from "./quota-decision-snapshot"; import { resolveEnvValue } from "../config/proxy-env"; import { loadKeyringBinding } from "../lib/keyring-native"; import type { OcxProviderConfig } from "../types"; @@ -43,12 +44,14 @@ const warnedAccounts = new Set(); /** Test seam: swap the OS entry for an in-memory one and drop caches. */ export function setProviderKeychainEntryFactoryForTests(factory: ProviderKeychainEntryFactory | null): void { entryFactory = factory ?? defaultEntryFactory; + invalidateDecisionKeyQuotas(); resolvedCache.clear(); warnedAccounts.clear(); } /** Write-path seam: a store/restore mutated secrets, so cached reads and warnings are stale. */ export function invalidateResolvedProviderKeyCache(): void { + invalidateDecisionKeyQuotas(); resolvedCache.clear(); warnedAccounts.clear(); } @@ -101,8 +104,9 @@ function readKeychain(account: string): string | undefined { */ export function resolveProviderApiKey(value: string | undefined): string | undefined { if (!value) return undefined; - if (isKeychainReference(value)) return readKeychain(keychainAccount(value)); - return resolveEnvValue(value); + const resolved = isKeychainReference(value) ? readKeychain(keychainAccount(value)) : resolveEnvValue(value); + observeDecisionKeyCredential(value, resolved); + return resolved; } export type ProviderKeyStoreKind = "keychain" | "env" | "file" | "none"; diff --git a/src/providers/quota-decision-publication.ts b/src/providers/quota-decision-publication.ts new file mode 100644 index 00000000000..744549ef9b2 --- /dev/null +++ b/src/providers/quota-decision-publication.ts @@ -0,0 +1,59 @@ +/** Producer projection: only measured subscription windows reach advisory snapshots. */ +import type { ProviderQuota } from "./quota-types"; +import type { StoredAccountQuota } from "../codex/quota-types"; +import { resetAtToMs } from "../codex/quota-types"; +import { publishDecisionAccountQuota, type DecisionQuotaWindow } from "./quota-decision-snapshot"; +const rawWindows = new WeakMap(); +/** Producer retains raw advisory projection separately from unchanged display/hard policy. */ +export function bindRawDecisionWindows(quota: ProviderQuota, windows: readonly DecisionQuotaWindow[]): void { rawWindows.set(quota, windows); } +/** Project a provider quota report onto decision windows, preferring raw windows bound by the producer over the display fields. */ +export function providerDecisionWindows(quota: ProviderQuota): DecisionQuotaWindow[] { + const raw = rawWindows.get(quota); + if (raw) return raw.map(row => ({ ...row })); + const windows: DecisionQuotaWindow[] = []; + for (const [window, percent, reset] of [ + ["5h", "fiveHourPercent", "fiveHourResetAt"], ["weekly", "weeklyPercent", "weeklyResetAt"], ["monthly", "monthlyPercent", "monthlyResetAt"], + ] as const) { + if (quota[percent] !== undefined) windows.push({ window, percent: quota[percent]!, observedAt: quota.updatedAt, ...(quota[reset] !== undefined ? { resetAt: quota[reset] } : {}) }); + } + for (const row of Array.isArray(quota.customWindows) ? quota.customWindows : []) { + const window = typeof row?.label === "string" ? row.label.trim().toLowerCase() : ""; + if (row?.scope === "model" && (window === "fable" || window === "opus" || window === "sonnet")) { + windows.push({ window, percent: row.percent, observedAt: row.passiveObservedAt ?? quota.updatedAt, ...(row.resetAt !== undefined ? { resetAt: row.resetAt } : {}) }); + } + } + return windows; +} +/** Publish decision windows for one Anthropic pool account, bound to its credential generation; a null quota publishes no windows. */ +export function publishAnthropicDecisionQuota(id: string, generation: string, quota: ProviderQuota | null, partial = false): void { + publishDecisionAccountQuota("anthropic", id, generation, quota ? providerDecisionWindows(quota) : [], partial); +} +type CodexObservation = Omit; +type CodexWindow = "short" | "weekly" | "monthly"; +const rawCodexWindows = new WeakMap(); +/** Keep invalid resets and the producer clock apart from compatibility display/policy parsing. */ +export function bindRawCodexDecisionResets(raw: CodexObservation, resets: Record, observedAt: number): void { + const windows = codexDecisionWindows(raw, observedAt).map(row => { + const value = resets[row.window === "5h" ? "short" : row.window as CodexWindow]; + if (value === undefined || value === null) return row; + const numeric = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN; + return { ...row, resetAt: Number.isFinite(numeric) && numeric >= 0 ? resetAtToMs(numeric) : NaN }; + }); + rawCodexWindows.set(raw, windows); +} +/** Build decision windows from a raw Codex observation, stamping each with the producer clock rather than a merge-time clock. */ +function codexDecisionWindows(raw: CodexObservation, observedAt: number): DecisionQuotaWindow[] { + const windows: DecisionQuotaWindow[] = []; + for (const kind of ["short", "weekly", "monthly"] as const) { + const percent = raw[`${kind}Percent`]; + const reset = raw[`${kind}ResetAt`]; + if (percent !== undefined) windows.push({ window: kind === "short" ? "5h" : kind, percent, observedAt, + ...(reset !== undefined ? { resetAt: resetAtToMs(reset) } : {}) }); + } + return windows; +} +/** Publish decision windows for a Codex or main account from the raw observation, never from the display merge, so carried windows cannot acquire a fresh clock. */ +export function publishCodexDecisionQuota(id: string, generation: number, raw: CodexObservation, observedAt: number, provider = "codex", partial = true): void { + // RAW observation, never the display merge: carried windows cannot acquire a fresh clock. + publishDecisionAccountQuota(provider, id, generation, rawCodexWindows.get(raw) ?? codexDecisionWindows(raw, observedAt), partial); +} diff --git a/src/providers/quota-decision-snapshot.ts b/src/providers/quota-decision-snapshot.ts new file mode 100644 index 00000000000..38fa2ed2d15 --- /dev/null +++ b/src/providers/quota-decision-snapshot.ts @@ -0,0 +1,137 @@ +/** Loaded-only advisory evidence. No auth, disk, keychain, selectors, timers or probes. */ +import { createHash } from "node:crypto"; +import { getConfigDir } from "../config/paths"; +import { captureConfigGeneration } from "../lib/state-store-sweeper"; +import type { OcxProviderConfig } from "../types"; + +export interface DecisionQuotaWindow { + window: "5h" | "weekly" | "monthly" | "fable" | "opus" | "sonnet"; + percent: number; + observedAt: number; + resetAt?: number; +} +export interface DecisionQuotaAccount { + id: string; + generation: string | number; + usable: boolean; +} +type Evidence = { generation: string | number; windows: readonly DecisionQuotaWindow[] }; +type Roster = { root: string; accounts: readonly DecisionQuotaAccount[] }; +const rosters = new Map(); +const accounts = new Map(); +const unusable = new Map(); +const POOL_PROVIDERS = new Set(["anthropic", "codex", "codex-main"]); +const MAX_ROWS = 1024; +const MAX_WINDOWS = 64; +let keyEpoch = 0; +const credentialVersions = new Map(); +/** Credential owner calls this after its normal read; JEV never invokes that owner. */ +export function observeDecisionKeyCredential(reference: string, resolved: string | undefined): void { + const version = createHash("sha256").update(resolved ?? "").digest("hex"); + const referenceId = createHash("sha256").update(reference).digest("hex"); + const previous = credentialVersions.get(referenceId); + if (previous !== undefined && previous !== version) invalidateDecisionKeyQuotas(); + if (credentialVersions.size >= 256 && !credentialVersions.has(referenceId)) { + credentialVersions.delete(credentialVersions.keys().next().value!); + invalidateDecisionKeyQuotas(); + } + credentialVersions.set(referenceId, version); +} +const keys = new Map(); +/** Compose the per-provider, per-account map key. */ +const accountKey = (provider: string, id: string) => `${provider}\0${id}`; +// Private policy digest, never returned or serialized. Resolving the configured credential is +// the collector's responsibility; request reads only this already-published generation. +/** Digest the provider settings a key-based evidence row was published under; it is never returned or serialized. */ +function keyPolicy(provider: OcxProviderConfig): string { + return createHash("sha256").update(JSON.stringify([ + provider.adapter, provider.baseUrl, provider.authMode, provider.apiKey, provider.apiKeyPool, provider.headers, + provider.apiKey && credentialVersions.get(createHash("sha256").update(provider.apiKey).digest("hex")), + ])).digest("hex"); +} +/** Drop all key-based decision evidence and advance the epoch so earlier publications cannot be read. */ +export function invalidateDecisionKeyQuotas(): void { keyEpoch++; keys.clear(); } +/** Publish decision windows for a single-key provider; pooled, disabled or non-key providers are never published, and the table is bounded. */ +export function publishDecisionKeyQuota(name: string, provider: OcxProviderConfig, windows: readonly DecisionQuotaWindow[]): void { + if (name.length > 1024 || provider.disabled || (provider.authMode ?? "key") !== "key" || (provider.apiKeyPool?.length ?? 0) > 1) return; + if (keys.size >= 256 && !keys.has(name)) keys.delete(keys.keys().next().value!); + keys.set(name, { root: getConfigDir(), epoch: keyEpoch, configGeneration: captureConfigGeneration(), policy: keyPolicy(provider), windows: copyWindows(windows) }); +} +/** Read key-based evidence only when the config root, epoch, generation and provider policy still match what was published. */ +export function readLoadedDecisionKeyQuota(name: string, provider: OcxProviderConfig): readonly DecisionQuotaWindow[] | undefined { + const row = keys.get(name); + if (!row || provider.disabled || (provider.authMode ?? "key") !== "key" || (provider.apiKeyPool?.length ?? 0) > 1 + || row.root !== getConfigDir() || row.epoch !== keyEpoch || row.configGeneration !== captureConfigGeneration() + || row.policy !== keyPolicy(provider)) return undefined; + return row.windows.map(row => ({ ...row })); +} +/** Replace a pool provider's roster; an oversized pool makes the whole pool unknown rather than hiding usable rows. */ +export function publishDecisionQuotaRoster(provider: string, rows: readonly DecisionQuotaAccount[]): void { + if (!POOL_PROVIDERS.has(provider)) return; + const old = rosters.get(provider); + if (old?.root !== getConfigDir()) { + clearDecisionAccountQuotas(provider); + for (const key of unusable.keys()) if (key.startsWith(`${provider}\0`)) unusable.delete(key); + } + // Overflow or oversized identities make the WHOLE pool unknown; never hide a usable row. + if (rows.length > MAX_ROWS || rows.some(row => row.id.length > 1024 + || typeof row.generation === "string" && row.generation.length > 128)) { + clearDecisionAccountQuotas(provider); + rosters.delete(provider); + return; + } + const live = new Map(rows.map(row => [row.id, row])); + for (const row of old?.accounts ?? []) { + if (live.get(row.id)?.generation !== row.generation) { + const key = accountKey(provider, row.id); + accounts.delete(key); + if (!live.has(row.id) || unusable.get(key) !== true) unusable.delete(key); + } + } + rosters.set(provider, { root: getConfigDir(), accounts: rows.map(row => ({ id: row.id, generation: row.generation, usable: row.usable })) }); +} +/** Withdraw a pool provider's roster and evidence so readers see it as unknown rather than stale or empty. */ +export function withdrawDecisionQuotaRoster(provider: string): void { + clearDecisionAccountQuotas(provider); + rosters.delete(provider); + for (const key of unusable.keys()) if (key.startsWith(`${provider}\0`)) unusable.delete(key); +} +/** Publish windows for a roster account at the matching credential generation; a partial observation retains other windows of the same generation. */ +export function publishDecisionAccountQuota(provider: string, id: string, generation: string | number, windows: readonly DecisionQuotaWindow[], partial = false): void { + const roster = rosters.get(provider); + if (roster?.root !== getConfigDir() || !roster.accounts.some(row => row.id === id && row.generation === generation)) return; + const key = accountKey(provider, id); + const previous = accounts.get(key); + const retained = partial && previous?.generation === generation ? previous.windows.filter(row => !windows.some(next => row.window === next.window)) : []; + accounts.set(key, { generation, windows: copyWindows([...retained, ...windows]) }); +} +/** Read a pool's already-loaded roster with generation-matched windows; undefined when nothing is loaded for the current config root. */ +export function readLoadedDecisionQuotaPool(provider: string): readonly { id: string; usable: boolean; windows?: readonly DecisionQuotaWindow[] }[] | undefined { + const roster = rosters.get(provider); + if (roster?.root !== getConfigDir()) return undefined; + return roster.accounts.map(row => { + const evidence = accounts.get(accountKey(provider, row.id)); + const quarantine = unusable.get(accountKey(provider, row.id)); + return { id: row.id, usable: row.usable && quarantine !== true && quarantine !== row.generation, ...(evidence?.generation === row.generation ? { windows: evidence.windows.map(window => ({ ...window })) } : {}) }; + }); +} +/** Mark a roster account usable or unusable, optionally scoped to one credential generation so an unrelated generation is left intact. */ +export function setDecisionAccountUsable(provider: string, id: string, usable: boolean, generation?: string | number): void { + const roster = rosters.get(provider); + if (roster?.root !== getConfigDir()) return; + const row = roster.accounts.find(row => row.id === id && (generation === undefined || generation === row.generation)); + if (!row) return; + const key = accountKey(provider, id); + if (usable) { + if (generation === undefined || unusable.get(key) !== true) unusable.delete(key); + } else if (generation === undefined || unusable.get(key) !== true) unusable.set(key, generation ?? true); +} +/** Drop published account windows for a provider or one account, or for every provider when none is given. */ +export function clearDecisionAccountQuotas(provider?: string, id?: string): void { + for (const key of accounts.keys()) if (!provider || key.startsWith(`${provider}\0`) && (!id || key === accountKey(provider, id))) accounts.delete(key); +} +/** Copy only known window kinds into the bounded evidence table, so callers cannot retain a mutable reference. */ +function copyWindows(windows: readonly DecisionQuotaWindow[]): DecisionQuotaWindow[] { + if (windows.length > MAX_WINDOWS) return []; + return windows.filter(row => ["5h", "weekly", "monthly", "fable", "opus", "sonnet"].includes(row.window)).map(row => ({ window: row.window, percent: row.percent, observedAt: row.observedAt, ...(row.resetAt !== undefined ? { resetAt: row.resetAt } : {}) })); +} diff --git a/src/providers/quota-routing-cache.ts b/src/providers/quota-routing-cache.ts index 274d07feec1..c9fee613f52 100644 --- a/src/providers/quota-routing-cache.ts +++ b/src/providers/quota-routing-cache.ts @@ -1,3 +1,4 @@ +import { invalidateDecisionKeyQuotas } from "./quota-decision-snapshot"; import { createHash } from "node:crypto"; import type { OcxProviderConfig } from "../types"; import type { ProviderQuota, ProviderQuotaReport } from "./quota"; @@ -38,7 +39,9 @@ export function providerQuotaRoutingBinding( ])).digest("hex"); } +/** Clear the routing quota cache together with key-based decision evidence, so one cannot outlive the other. */ export function clearCachedProviderQuotas(): void { + invalidateDecisionKeyQuotas(); quotaCache.clear(); } diff --git a/src/providers/quota.ts b/src/providers/quota.ts index a6403fbfdcd..18b909b4034 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -30,7 +30,7 @@ import { isProviderQuotaReportCurrent, LAST_GOOD_MAX_AGE_MS, providerQuotaBeforePublishForTests, - routingEvidence, + routingEvidence, publishCollectedDecisionQuota, setProviderQuotaReportCache, TERMINAL_QUOTA_FAILURE, type CodexAuthAccountsSnapshotPromise, @@ -228,6 +228,7 @@ export function flushProviderQuotaObservationsForTests(): Promise { return pendingProviderObservation; } +/** Collect provider quota reports, using the cache unless refresh is forced, and publish the secret-free decision evidence from the collected reports. */ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh = false): Promise { // A Pool report's cache signature and provider fetch must share one account snapshot. // Preserve force semantics when deciding whether that snapshot refreshes upstream data. @@ -326,7 +327,7 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh ) { const reports = response.reports.filter(item => mayCommitProviderQuotaKey(item.provider, writerGeneration)); setProviderQuotaReportCache({ key, ts: Date.now(), response: { ...response, reports } }); - replaceCachedProviderQuotas(reports, routingEvidence); + replaceCachedProviderQuotas(reports, routingEvidence); publishCollectedDecisionQuota(reports, config); notifyProviderQuotaSnapshot(reports, config); } return response; diff --git a/src/providers/quota/account-cache.ts b/src/providers/quota/account-cache.ts index a92f4f81371..7784b86bf15 100644 --- a/src/providers/quota/account-cache.ts +++ b/src/providers/quota/account-cache.ts @@ -1,3 +1,6 @@ +import { rawAnthropicHeaderWindows } from "./anthropic-decision-windows"; +import { clearDecisionAccountQuotas } from "../quota-decision-snapshot"; +import { bindRawDecisionWindows, publishAnthropicDecisionQuota } from "../quota-decision-publication"; import { parseAnthropicFamilyHeaders, mergeAnthropicFamilyWindows } from "./anthropic-family-headers"; import { anthropicModelQuotaFor, ANTHROPIC_PASSIVE_FAMILY_MAX_AGE_MS } from "../../oauth/anthropic-model-quota"; import { createHash } from "node:crypto"; @@ -289,6 +292,7 @@ export function recordAnthropicAccountQuotaFromHeadersForInstance( writerGeneration: number, status?: number, model?: string, + decisionCredentialGeneration?: string, ): void { if (!accountId) return; const key = accountCacheKey(instance, accountId); @@ -318,14 +322,20 @@ export function recordAnthropicAccountQuotaFromHeadersForInstance( ...(observed.customWindows ? { customWindows: mergeAnthropicFamilyWindows(previous?.quota?.customWindows, observed.customWindows) } : {}), }, observed.updatedAt), }); + // Advisory evidence is published for the primary pool only; Pool 2 stays unknown to decisions. + if (instance === "anthropic" && generation && generation === decisionCredentialGeneration) { + bindRawDecisionWindows(observed, rawAnthropicHeaderWindows(headers, observed.updatedAt)); + publishAnthropicDecisionQuota(accountId, generation, observed, true); + } persistAccountQuotaCache(); } /** Legacy recorder: config/roster generation is numeric and distinct from bearer ownership. */ export function recordAnthropicAccountQuotaFromHeaders( accountId: string, headers: Headers, writerGeneration: number, status?: number, model?: string, + decisionCredentialGeneration?: string, ): void { - recordAnthropicAccountQuotaFromHeadersForInstance("anthropic", accountId, headers, writerGeneration, status, model); + recordAnthropicAccountQuotaFromHeadersForInstance("anthropic", accountId, headers, writerGeneration, status, model, decisionCredentialGeneration); } /** @@ -446,6 +456,7 @@ export function resetProviderQuotaReconcileStateForTests(): void { /** Drop cached per-account rows (all, or just one provider's). */ export function clearAccountQuotaCache(provider?: string): void { + clearDecisionAccountQuotas(provider); // Anthropic readers use provider-owned epochs; B cannot revoke an A flight. if (provider !== "anthropic2") explicitAccountEpoch += 1; if (!provider) { diff --git a/src/providers/quota/anthropic-account-quota.ts b/src/providers/quota/anthropic-account-quota.ts index ff28c192ad0..6f653c0ddc4 100644 --- a/src/providers/quota/anthropic-account-quota.ts +++ b/src/providers/quota/anthropic-account-quota.ts @@ -62,6 +62,7 @@ export async function fetchAnthropicAccountQuota( // and a thrown read (network failure) both record the unavailable row over the retained // in-flight observations, as the pre-instance A path did, so joined callers and later cached // reads agree and windows that expired during the shared probe are normalized away. + /** Cache the settled probe result for an account and publish its decision evidence only while the flight is still current for its credential and generation. */ const publish = (result: AnthropicQuotaRecoveryResult | null): AccountQuotaCacheEntry => { const previous = accountQuotaCache.get(key); const retained = previous?.isCurrent?.() === false ? undefined : previous; @@ -70,7 +71,7 @@ export async function fetchAnthropicAccountQuota( ts: Date.now(), quota: normalizeAnthropicQuota(result?.quota ?? retained?.quota, Date.now()), ...(!result ? { unavailable: true as const } : {}), isCurrent, }; - if (isCurrent() && mayCommitAccountQuotaKey(key, writerGeneration)) { accountQuotaCache.set(key, entry); sweepExpiredOnWrite(entry.ts); } + if (isCurrent() && mayCommitAccountQuotaKey(key, writerGeneration)) { accountQuotaCache.set(key, entry); result?.publishDecisionQuota(); sweepExpiredOnWrite(entry.ts); } return entry; }; const probe = (async (): Promise => { diff --git a/src/providers/quota/anthropic-cooldown-recovery.ts b/src/providers/quota/anthropic-cooldown-recovery.ts index 114d0251fcc..087ec180f5f 100644 --- a/src/providers/quota/anthropic-cooldown-recovery.ts +++ b/src/providers/quota/anthropic-cooldown-recovery.ts @@ -1,3 +1,5 @@ +import { providerDecisionWindows } from "../quota-decision-publication"; +import { publishDecisionAccountQuota } from "../quota-decision-snapshot"; import { mergeAnthropicFamilyWindows, hasAnthropicFamilyEnumeration } from "./anthropic-family-headers"; import { getCachedProviderAccountQuota, captureProviderAccountQuotaEpoch } from "./account-cache"; import { anthropicModelQuotaFor } from "../../oauth/anthropic-model-quota"; @@ -10,6 +12,7 @@ export class AnthropicQuotaProbeOwnershipError extends Error {} export type AnthropicCooldownRecoveryProbe = Readonly<{ instance: AnthropicInstanceId; + generation: string; requiresFreshDispatch: boolean; isCurrentCredential(): boolean; isCurrentFamily(): boolean; @@ -20,10 +23,13 @@ export type AnthropicCooldownRecoveryProbe = Readonly<{ export type AnthropicQuotaRecoveryResult = Readonly<{ instance: AnthropicInstanceId; quota: ProviderQuota; + /** Publishes secret-free advisory evidence for the primary pool only; a no-op for Pool 2. */ + publishDecisionQuota(): void; /** Must be checked synchronously at each publication boundary. */ isCurrent(): boolean; }>; +/** Create the cooldown-recovery probe owner for one Anthropic instance; its results are credential- and generation-bound before they may publish. */ function createAnthropicCooldownRecovery(instance: AnthropicInstanceId) { const familyGeneration = (accountId: string) => anthropicModelQuotaFor(instance).anthropicFamilyQuotaGeneration(accountId); @@ -129,6 +135,7 @@ function createAnthropicCooldownRecovery(instance: AnthropicInstanceId) { }; return { instance, + generation, requiresFreshDispatch: claim !== null, isCurrentCredential, isCurrentFamily: () => familyGeneration(accountId) === capturedFamilyGeneration, @@ -165,6 +172,7 @@ function createAnthropicCooldownRecovery(instance: AnthropicInstanceId) { } // Clearing the claimed cooldown intentionally advances the fence. Adopt that exact new // generation; any later observation/429 then invalidates publication before a cache write. + const rawQuota = quota; const ownsFamily = probe.isCurrentFamily(); const authoritative = ownsFamily && hasAnthropicFamilyEnumeration(quota); if (ownsFamily) anthropicModelQuotaFor(instance).observeAnthropicFamilyQuota(accountId, quota.customWindows ?? [], quota.updatedAt, authoritative); @@ -177,14 +185,20 @@ function createAnthropicCooldownRecovery(instance: AnthropicInstanceId) { } const publicationGeneration = anthropicCooldownGeneration(accountId); const publicationFamilyGeneration = familyGeneration(accountId); + /** True while the probed credential, cooldown generation and family generation are unchanged and publication is still permitted. */ + const isCurrent = () => probe.isCurrentCredential() + && anthropicCooldownGeneration(accountId) === publicationGeneration + && familyGeneration(accountId) === publicationFamilyGeneration + && mayPublish(); afterSettlementForTests?.(); return { instance, quota, - isCurrent: () => probe.isCurrentCredential() - && anthropicCooldownGeneration(accountId) === publicationGeneration - && familyGeneration(accountId) === publicationFamilyGeneration - && mayPublish(), + isCurrent, + publishDecisionQuota: () => { + if (instance === "anthropic" && isCurrent()) publishDecisionAccountQuota("anthropic", accountId, probe.generation, + providerDecisionWindows(rawQuota).filter(row => ownsFamily || ["5h", "weekly", "monthly"].includes(row.window)), !authoritative); + }, }; } diff --git a/src/providers/quota/anthropic-decision-windows.ts b/src/providers/quota/anthropic-decision-windows.ts new file mode 100644 index 00000000000..f1e972cc096 --- /dev/null +++ b/src/providers/quota/anthropic-decision-windows.ts @@ -0,0 +1,47 @@ +/** Raw advisory projection; compatibility display clamping/rounding remains unchanged. */ +import type { DecisionQuotaWindow } from "../quota-decision-snapshot"; +import { normalizeResetAt, toFiniteNumber } from "../quota-wire"; +/** Return a plain object value, or undefined for non-objects and arrays. */ +const record = (value: unknown): Record | undefined => + value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; +/** Normalize a reset value; an explicit invalid reset becomes NaN so it cannot read as an undated valid measurement. */ +function reset(value: unknown): number | undefined { + // Explicit invalid reset must not become an apparently undated valid measurement. + return value === undefined || value === null ? undefined : normalizeResetAt(value) ?? NaN; +} +/** Extract raw decision windows (5h, weekly and per-model families) from an Anthropic usage body, keeping invalid resets distinct from display parsing. */ +export function rawAnthropicUsageWindows(body: Record, observedAt: number): DecisionQuotaWindow[] { + const rows: DecisionQuotaWindow[] = []; + /** Append one window when its percentage is a finite number, carrying its normalized reset. */ + const add = (window: DecisionQuotaWindow["window"], raw: unknown, percentField: string) => { + const row = record(raw); + const percent = toFiniteNumber(row?.[percentField]); + if (percent === undefined) return; + const resetAt = reset(row?.resets_at); + rows.push({ window, percent, observedAt, ...(resetAt !== undefined ? { resetAt } : {}) }); + }; + for (const [window, field] of [["5h", "five_hour"], ["weekly", "seven_day"], ["fable", "seven_day_fable"], ["opus", "seven_day_opus"], ["sonnet", "seven_day_sonnet"]] as const) add(window, body[field], "utilization"); + for (const limit of Array.isArray(body.limits) ? body.limits : []) { + const row = record(limit); + if (row?.kind !== "weekly_scoped") continue; + const display = record(record(row.scope)?.model)?.display_name; + if (typeof display !== "string") continue; + const label = display.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "").toLowerCase(); + const family = (["fable", "opus", "sonnet"] as const).find(family => label.includes(family)); + if (family) add(family, row, "percent"); + } + return rows; +} +/** Extract decision windows from Anthropic unified rate-limit response headers, converting utilization fractions to percentages. */ +export function rawAnthropicHeaderWindows(headers: Headers, observedAt: number): DecisionQuotaWindow[] { + const rows: DecisionQuotaWindow[] = []; + for (const [window, wire] of [["5h", "5h"], ["weekly", "7d"], ["fable", "7d_oi"]] as const) { + const fraction = toFiniteNumber(headers.get(`anthropic-ratelimit-unified-${wire}-utilization`)); + if (fraction === undefined || fraction < 0 || fraction > 1) continue; + const rawReset = headers.get(`anthropic-ratelimit-unified-${wire}-reset`); + const seconds = toFiniteNumber(rawReset); + const resetAt = rawReset === null ? undefined : seconds !== undefined && seconds > 0 ? seconds * 1000 : NaN; + rows.push({ window, percent: fraction * 100, observedAt, ...(resetAt !== undefined ? { resetAt } : {}) }); + } + return rows; +} diff --git a/src/providers/quota/report-cache.ts b/src/providers/quota/report-cache.ts index 8329e10f63e..0b22b5db9a7 100644 --- a/src/providers/quota/report-cache.ts +++ b/src/providers/quota/report-cache.ts @@ -1,3 +1,5 @@ +import { invalidateDecisionKeyQuotas, publishDecisionKeyQuota } from "../quota-decision-snapshot"; +import { providerDecisionWindows } from "../quota-decision-publication"; import { createHash } from "node:crypto"; import { effectiveCodexAuthAccountId, listCodexAuthAccountsSnapshot } from "../../codex/auth-api"; import { withoutRetiredCodexQuota, type StoredAccountQuota } from "../../codex/quota"; @@ -344,3 +346,14 @@ export function isProviderQuotaReportCurrent(value: ProviderQuotaReport): boolea return (generation === undefined || isMainAccountIdentityGenerationLive(generation)) && (accountReportCurrent.get(value)?.() ?? true); } + +/** Called only by an accepted collector/cache publication, never the JEV read path. */ +export function publishCollectedDecisionQuota(reports: ProviderQuotaReport[], config: OcxConfig): void { + invalidateDecisionKeyQuotas(); + for (const report of reports) { + const provider = config.providers[report.provider]; + const evidence = routingEvidence.get(report); + if (provider && evidence && evidence.binding === providerQuotaRoutingBinding(report.provider, provider)) + publishDecisionKeyQuota(report.provider, provider, providerDecisionWindows(evidence.quota)); + } +} diff --git a/src/providers/quota/vendor-probes-oauth.ts b/src/providers/quota/vendor-probes-oauth.ts index 72808aa5e4e..f4b2b55d9c9 100644 --- a/src/providers/quota/vendor-probes-oauth.ts +++ b/src/providers/quota/vendor-probes-oauth.ts @@ -1,3 +1,5 @@ +import { bindRawDecisionWindows } from "../quota-decision-publication"; +import { rawAnthropicUsageWindows } from "./anthropic-decision-windows"; import { markAnthropicFamilyEnumeration } from "./anthropic-family-headers"; import { effectiveCodexAuthAccountId, fetchMainAccountInfoSnapshot, listCodexAuthAccountsSnapshot } from "../../codex/auth-api"; import { MAIN_CODEX_ACCOUNT_ID } from "../../codex/main-account"; @@ -268,6 +270,7 @@ function parseClaudeLimit(value: unknown): ProviderQuotaWindow | null { /** Claude's OAuth usage endpoint, probed with ONE account's own bearer token. */ const anthropicUsageInflight = new Map>(); +/** Read the Anthropic OAuth usage endpoint for an access token and return the parsed quota, or null on a non-OK or malformed response. */ async function readAnthropicUsageQuota(accessToken: string): Promise { const response = await fetch("https://api.anthropic.com/api/oauth/usage", { headers: { @@ -314,6 +317,7 @@ async function readAnthropicUsageQuota(accessToken: string): Promise 0 ? { customWindows } : {}), updatedAt: Date.now(), }; + bindRawDecisionWindows(quota, rawAnthropicUsageWindows(body, quota.updatedAt)); // Empty / schema-changed payloads must not cache as "success with no bars". return hasQuotaRows(quota) ? markAnthropicFamilyEnumeration(quota, Array.isArray(body.limits) && body.limits.every(raw => { const kind = asRecord(raw)?.kind; @@ -392,7 +396,7 @@ export async function fetchAnthropicQuota(provider: string, config: OcxConfig = if (!initialRow || !currentRow || initialRow.loginId !== currentRow.loginId || initialRow.addedAt !== currentRow.addedAt || epoch !== captureProviderAccountQuotaEpoch(instance) || recovery.anthropicAccountIncarnation(probedAccountId) !== incarnation) return null; let quota: ProviderQuota | null; - let anthropicCurrent: (() => boolean) | undefined; + let anthropicCurrent: (() => boolean) | undefined; let anthropicPublish: (() => void) | undefined; try { const result = await recovery.probeAnthropicQuotaWithRecovery(probedAccountId, accessToken, fresh => { @@ -403,7 +407,7 @@ export async function fetchAnthropicQuota(provider: string, config: OcxConfig = () => targetCurrent() && mayCommitAccountQuotaKey(probedAccountKey, writerGeneration)); if (result && !result.isCurrent()) return null; quota = result?.quota ?? null; - anthropicCurrent = result?.isCurrent; + anthropicCurrent = result?.isCurrent; anthropicPublish = result?.publishDecisionQuota; } catch (error) { if (error instanceof AnthropicQuotaProbeOwnershipError) return null; throw error; @@ -414,7 +418,7 @@ export async function fetchAnthropicQuota(provider: string, config: OcxConfig = if (probedAccountId && probedAccountKey) { const stillOwnsToken = getAccountCredential(instance, probedAccountId)?.access === accessToken; if (stillOwnsToken && anthropicCurrent?.() && mayCommitAccountQuotaKey(probedAccountKey, writerGeneration)) { - accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota, isCurrent: anthropicCurrent }); + accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota, isCurrent: anthropicCurrent }); anthropicPublish?.(); } } const quotaReport = report(provider, "anthropic:oauth-usage", quota); diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index a1f64030cce..305baa3e1a8 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -116,6 +116,7 @@ function sparseComboConfig { const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; @@ -176,6 +177,8 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise = body.combo; const effectiveCombo = { ...requestedCombo, + ...(!Object.hasOwn(requestedCombo, "decisionQuotaSignals") && requestedCombo.strategy === "jev" && previous?.decisionQuotaSignals !== undefined ? { decisionQuotaSignals: previous.decisionQuotaSignals } : {}), + ...(!Object.hasOwn(requestedCombo, "decisionQuotaTiers") && requestedCombo.strategy === "jev" && previous?.decisionQuotaTiers !== undefined ? { decisionQuotaTiers: previous.decisionQuotaTiers } : {}), ...(!Object.hasOwn(requestedCombo, "cooldownMs") && previous?.cooldownMs !== undefined ? { cooldownMs: previous.cooldownMs } : {}), diff --git a/src/server/messages-native.ts b/src/server/messages-native.ts index 58277a0a707..7b4acb57a88 100644 --- a/src/server/messages-native.ts +++ b/src/server/messages-native.ts @@ -513,6 +513,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) && nativeMessagesDeclineReason({ ...route, provider: routed }, body, config, selector) === undefined; }; + /** Send the native Messages request under the transient retry policy, re-validating the selected OAuth account or API key at the dispatch boundary. */ const send = async (recovery?: "rate-limit-429" | "oauth-401" | "oauth-account-403" | "key-429" | "key-401"): Promise => { const remaining = remainingTransientSends(); if (requestTransientPolicy && remaining <= 0) { @@ -610,7 +611,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) && credentialGeneration(current.credential) === snapshot.generation && current.credential.accountId === providerAccountUuid) { bindAnthropicRefusalCredentialForSend(dispatched, sendOwner, providerAccountUuid); - recordAnthropicAccountQuotaFromHeadersForInstance(nativeInstance!, snapshot.accountId, dispatched.headers, writerGeneration, dispatched.status, route.modelId); + recordAnthropicAccountQuotaFromHeadersForInstance(nativeInstance!, snapshot.accountId, dispatched.headers, writerGeneration, dispatched.status, route.modelId, snapshot.generation); } } catch { /* Passive observation must not fail the response. */ } } diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 0d1f58163d0..1bac0ab0bd7 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -584,6 +584,7 @@ export async function executeComboResponses( isDestinationAllowed: (providerName, modelId) => routeAllowedByScope( resolveAdmissionModelScope(config, options.admission), { providerName, modelId }, ), + ...(combo.decisionQuotaSignals === true ? { decisionQuotaSignals: true, decisionQuotaTiers: combo.decisionQuotaTiers } : {}), ...(combo.decisionProvider ? { decisionProvider: combo.decisionProvider } : {}), ...(combo.decisionModel ? { @@ -627,6 +628,7 @@ export async function executeComboResponses( ? { chosenProbability: decision.chosenProbability } : {}), ...(decision.usage ? { usage: decision.usage } : {}), + ...(decision.quota ? { quota: decision.quota } : {}), }); console.debug("[combo] JEV decision", { backend: decision.backend, diff --git a/src/server/responses/jev-model-invoke.ts b/src/server/responses/jev-model-invoke.ts index e9e97b1e1fb..28990aef5eb 100644 --- a/src/server/responses/jev-model-invoke.ts +++ b/src/server/responses/jev-model-invoke.ts @@ -1,6 +1,7 @@ import { readBoundedResponseBytes } from "../../lib/bounded-body"; import { JevModelInvokeError, type JevModelInvoke, type JevModelInvokeResult } from "../../combos/jev-model-backend"; import { JEV_MAX_REQUEST_BYTES, JEV_MAX_RESPONSE_BYTES } from "../../combos/jev"; +import { serializeJevModelRequest } from "../../combos/jev-model-request"; import type { OcxConfig } from "../../types"; import { codexEffortRank, isCodexReasoningEffort } from "../../reasoning-effort"; import { routeConcreteModel } from "../../router"; @@ -92,7 +93,7 @@ function decodeSse(bytes: Uint8Array): JevModelInvokeResult { * bounds billed output (including reasoning) and gives the spend reservation a nonzero ceiling; * a truncated answer is malformed and fails open like any other bad reply. */ -export const JEV_MODEL_MAX_OUTPUT_TOKENS = 1024; +export { JEV_MODEL_MAX_OUTPUT_TOKENS } from "../../combos/jev-model-request"; /** * The cheapest reasoning effort the decision model declares, so a reasoning model cannot spend @@ -121,19 +122,17 @@ export function jevDecisionReasoningEffort(config: OcxConfig, model: string): st * or conversation header. It therefore runs on credentials configured on provider rows only. */ export function createJevModelInvoker(context: JevModelInvokerContext): JevModelInvoke { - return async ({ model, instructions, input, signal }) => { + return async ({ model, instructions, input, signal, withoutQuota }) => { const effort = jevDecisionReasoningEffort(context.config, model); - const body = JSON.stringify({ - model, - stream: true, - store: false, - instructions, - input: [{ role: "user", content: [{ type: "input_text", text: input }] }], - tools: [], - max_output_tokens: JEV_MODEL_MAX_OUTPUT_TOKENS, - ...(effort ? { reasoning: { effort } } : {}), - }); - if (new TextEncoder().encode(body).byteLength > JEV_MAX_REQUEST_BYTES) { + let body = serializeJevModelRequest({ model, instructions, input }, effort); + /** True when the serialized decision request exceeds the request byte limit. */ + const oversized = () => new TextEncoder().encode(body).byteLength > JEV_MAX_REQUEST_BYTES; + let quotaOmitted = false; + if (oversized() && withoutQuota) { + body = serializeJevModelRequest({ model, ...withoutQuota }, effort); + quotaOmitted = true; + } + if (oversized()) { throw new JevModelInvokeError("malformed", "decision request too large"); } const lease = tryAdmitTurn(undefined, signal); @@ -178,7 +177,7 @@ export function createJevModelInvoker(context: JevModelInvokerContext): JevModel return terminalResult(parsed, ""); })(); usage = result.usage; - return result; + return quotaOmitted ? { ...result, quotaOmitted: true } : result; } finally { // No final log row is written for this detached context, so settle its spend here. childLog.spendTracker?.settle(childLog.usage ?? (usage diff --git a/src/server/responses/request-transport.ts b/src/server/responses/request-transport.ts index 79e3e597434..cad8326212a 100644 --- a/src/server/responses/request-transport.ts +++ b/src/server/responses/request-transport.ts @@ -689,7 +689,7 @@ export async function prepareResponsesTransport( && current.credential.access === snapshot.accessToken && credentialGeneration(current.credential) === snapshot.generation) { bindAnthropicRefusalCredentialForSend(response, physicalOwner); - recordAnthropicAccountQuotaFromHeadersForInstance(anthropicInstance!, snapshot.accountId, response.headers, writerGeneration, response.status, route.modelId); + recordAnthropicAccountQuotaFromHeadersForInstance(anthropicInstance!, snapshot.accountId, response.headers, writerGeneration, response.status, route.modelId, snapshot.generation); } } catch { /* best-effort observation cannot fail the response */ } } diff --git a/src/types/config.ts b/src/types/config.ts index 0011ba8cdf6..ab2294da3d9 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1379,6 +1379,10 @@ export interface OcxComboConfig { * whose first call may include a cold model load. */ decisionTimeoutMs?: number; + /** JEV-only advisory loaded quota evidence; off/unset leaves request bytes unchanged. */ + decisionQuotaSignals?: boolean | null; + /** Partial 0..100 tier overrides; defaults limited 70/nearlyExhausted 90. null clears. */ + decisionQuotaTiers?: import("../combos/jev-quota-config").JevQuotaTiers | null; } export type OcxRoutingUnknownEvidenceMode = "allow" | "penalize" | "exclude"; diff --git a/src/usage/jev-stats.ts b/src/usage/jev-stats.ts index c3956837021..fe61f79aa4b 100644 --- a/src/usage/jev-stats.ts +++ b/src/usage/jev-stats.ts @@ -1,3 +1,4 @@ +import { normalizeJevQuotaSummary, type JevQuotaDecisionSummary } from "../combos/jev-quota-config"; import type { OcxComboDefaultEffort } from "../types"; import type { PersistedUsageEntry } from "./log"; import { usageDisplayTotalTokens } from "./totals"; @@ -39,6 +40,7 @@ export interface PersistedJevDecisionV1 { }; gate: JevDecisionGate; latencyMs: number; + quota?: JevQuotaDecisionSummary; confidence?: number; chosenProbability?: number; usage?: { @@ -182,6 +184,7 @@ export function normalizePersistedJevDecision(value: unknown): PersistedJevDecis const confidence = probability(value.confidence); const chosenProbability = probability(value.chosenProbability); const usage = normalizedDecisionUsage(value.usage); + const quota = normalizeJevQuotaSummary(value.quota); const backend = value.backend === "typesafe" || value.backend === "systemone" || value.backend === "model" ? value.backend : undefined; @@ -196,6 +199,7 @@ export function normalizePersistedJevDecision(value: unknown): PersistedJevDecis }, gate: gate as JevDecisionGate, latencyMs, + ...(quota ? { quota } : {}), ...(confidence !== undefined ? { confidence } : {}), ...(chosenProbability !== undefined ? { chosenProbability } : {}), ...(usage ? { usage } : {}), diff --git a/structure/cli-management.md b/structure/cli-management.md index 0aef70d2f90..ba1baafdd99 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -62,7 +62,7 @@ Management route declarations describe actual HTTP calls only; local Lab automat `src/cli/models-order.ts` owns raw upstream display-name addressing and routed picker operations. Status preserves saved native identities, while manual/preset replacement requires explicit reset of native-inclusive order. Manual input is a full unique routed permutation with the observed featured prefix; settings/identity rereads reject observable drift without claiming CAS. `src/cli/model-picker-ordering.ts` mirrors the GUI's pure identity/sort projection without importing GUI code into the CLI. Independent ordering fixtures and test-only GUI conformance exercise the deliberate shared contract. Most-used consumes complete all/all requested-model usage, not representative resolved-model attribution. The registry declares the existing display-name PUT; its handler remains authoritative, including saved-but-failed HTTP503. -`src/cli/route-policy-write.ts` accepts editable-only documents, requires the caller's update revision and uses fixed create/update/delete requests. Strict nested field ownership is local; provider/alias/routing validation stays on the server, without fabricated config context. Profile writes may activate already-enabled Lab automation. `src/cli/combo-input.ts` preserves target metadata and explicit false/default options before transport; `src/cli/combo.ts` applies them through its existing merge stages, retaining a carried force policy's omitted default effort. These combo upserts are not revision CAS. `src/cli/combo-stats.ts` reports actual JEV decision/token/coverage facts and nullable/incomplete observations without a savings estimate or decision probe. +`src/cli/route-policy-write.ts` accepts editable-only documents, requires the caller's update revision and uses fixed create/update/delete requests. Strict nested field ownership is local; provider/alias/routing validation stays on the server, without fabricated config context. Profile writes may activate already-enabled Lab automation. `src/cli/combo-input.ts` preserves target metadata and explicit false/default options before transport; `src/cli/combo.ts` applies them through its existing merge stages, retaining a carried force policy's omitted default effort. These combo upserts are not revision CAS. `--decision-quota-signals on|off|-` and `--decision-quota-tiers JSON|-` share quota validation; file/`--strategy jev` target replacement and rename preserve omitted policy (raw `--targets` alone keeps the existing failover default), off retains tiers and a strategy switch drops it. The receipt rebuild validates both fields; [loaded advisory quota](providers/jev-decision.md#loaded-advisory-quota) owns defaults and privacy. `src/cli/combo-stats.ts` reports actual JEV decision/token/coverage facts and nullable/incomplete observations without a savings estimate or decision probe. `src/cli/catalog-command-result.ts` owns fixed safe errors and shared catalog evidence only; callers validate their distinct public DTOs. `src/cli/combo-result.ts` rebuilds the combo write receipt and verifies the requested identity. Numeric partial outcomes reach the original root dispatch, and read-back is required before any caller retry. The helpers do not select endpoints, grant authority, perform writes or execute generic CRUD. Existing provider receipt semantics remain separate. Local discovery refusals raised by `runtimeBaseUrl` before any request carry a typed `RuntimeApiError.code` (`proxy_not_running`, `client_role_management_unavailable`) and render a fixed "No request was sent" message instead of uncertain-write wording; integration-route errors (normalized, unredirected path) may append a fixed recovery line keyed on the closed `reason` set; the writer's `message` prose is never echoed. diff --git a/structure/config.md b/structure/config.md index 86a27b4f835..594900ade27 100644 --- a/structure/config.md +++ b/structure/config.md @@ -1,6 +1,6 @@ # Config Surface -Quota activation reuses the existing next-reset fields without adding a polling configuration key. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). `src/types/config.ts` defines `anthropicAccountPool.routes` as ordered model rules. `src/oauth/anthropic-model-routes.ts` validates bounded names, full case-sensitive globs and stored account IDs; `src/config/diagnostics.ts` rejects malformed candidate writes. Load retains a malformed hand edit so unrelated providers survive, while the enabled Anthropic selector refuses it locally before dispatch. Saved rules remain inert when the pool is disabled; affinity is process-local. Request logs use only the rule’s 1-based `route:#` position, never its configured name. +JEV `decisionQuotaSignals` and partial `decisionQuotaTiers` use the shared validator and sparse preserve/clear/strategy-switch contract in [loaded advisory quota](providers/jev-decision.md#loaded-advisory-quota). Quota activation reuses the existing next-reset fields without adding a polling configuration key. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). `src/types/config.ts` defines `anthropicAccountPool.routes` as ordered model rules. `src/oauth/anthropic-model-routes.ts` validates bounded names, full case-sensitive globs and stored account IDs; `src/config/diagnostics.ts` rejects malformed candidate writes. Load retains a malformed hand edit so unrelated providers survive, while the enabled Anthropic selector refuses it locally before dispatch. Saved rules remain inert when the pool is disabled; affinity is process-local. Request logs use only the rule’s 1-based `route:#` position, never its configured name. Native function-result injection follows [the separate opt-in control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index bf7c4e1a774..f32906d60ef 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -131,7 +131,7 @@ The sidebar exposes eight rows (`gui/src/nav-groups.ts` `NAV_GROUPS`), in order: | Codex accounts | Account pool cards, add-account flow, switch and reset modals (`gui/src/components/CodexAccountPool.tsx`, `gui/src/components/AddCodexAccountModal.tsx`), plus the generic account-targeting picker opt-in on `gui/src/pages/codex-set-multiauth.tsx`. Add/delete/login completion is projected to one boolean before presentation; pending catalog work is a warning, not a failed account mutation. The main card's native-main device reauth (#3898) is owned by `gui/src/components/use-main-device-reauth.ts`: the dedicated `/api/codex-auth/main/reauth-device` namespace only — never the pool login route — with flowId-owned polling, an allowlisted verification URL, and no token fields accepted from payloads. The main-device reauth hook retains flow ownership from the Cancel click, while DELETE is unresolved and after retryable failure; polling normally continues. A concurrent GET HTTP error cannot expose a replacement login POST before DELETE settles. If a retryable DELETE failure races with a non-2xx GET while the flow is pending or committing, either response order preserves same-flow Cancel retry, restores the last server-provided device code, verification URL, and phase when needed, and keeps the existing poll cadence so a later terminal result remains observable. Outside same-flow cancellation ownership, a GET HTTP failure still stops polling without starting a second login POST. The cancellation-failure indication survives pending status updates until a trusted terminal result releases ownership. A successful DELETE with a terminal `failed` DTO releases it and uses the same closed failure-code mapping as polling; only `succeeded` notifies login completion. Unrecognized or nonterminal DTO status values remain retryable. A DELETE response with HTTP 404 and code `unknown_flow` releases the expired flow and shows the existing generic failure state so device re-login is available again; it claims neither login success nor confirmed cancellation. Confirmed cancellation also makes device re-login available. Start, polling and cancellation completions verify their controller or flow ownership after asynchronous response reads; replaced flows and unmounted hooks cannot update a newer flow or notify completion. Effect setup restores mounted state after the StrictMode development cleanup cycle. | | Dashboard overview | Overview, Providers, and Models tabs at the page level (`gui/src/pages/Dashboard.tsx`), the 30-day token and coverage stats in the overview head (`gui/src/pages/dashboard-overview-head.tsx`), and the effort-cap, injection, maintenance, sidecar, and memory panels below it (`gui/src/pages/dashboard-overview-panels.tsx`). | -JEV setup and its Stats tab reuse these shells; see [providers/jev-decision.md](./providers/jev-decision.md). +JEV setup and its Stats tab reuse these shells; omitted advanced quota fields survive editor saves without new GUI controls. Content-free v1 quota tier counts/selection follow [loaded advisory quota](providers/jev-decision.md#loaded-advisory-quota); see [providers/jev-decision.md](./providers/jev-decision.md). The native-main reauth poller captures an immutable accepted flow id for queued callbacks. Its POST, GET and DELETE JSON reads retain API error codes, but non-2xx responses never diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 86e18049382..233a9e2b0c2 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -311,7 +311,7 @@ after the save, and a non-retryable skip (no managed catalog) is a clean save. C | File-integration plans | `src/server/management/integration-routes.ts` and `aside-profile-routes.ts` — `POST /api/client-integrations/preview`, `POST /api/client-integrations/restore/preview`, and `POST /api/client-integrations/aside/profiles/{profileId}/preview`. Management-authenticated, declared non-mutating, and they write nothing: no snapshot, no lock, no maintenance, no recovery. They answer `409 integration_preview_unavailable` rather than gathering a model roster, because discovery refreshes credentials and writes the provider cache. Responses carry only declared managed schema paths, closed change kinds and an opaque fingerprint; no value, filesystem location or selected member identity appears. Mutation routes accept `operation` and `planFingerprint` together or not at all, reject a half-bound request and an operation that disagrees with the change, and answer `409 integration_preview_stale` with a freshly computed plan. Binding is an optimistic token, never authorization. [The integration contract](clients/integrations.md) owns the ordering. | | Grok reset coupons | `src/server/management/grok-coupon-routes.ts` — `GET /api/grok/reset-coupons`, `POST /api/grok/reset-coupons/consume`. The dashboard owner is `gui/src/hooks/useGrokResetCoupons.ts` with `gui/src/components/provider-workspace/GrokResetCoupons.tsx`, wired into the xAI OAuth rows of `ProviderAuthPanel`. Redemption truth is the settled ledger `code`, not the HTTP status: a replayed failure returns 200 with `replayed: true`. See [`providers/xai-grok.md`](providers/xai-grok.md). | | Claude reset grants | `src/server/management/anthropic-reset-grant-routes.ts` — `GET /api/anthropic/reset-grants`, `POST /api/anthropic/reset-grants/consume` (lazy-loaded). Wire and fail-closed parsing live in `src/providers/anthropic-reset-grants.ts` (the Claude Code 2.1.278 `cedar_ember` contract, sent with `CLAUDE_CLI_USER_AGENT` from `src/providers/claude-cli-identity.ts`); the journal is `src/providers/anthropic-reset-grant-ledger.ts`: a cross-process `BEGIN IMMEDIATE` lock around every synchronous read-modify-write, a 90 s lease, the operation id reused as the upstream `request_id`, same-id retry only inside the vendor's ten-minute window, no settlement inferred from a re-read, and a fail-closed `500 journal_write_failed` when an answer cannot be recorded. Spending requires the `gui-session` principal. The dashboard owner is `gui/src/hooks/useAnthropicResetGrants.ts` with `gui/src/components/provider-workspace/AnthropicResetGrants.tsx` on the Anthropic OAuth rows of `ProviderAuthPanel`; after an unknown outcome the dialog only retries the same id. Design and audit record: [`../devlog/_fin/260923_claude_reset_grants/010_plan.md`](../devlog/_fin/260923_claude_reset_grants/010_plan.md). | -| Combos | `src/server/management/combo-routes.ts` — `GET/PUT/DELETE /api/combos` own provider combination and failover definitions. `PUT` keeps a stored field the body omits (`cooldownMs`, `waitForCooldownMs`, `defaultEffortMode`, `reasoningEffortMode`, `imageInput`, `cooldownWaitPolicy`, per-target `lastResort`); explicit values replace it and defaults are stored sparse. | +| Combos | `src/server/management/combo-routes.ts` — `GET/PUT/DELETE /api/combos` own provider combination and failover definitions. `PUT` keeps a stored field the body omits (`cooldownMs`, `waitForCooldownMs`, `defaultEffortMode`, `reasoningEffortMode`, `imageInput`, `cooldownWaitPolicy`, per-target `lastResort`, JEV `decisionQuotaSignals`/`decisionQuotaTiers` on updates/rename); quota false/null clears sparsely and switching away removes quota policy under [the owning contract](providers/jev-decision.md#loaded-advisory-quota). Explicit values replace it and defaults are stored sparse. | | Protocol paths | `src/server/management/protocol-routes.ts` (lazy-loaded) — `GET /api/protocols` returns the contract version, the resolved API surfaces and protocol settings, the policy revision and the feature vocabulary; with `?provider=` (one non-empty name of at most 200 characters without control characters, else 400 `invalid_provider`; an unconfigured name is 404 `unknown_provider`, neither echoing the name) it adds a `provider` block from `src/protocols/provider-summary.ts` ([Protocol Paths](data-planes/protocol-paths.md#provider-wire-summary)); `POST /api/protocols/plan` takes `{ model, inbound, features? }` (model at most 200 characters, at most 24 features, any other key refused with 400) and returns a `ProtocolPlanV1` with `basis: "preview"` from `src/protocols/plan-snapshot.ts`. Both are read-only, never log their input, and send nothing upstream. `PATCH /api/protocols/settings` takes `{ messagesEnabled?, unrepresentable?, rollout? }` (strict: unknown keys and wrong types are 400), validates and applies through `src/server/management/protocol-settings-patch.ts`, persists with `saveConfigPreservingClaudeCode`, restores the live config if the save fails (409 on lock contention, 500 otherwise), and answers with the fresh `GET /api/protocols` body; closing Messages also writes `claudeCode.enabled = false` through `commitClaudeCodeBlock` ([Protocol Paths](data-planes/protocol-paths.md#settings)). The CLI drives all three through `ocx api protocols`, `ocx api explain` and `ocx api policy` ([Protocol Paths](data-planes/protocol-paths.md#cli)); none carries a route-registry exemption. | | Workflow budget | `src/server/management/workflow-budget-routes.ts` — `GET /api/workflow-budget` reads the tracked roots or one root, and `POST /api/workflow-budget/clear` clears exactly one. The clear moves the windowed send ring and the child map and nothing else: `active` belongs to turns still in flight, the spend ledger is a token budget an operator did not ask to forgive, and the lifetime send total survives so a clear cannot launder the record. A refusal event carries `spendScope` and `spendLimit` when a token ceiling fired, so the reason is readable without the config open beside it; no scope id is ever attached, because root ids are client thread headers and identity ids are credentials. Both are `deferred-verb` in the route registry — they are owed CLI verbs, and because the ledger is process memory there is no local projection the CLI could read instead. See [`../devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md`](../devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md). | | Codex accounts | `src/codex/auth-api/routes.ts` — `GET/POST/DELETE /api/codex-auth/accounts`, `PUT /api/codex-auth/accounts/alias`, `PUT /api/codex-auth/accounts/pause`, `PUT /api/codex-auth/accounts/pause-exhausted`, `POST /api/codex-auth/accounts/clear-cooldown`, `GET/PUT /api/codex-auth/active`, `PUT /api/codex-auth/auto-switch`, `PUT /api/codex-auth/pool-strategy`, `PUT /api/codex-auth/failover`, `GET /api/codex-auth/quota`, `GET /api/codex-auth/reset-credits` with `POST /api/codex-auth/reset-credits/consume`, and the login flow `POST /api/codex-auth/login`, `POST /api/codex-auth/login/code`, `POST /api/codex-auth/login/cancel`, `GET /api/codex-auth/login-status`. Per-account quota activation uses the existing `GET/PUT /api/settings` surface and `src/codex/quota-auto-refresh.ts`, keeping scheduled spending separate from credential/authentication mutation. Account ids are opaque handles and are serialized so the GUI can address an account; emails are masked and tokens are never serialized. New-account config commits add UI-managed selector bindings in the same config save; deletion deliberately retains existing bindings for fail-closed exact routing and re-add stability. Account mutations request catalog convergence only after config durability and expose only the boolean `catalogRefreshPending` completion projection. `showCodexCredits` is a default-off boolean in settings GET and successful PUT; partial PUT accepts a boolean and rolls back on persistence failure. See [credits exposure](providers/openai-accounts.md#display-only-codex-credits). | diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 90b13c2640f..5e0671082b5 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -350,7 +350,7 @@ and the upstream URL through `handleResponses`. ## TypeSafe JEV decision provider The JEV Combo decision contract (TypeSafe, compatible HTTPS services with any endpoint path, local System One rows, and opencodex-model -decision backends) lives in [JEV Decision Routing](providers/jev-decision.md). +decision backends) lives in [JEV Decision Routing](providers/jev-decision.md), including [loaded-only advisory quota](providers/jev-decision.md#loaded-advisory-quota); credential owners publish bounded secret-free generation projections without expanding the quota barrel or changing hard selection. ## Preset notes diff --git a/structure/providers/anthropic-account-pool.md b/structure/providers/anthropic-account-pool.md index 9283037fded..7d794474ab2 100644 --- a/structure/providers/anthropic-account-pool.md +++ b/structure/providers/anthropic-account-pool.md @@ -393,3 +393,11 @@ updating live state. A confirmed published write followed by bookkeeping failure pool and returns a fixed warning; an unpublished failure keeps old state. Unknown outcomes require reload and are not represented as a successful save. The GUI checkbox is a saved preference rather than a promise that a route is eligible; explicit rollout opt-outs still apply. + +## JEV advisory publication + +Existing credential load/persist and accepted quota producer boundaries publish bounded secret-free +rosters/windows to `src/providers/quota-decision-snapshot.ts`. These owner-observed generations +are separate from display caches and serving-account selection. Request decisions consume only +loaded evidence; cold or mismatched generations remain unknown. Usability, partial-window clocks +and late-writer fences follow [loaded advisory quota](jev-decision.md#loaded-advisory-quota). Only the primary `anthropic` pool publishes; `anthropic2` is excluded and stays unknown. diff --git a/structure/providers/jev-decision.md b/structure/providers/jev-decision.md index 3373fb16e35..f35b3954622 100644 --- a/structure/providers/jev-decision.md +++ b/structure/providers/jev-decision.md @@ -82,6 +82,56 @@ optional decision uses the existing fail-open inference target without sending a management probes and unrestricted admissions retain their existing behavior. Telemetry never retains extracted state or credentials. +## Loaded advisory quota + +`decisionQuotaSignals: true` is the per-Combo opt-in across TypeSafe, System One and model +backends. `src/combos/jev-quota-config.ts` merges partial `decisionQuotaTiers` over limited 70 / +nearlyExhausted 90; optional moderate is explicit, finite 0–100 values strictly ascend. The +switch is sparse; false/null clears it while omitted JEV updates and rename preserve policy. +Threshold null clears overrides; switching away drops both fields. API and CLI share validation. + +`src/providers/quota-decision-snapshot.ts` stores only bounded scalar windows and secret-free +owner-observed generations in memory, scoped to the config root. Credential loaders/mutations, +accepted collectors and response producers publish or invalidate evidence. Reading advisory quota +evidence never invokes credential resolution, auth-store loading, disk quota hydration, keychain +reads, account selectors or network work. Normal decision-backend credential resolution is unchanged. +The sole-key policy fingerprint stays private; multiple-key/current-account reports do not supply +pool evidence. Root/generation changes and account deletion retire rows. No timer or refresher is +activated by evidence reads. Credential changes become visible through existing owners. + +`src/providers/quota-decision-publication.ts` projects subscription windows. Anthropic raw +usage/header projections and Codex raw reset validity/producer clocks stay separate from unchanged display and hard-policy parsing; scoped family +windows require producer-validated structure. Captured generations and cooldown/family fences +reject late observations. Partial responses retain omitted windows with their own original clocks; +authoritative enumerations replace them. Codex pool writers require dispatched live provenance; +native-main writer objects bind the observed bearer generation. Display-only and disk-carried +rows cannot republish advisory evidence. Producer/store bounds overflow to unknown, not a +truncated supposedly exhaustive pool. + +`src/combos/jev-quota.ts` uses the worst valid relevant 5h/weekly/monthly/requested-family +window per account, then the best usable pool tier in stable order. Observations older than +30 minutes, future/invalid clocks or percentages and expired resets are ignored. Unknown ties +healthy; all usable accounts must be known nearly exhausted before the pool can be. Existing +Anthropic model routes restrict the loaded roster; Codex pause/reauth/terminal validation and +main usability observations exclude unusable rows. Reserve without its own bars is unknown. +Credits, API, USD, Spark and unscoped display bars never contribute. Sole-key reports reuse each +producer's existing 0-100 normalized bars (the provider quota guard's inputs); Anthropic/Codex raw +observations outside 0-100 are ignored rather than clamped. Sole-key evidence is dropped when the +ordinary key resolver next observes a changed credential; advisory quota evidence reads never resolve keys. + +`src/combos/jev-quota-route.ts` enriches only opted-in candidates. Authorization precedes +service evidence reads. TypeSafe criteria carry scalar quota fields, System One/model descriptions +carry advisory clauses, and all retain target/effort ordering and parser/deadline/abort contracts. +If additions exceed 64 KiB, serialization retries without quota before ordinary size refusal. +`src/combos/jev-model-request.ts` owns the exact detached Responses JSON envelope, including escaping. The model backend uses it for quota sizing; the invoker adds its existing reasoning effort and checks that same serialized budget before admission/dispatch, selecting the supplied quota-free prompt only on overflow. This never retries a sent request or malformed output. +Off payload bytes remain unchanged. Outbound quota omits ids/fingerprints; the optional v1 +`quota` telemetry is only closed tier counts (total at most 64) and selected tier, normalized by +`src/usage/jev-stats.ts`. It contains no windows, percentages, resets or account identity. +Hard eligibility, veto, cooldown and serving-account selection remain separate and unchanged. +Only the primary `anthropic` pool is published; `anthropic2` publishes no roster or windows, so its targets stay unknown (`tests/routing/jev-quota-anthropic2.test.ts`). +Coverage: `tests/routing/jev-quota-signals.test.ts`, `jev-quota-config.test.ts` and +`jev-quota-publication.test.ts`, `tests/routing/jev-quota-codex-reset.test.ts` and `tests/server/server-jev-model-quota-budget.test.ts`. + The optional `targets[].modelProfile` note is validated at the Combo management input boundary to a non-empty string of at most 512 characters; tab, line feed and carriage return are allowed for multi-line notes, every other C0 control character and DEL is diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index ecb69bfe6e7..df24b850238 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -532,3 +532,11 @@ replacement drops that window's warning. Main/all quota cleanup forgets the base expire at reset or six hours after detection. Main-card/settings DTOs and live CLI projections expose only window kind, previous/current percentage and observation time; the notice says possible usage outside opencodex because a long-running request can also explain the rise. + +## JEV advisory publication + +Existing credential load/persist and accepted quota producer boundaries publish bounded secret-free +rosters/windows to `src/providers/quota-decision-snapshot.ts`. These owner-observed generations +are separate from display caches and serving-account selection. Request decisions consume only +loaded evidence; cold or mismatched generations remain unknown. Usability, partial-window clocks +and late-writer fences follow [loaded advisory quota](jev-decision.md#loaded-advisory-quota). Codex header/WHAM parsers bind a separate in-memory raw-reset projection to their parsed observation; an explicit invalid reset stays invalid and its producer clock survives publication without changing display, durable history or hard-policy parsing. diff --git a/structure/runtime.md b/structure/runtime.md index 1dffd973640..d5a51cdfddf 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -417,7 +417,7 @@ the old binding; restoring the same configuration may reuse still-fresh evidence cooldowns and response-driven retry remain authoritative. The management quota DTO keeps Combo editing aligned with scoped inference evidence; -see [Combo editor routing quota](dashboard-and-usage.md#combo-editor-routing-quota). +see [Combo editor routing quota](dashboard-and-usage.md#combo-editor-routing-quota). JEV separately reads bounded loaded-only advisory snapshots, never this credential-resolving getter; publication, opt-in and privacy follow [loaded advisory quota](providers/jev-decision.md#loaded-advisory-quota). Canonical Spark Lite metadata follows the final serialized model and surviving nonempty Lite tool catalog; see [Responses transport](transports/responses.md). diff --git a/tests/codex-integration/codex-account-store-quota-isolation.test.ts b/tests/codex-integration/codex-account-store-quota-isolation.test.ts new file mode 100644 index 00000000000..09bce8485d0 --- /dev/null +++ b/tests/codex-integration/codex-account-store-quota-isolation.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import * as snapshot from "../../src/providers/quota-decision-snapshot"; +import { getCodexAccountCredential, loadCodexAccountRecordSnapshot, saveCodexAccountCredential } from "../../src/codex/account-store"; +import { getMainAccountCredentialPresence, setMainAccountCredentialPresence } from "../../src/codex/main-account-cache"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +let home: TempHome; +let publish: ReturnType | undefined; +beforeEach(() => { home = createTempHome("ocx-codex-quota-iso-"); snapshot.publishDecisionQuotaRoster("codex", []); }); +afterEach(() => { publish?.mockRestore(); publish = undefined; home.remove(); }); +const credential = { accessToken: "fixture-access", refreshToken: "fixture-refresh", expiresAt: 2_000_000_000_000, chatgptAccountId: "fixture-account" }; +const accountFiles = () => readdirSync(home.configDir).filter(name => name.startsWith("codex-accounts")); +const failPublication = () => spyOn(snapshot, "publishDecisionQuotaRoster").mockImplementation(() => { throw new Error("injected publication failure"); }); + +test("a throwing quota publication neither empties a loaded Codex store nor backs the file up", () => { + saveCodexAccountCredential("a1", credential); + const before = JSON.stringify(loadCodexAccountRecordSnapshot()); + const bytes = readFileSync(join(home.configDir, "codex-accounts.json"), "utf-8"); + const files = accountFiles(); + publish = failPublication(); + expect(() => loadCodexAccountRecordSnapshot()).not.toThrow(); + expect(JSON.stringify(loadCodexAccountRecordSnapshot())).toBe(before); + expect(getCodexAccountCredential("a1")?.accessToken).toBe("fixture-access"); + expect(accountFiles()).toEqual(files); + expect(readFileSync(join(home.configDir, "codex-accounts.json"), "utf-8")).toBe(bytes); + expect(publish).toHaveBeenCalled(); +}); + +test("a throwing quota publication does not fail or undo Codex persistence, and withdraws the roster", () => { + saveCodexAccountCredential("a1", credential); + expect(snapshot.readLoadedDecisionQuotaPool("codex")).toHaveLength(1); + publish = failPublication(); + expect(() => saveCodexAccountCredential("a1", { ...credential, accessToken: "replacement" })).not.toThrow(); + expect(snapshot.readLoadedDecisionQuotaPool("codex")).toBeUndefined(); + publish.mockRestore(); publish = undefined; + expect(getCodexAccountCredential("a1")?.accessToken).toBe("replacement"); +}); + +test("a throwing quota publication does not break main-credential presence transitions, and withdraws the main roster", () => { + setMainAccountCredentialPresence(true); + expect(snapshot.readLoadedDecisionQuotaPool("codex-main")).toHaveLength(1); + publish = failPublication(); + expect(() => setMainAccountCredentialPresence(false)).not.toThrow(); + expect(getMainAccountCredentialPresence()).toBe(false); + expect(snapshot.readLoadedDecisionQuotaPool("codex-main")).toBeUndefined(); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 9071bebffff..fa308ad3d8d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -368,6 +368,7 @@ "codex-account-label.test.ts": "codex-integration", "codex-account-mode-state.test.ts": "gui", "codex-account-namespaces.test.ts": "codex-integration", "codex-account-selection-preferences.test.ts": "codex-integration", + "codex-account-store-quota-isolation.test.ts": "codex-integration", "codex-account-store-refresh-classification.test.ts": "codex-integration", "codex-account-store.test.ts": "codex-integration", "codex-account-threshold-api.test.ts": "codex-integration", "codex-account-threshold-auth.test.ts": "codex-integration", @@ -552,7 +553,7 @@ "cyber-policy-error-fidelity.test.ts": "providers", "dashboard-sidecar-codex-write.test.ts": "gui", "dashboard-uptime.test.ts": "gui", "data-plane-admission-identity.test.ts": "server", "debug-settings.test.ts": "server", "debug.test.ts": "lib", - "decision-discovery.test.ts": "server", "server-jev-model-decision-e2e.test.ts": "server", + "decision-discovery.test.ts": "server", "server-jev-model-decision-e2e.test.ts": "server", "server-jev-model-quota-budget.test.ts": "server", "decision-routes.test.ts": "server", "deepinfra-provider.test.ts": "providers", "deepseek-inbound-wire.test.ts": "providers", "deepseek-reasoning-replay-gaps.test.ts": "providers", "deepseek-reasoning-replay.test.ts": "providers", "deepseek-responses-item-id-repair.test.ts": "providers", @@ -666,7 +667,7 @@ "key-login-preserves-model-costs.test.ts": "oauth", "jev-decision.test.ts": "routing", "jev-service-exchange.test.ts": "routing", "jev-decision-destination.test.ts": "routing", "jev-decision-provider-combo.test.ts": "routing", - "jev-typesafe-golden.test.ts": "routing", "jev-model-backend.test.ts": "routing", + "jev-typesafe-golden.test.ts": "routing", "jev-quota-signals.test.ts": "routing", "jev-quota-config.test.ts": "routing", "jev-quota-publication.test.ts": "routing", "jev-quota-codex-reset.test.ts": "routing", "jev-quota-anthropic2.test.ts": "routing", "jev-model-backend.test.ts": "routing", "jev-provider.test.ts": "providers", "keyring-smoke.test.ts": "ci-workflows", "kilo-client.test.ts": "clients", "kimi-oauth-identity.test.ts": "providers", "kimi-responses-adjacency.test.ts": "providers", "kiro-account-load.test.ts": "providers/kiro", @@ -802,7 +803,7 @@ "oauth-provider-reconcile.test.ts": "oauth", "oauth-public-surface.test.ts": "oauth", "oauth-reauth-bind.test.ts": "oauth", "oauth-refresh-generic-lock.test.ts": "oauth", "oauth-refresh-lock-multiprocess.test.ts": "oauth", "oauth-refresh.test.ts": "oauth", - "oauth-status-privacy.test.ts": "oauth", "oauth-store-multi.test.ts": "oauth", + "oauth-status-privacy.test.ts": "oauth", "oauth-store-multi.test.ts": "oauth", "oauth-store-quota-isolation.test.ts": "oauth", "oauth-tos-warning.test.ts": "gui", "oauth-upsert-preserves-api-key.test.ts": "oauth", "ocx-launcher-runtime.test.ts": "cli", "ocx-launcher-source.test.ts": "cli", "ocx-launcher-desktop-handoff.test.ts": "cli", "cli-path-diagnostics.test.ts": "cli", diff --git a/tests/oauth/oauth-store-quota-isolation.test.ts b/tests/oauth/oauth-store-quota-isolation.test.ts new file mode 100644 index 00000000000..5f554ce8c1e --- /dev/null +++ b/tests/oauth/oauth-store-quota-isolation.test.ts @@ -0,0 +1,36 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import * as snapshot from "../../src/providers/quota-decision-snapshot"; +import { getAccountSet, loadAuthStore, saveCredential } from "../../src/oauth/store"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +let home: TempHome; +let publish: ReturnType | undefined; +beforeEach(() => { home = createTempHome("ocx-auth-quota-iso-"); snapshot.publishDecisionQuotaRoster("anthropic", []); }); +afterEach(() => { publish?.mockRestore(); publish = undefined; home.remove(); }); +const credential = { access: "fixture-access", refresh: "fixture-refresh", expires: 2_000_000_000_000 }; +const authFiles = () => readdirSync(home.configDir).filter(name => name.startsWith("auth")); + +test("a throwing quota publication neither empties a loaded auth store nor backs the file up", async () => { + await saveCredential("anthropic", credential); + const before = JSON.stringify(loadAuthStore()); + const bytes = readFileSync(join(home.configDir, "auth.json"), "utf-8"); + const files = authFiles(); + publish = spyOn(snapshot, "publishDecisionQuotaRoster").mockImplementation(() => { throw new Error("injected publication failure"); }); + expect(JSON.stringify(loadAuthStore())).toBe(before); + expect(loadAuthStore().anthropic?.accounts).toHaveLength(1); + expect(authFiles()).toEqual(files); + expect(readFileSync(join(home.configDir, "auth.json"), "utf-8")).toBe(bytes); + expect(publish).toHaveBeenCalled(); +}); + +test("a throwing quota publication does not fail or undo auth persistence, and withdraws the roster", async () => { + await saveCredential("anthropic", credential); + expect(snapshot.readLoadedDecisionQuotaPool("anthropic")).toHaveLength(1); + publish = spyOn(snapshot, "publishDecisionQuotaRoster").mockImplementation(() => { throw new Error("injected publication failure"); }); + await saveCredential("anthropic", { ...credential, access: "replacement" }); + expect(snapshot.readLoadedDecisionQuotaPool("anthropic")).toBeUndefined(); + publish.mockRestore(); publish = undefined; + expect(getAccountSet("anthropic")?.accounts[0]?.credential.access).toBe("replacement"); +}); diff --git a/tests/routing/jev-quota-anthropic2.test.ts b/tests/routing/jev-quota-anthropic2.test.ts new file mode 100644 index 00000000000..bd4e4834800 --- /dev/null +++ b/tests/routing/jev-quota-anthropic2.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { createAnthropicInstanceFixture, type AnthropicInstanceFixture } from "../helpers/anthropic-instance-fixture"; +import { readLoadedDecisionQuotaPool } from "../../src/providers/quota-decision-snapshot"; +import { anthropicCooldownRecoveryFor } from "../../src/providers/quota/anthropic-cooldown-recovery"; +import { recordAnthropicAccountQuotaFromHeadersForInstance } from "../../src/providers/quota/account-cache"; +import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; +import { jevQuotaSignalForTarget } from "../../src/combos/jev-quota"; +import { setAccountPaused } from "../../src/oauth/store"; +import type { AnthropicInstanceId } from "../../src/providers/anthropic-instance-id"; + +// Both pools deliberately share account ids, so an unscoped publisher would collide. +let f: AnthropicInstanceFixture; +beforeEach(async () => { f = await createAnthropicInstanceFixture(); await f.seed(); }); +afterEach(async () => { await f.dispose(); }); + +/** Read the loaded primary-pool decision windows for an account id. */ +const windowsOf = (id: string) => readLoadedDecisionQuotaPool("anthropic")?.find(row => row.id === id)?.windows; +/** Credential generation of an account in the given Anthropic instance. */ +const generationOf = (instance: AnthropicInstanceId, id: string) => + f.store.credentialGeneration(f.store.getAccountCredential(instance, id)!); +/** Force the strongest collision: same id AND same credential generation in both pools. */ +async function collidePool2Credential(id: string): Promise { + await f.store.mutateStore(auth => { + const primary = auth.anthropic!.accounts.find(row => row.id === id)!.credential; + auth.anthropic2!.accounts.find(row => row.id === id)!.credential = { ...primary }; + }); + expect(generationOf("anthropic2", id)).toBe(generationOf("anthropic", id)); +} +/** Publish a quota probe result for an account through the instance's real cooldown-recovery path. */ +async function publishProbe(instance: AnthropicInstanceId, id: string, weeklyPercent: number): Promise { + const token = f.store.getAccountCredential(instance, id)!.access; + const result = await anthropicCooldownRecoveryFor(instance).probeAnthropicQuotaWithRecovery( + id, token, async () => ({ weeklyPercent, updatedAt: Date.now() }), () => true); + result!.publishDecisionQuota(); +} + +test("only the primary pool roster is published; Pool 2 has no advisory roster", () => { + expect(readLoadedDecisionQuotaPool("anthropic")?.map(row => row.id)).toEqual([...f.ids]); + expect(readLoadedDecisionQuotaPool("anthropic2")).toBeUndefined(); +}); + +test("a Pool 2 usage probe cannot publish onto a primary account with the same id and credential generation", async () => { + const id = f.ids[0]; + await collidePool2Credential(id); + await publishProbe("anthropic2", id, 95); + expect(windowsOf(id)).toBeUndefined(); + await publishProbe("anthropic", id, 40); + expect(windowsOf(id)?.[0]?.percent).toBe(40); +}); + +test("Pool 2 response headers cannot publish onto a primary account with the same id and credential generation", async () => { + const id = f.ids[0]; + await collidePool2Credential(id); + const headers = new Headers({ "anthropic-ratelimit-unified-7d-utilization": "0.95" }); + recordAnthropicAccountQuotaFromHeadersForInstance("anthropic2", id, headers, captureConfigGeneration(), 200, f.model, generationOf("anthropic2", id)); + expect(windowsOf(id)).toBeUndefined(); + recordAnthropicAccountQuotaFromHeadersForInstance("anthropic", id, headers, captureConfigGeneration(), 200, f.model, generationOf("anthropic", id)); + expect(windowsOf(id)?.[0]?.percent).toBeCloseTo(95, 8); +}); + +test("Pool 2 account state does not change primary usability or retained primary evidence", async () => { + const id = f.ids[0]; + await publishProbe("anthropic", id, 40); + await setAccountPaused("anthropic2", id, true); + expect(readLoadedDecisionQuotaPool("anthropic")?.find(row => row.id === id)).toMatchObject({ usable: true }); + expect(windowsOf(id)?.[0]?.percent).toBe(40); + await setAccountPaused("anthropic", id, true); + expect(readLoadedDecisionQuotaPool("anthropic")?.find(row => row.id === id)?.usable).toBe(false); +}); + +test("a Pool 2 target is unknown while the same-id primary pool carries advisory evidence", async () => { + for (const id of f.ids) await publishProbe("anthropic", id, 95); + const now = Date.now(); + expect(jevQuotaSignalForTarget(f.config, "anthropic", f.model, now).tier).toBe("nearly_exhausted"); + expect(jevQuotaSignalForTarget(f.config, "anthropic2", f.model, now)).toEqual({ tier: "unknown" }); +}); diff --git a/tests/routing/jev-quota-codex-reset.test.ts b/tests/routing/jev-quota-codex-reset.test.ts new file mode 100644 index 00000000000..f6c0670e9f6 --- /dev/null +++ b/tests/routing/jev-quota-codex-reset.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { fetchMainAccountInfoAttempt } from "../../src/codex/auth-api/main-account-probe"; +import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import { saveCodexAccountCredential, capturePoolQuotaWriter } from "../../src/codex/account-store"; +import { applyAccountQuotaFromUpstreamHeaders, clearAccountQuota, getAccountQuota, getMainPolicyQuota, parseUsageQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { commitPoolQuotaResponse } from "../../src/codex/auth-api/pool-quota-probe"; +import { clearMainAccountInfoCache, observeMainQuotaIdentity, observeMainQuotaCredential, setMainAccountCredentialPresence } from "../../src/codex/main-account-cache"; +import { readLoadedDecisionQuotaPool } from "../../src/providers/quota-decision-snapshot"; +import { jevQuotaSignalFromWindows } from "../../src/combos/jev-quota"; +import { captureConfigGeneration, reconcileStateGeneration } from "../../src/lib/state-store-sweeper"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +const now = 2_000_000_000_000; +let home: TempHome; +let clock: ReturnType; +beforeEach(() => { + // An earlier file in the same bun process may have reconciled with an incomplete context: the owners that ran before the + // failing one keep the raised fence while the shared generation stays behind, so every default-generation writer here + // would be discarded as stale. A complete reconciliation realigns both without touching registrations. + reconcileStateGeneration({ generation: 0, providerNames: new Set(), comboIds: new Set(), comboTargets: new Set(), + codexAccountIds: new Set(), oauthAccountKeys: new Set(), configRoots: new Set() }); + home = createTempHome("ocx-jq-reset-"); + clock = spyOn(Date, "now").mockReturnValue(now); + clearAccountQuota(); + clearMainAccountInfoCache(); +}); +afterEach(() => { clearAccountQuota(); resetMainCodexAccountIdentityTrackingForTests(); clock.mockRestore(); home.remove(); }); +/** Capture a live main quota writer for the fixture identity and credential. */ +function mainWriter() { + observeMainQuotaIdentity("fixture-main"); + setMainAccountCredentialPresence(true); + return observeMainQuotaCredential("fixture-access", "fixture-main")!; +} +/** Save a fixture pool credential and capture its quota writer. */ +function poolWriter() { + const credential = { accessToken: "fixture-access", refreshToken: "fixture-refresh", expiresAt: now + 60_000, chatgptAccountId: "fixture-subject" }; + const generation = saveCodexAccountCredential("fixture", credential); + return { generation, writer: capturePoolQuotaWriter("fixture", { ...credential, generation })! }; +} +/** Loaded decision windows of the first account for a provider. */ +const windows = (provider: string) => readLoadedDecisionQuotaPool(provider)![0]!.windows; + +for (const reset of ["NaN", "Infinity", "-1", "invalid", ""]) { + for (const owner of ["main", "pool"] as const) { + test(`${owner} headers retain invalid reset ${JSON.stringify(reset)} without changing policy/display`, () => { + const main = owner === "main" ? mainWriter() : undefined; + const pool = owner === "pool" ? poolWriter() : undefined; + const id = main ? "__main__" : "fixture"; + applyAccountQuotaFromUpstreamHeaders(id, new Headers({ "x-codex-primary-used-percent": "95", "x-codex-primary-reset-at": reset }), undefined, main, + pool ? { poolWriter: pool.writer, poolResponse: true } : undefined); + const advisory = windows(main ? "codex-main" : "codex"); + expect(advisory).toHaveLength(1); + expect(Number.isNaN(advisory![0]!.resetAt)).toBe(true); + expect(jevQuotaSignalFromWindows(advisory, "gpt-6-astra", now).tier).toBe("unknown"); + expect(getAccountQuota(id)).toMatchObject({ weeklyPercent: 95 }); + expect(getAccountQuota(id)!.weeklyResetAt).toBeUndefined(); + if (main) expect(getMainPolicyQuota()).toMatchObject({ weeklyPercent: 95 }); + }); + } + test(`pool WHAM retains invalid reset ${JSON.stringify(reset)} through its real commit boundary`, async () => { + const pool = poolWriter(); + const result = await commitPoolQuotaResponse(Response.json({ rate_limit: { primary_window: { used_percent: 95, reset_at: reset, limit_window_seconds: 604800 } } }), { + accountId: "fixture", existing: null, configuredPlan: "plus", generation: pool.generation, writerGeneration: captureConfigGeneration(), poolWriter: pool.writer, + }); + expect(jevQuotaSignalFromWindows(windows("codex"), "gpt-6-astra", now).tier).toBe("unknown"); + expect(Number.isNaN(windows("codex")![0]!.resetAt)).toBe(true); + expect(result.freshQuota).toEqual({ weeklyPercent: 95 }); + }); +} + +test("main WHAM producer retains raw invalid reset through its owned mocked refresh", async () => { + resetLifecycleDrainStateForTests(); + resetMainCodexAccountIdentityTrackingForTests(); + const account = "fixture-main-wham"; + const claims = Buffer.from(JSON.stringify({ exp: Math.floor(now / 1000) + 3600, + "https://api.openai.com/auth": { chatgpt_account_id: account } })).toString("base64url"); + mkdirSync(home.codexHome, { recursive: true }); + writeFileSync(home.path(".codex", "auth.json"), JSON.stringify({ tokens: { + access_token: `header.${claims}.signature`, refresh_token: "fixture-refresh", account_id: account, + } })); + reconcileMainCodexAccountRuntimeState(); + const fetch = spyOn(globalThis, "fetch").mockImplementation((async () => Response.json({ plan_type: "plus", rate_limit: { + primary_window: { used_percent: 95, reset_at: "Infinity", limit_window_seconds: 604800 }, + } })) as unknown as typeof globalThis.fetch); + try { + const result = await fetchMainAccountInfoAttempt(true, 0, undefined, false, true, false, + { port: 0, defaultProvider: "p", providers: {} }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(result.freshQuota).toEqual({ weeklyPercent: 95 }); + expect(jevQuotaSignalFromWindows(windows("codex-main"), "gpt-6-astra", now).tier).toBe("unknown"); + expect(Number.isNaN(windows("codex-main")![0]!.resetAt)).toBe(true); + expect(getMainPolicyQuota()).toMatchObject({ weeklyPercent: 95 }); + } finally { fetch.mockRestore(); } +}); + +test("producer preserves short/secondary/tertiary reset validity independently", () => { + const writer = mainWriter(); + applyAccountQuotaFromUpstreamHeaders("__main__", new Headers({ + "x-codex-primary-window-minutes": "300", "x-codex-primary-used-percent": "95", "x-codex-primary-reset-at": "NaN", + "x-codex-secondary-used-percent": "70", "x-codex-secondary-reset-at": String((now + 60_000) / 1000), + "x-codex-tertiary-used-percent": "99", "x-codex-tertiary-reset-at": "Infinity", + }), undefined, writer); + expect(jevQuotaSignalFromWindows(windows("codex-main"), "gpt-6-astra", now)).toMatchObject({ tier: "limited", usedPercent: 70, window: "weekly", resetsInSeconds: 60 }); +}); + +test("omitted reset remains valid; partial headers never refresh an omitted window clock", () => { + const writer = mainWriter(); + applyAccountQuotaFromUpstreamHeaders("__main__", new Headers({ "x-codex-primary-used-percent": "95" }), undefined, writer); + expect(jevQuotaSignalFromWindows(windows("codex-main"), "gpt-6-astra", now).tier).toBe("nearly_exhausted"); + clock.mockReturnValue(now + 31 * 60_000); + applyAccountQuotaFromUpstreamHeaders("__main__", new Headers({ "x-codex-primary-window-minutes": "300", "x-codex-primary-used-percent": "20" }), undefined, writer); + expect(windows("codex-main")).toContainEqual({ window: "weekly", percent: 95, observedAt: now }); + expect(jevQuotaSignalFromWindows(windows("codex-main"), "gpt-6-astra", Date.now())).toMatchObject({ tier: "healthy", usedPercent: 20, window: "5h" }); +}); + +test("parsed WHAM projection keeps its producer age instead of acquiring a commit clock", () => { + const pool = poolWriter(); + const quota = parseUsageQuota({ rate_limit: { primary_window: { used_percent: 95, reset_at: (now + 3_600_000) / 1000 } } })!; + clock.mockReturnValue(now + 31 * 60_000); + setAccountQuotaFromParsed("fixture", quota, undefined, undefined, null, { writer: pool.writer, observedAt: Date.now(), source: "wham", raw: quota }, true); + expect(windows("codex")![0]!.observedAt).toBe(now); + expect(jevQuotaSignalFromWindows(windows("codex"), "gpt-6-astra", Date.now()).tier).toBe("unknown"); +}); + +test("a live main writer without explicit decision evidence publishes no advisory window from display or policy data", () => { + const writer = mainWriter(); + const quota = parseUsageQuota({ rate_limit: { primary_window: { used_percent: 95, reset_at: (now + 3_600_000) / 1000 } } })!; + setAccountQuotaFromParsed("__main__", quota, undefined, writer, quota); + expect(getAccountQuota("__main__")).toMatchObject({ weeklyPercent: 95 }); + expect(getMainPolicyQuota()).toMatchObject({ weeklyPercent: 95 }); + expect(readLoadedDecisionQuotaPool("codex-main")?.flatMap(row => row.windows ?? []) ?? []).toEqual([]); +}); diff --git a/tests/routing/jev-quota-config.test.ts b/tests/routing/jev-quota-config.test.ts new file mode 100644 index 00000000000..1ba5bbc7ce0 --- /dev/null +++ b/tests/routing/jev-quota-config.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { readFileSync, writeFileSync } from "node:fs"; +import { comboConfigIssues, getCombo } from "../../src/combos/types"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { getConfigPath, loadConfig, saveConfig } from "../../src/config"; +import { handleComboRoutes } from "../../src/server/management/combo-routes"; +import { handleComboCommand } from "../../src/cli/combo"; +import type { RuntimeApiDeps } from "../../src/cli/runtime-api"; +import type { OcxConfig } from "../../src/types"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +const targets = [{ provider: "fixture", model: "m" }]; +let home: TempHome; +let config: OcxConfig; +let log: ReturnType; +let error: ReturnType; +beforeEach(() => { + home = createTempHome("ocx-jq-config-"); + log = spyOn(console, "log").mockImplementation(() => {}); + error = spyOn(console, "error").mockImplementation(() => {}); + config = { port: 10100, defaultProvider: "fixture", providers: { fixture: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", models: ["m"] } }, combos: { saved: { strategy: "jev", targets, decisionQuotaSignals: true, decisionQuotaTiers: { moderate: 40 } } } }; + saveConfig(config); +}); +afterEach(() => { log.mockRestore(); error.mockRestore(); home.remove(); }); +/** Call the real combo management route handler and parse its response. */ +async function api(method: string, body?: unknown): Promise { + const req = new Request("http://127.0.0.1/api/combos", { method, ...(body ? { headers: { "content-type": "application/json" }, body: JSON.stringify(body) } : {}) }); + const response = await handleComboRoutes({ req, url: new URL(req.url), config, deps: {}, version: "fixture", trustedLoopbackIngress: true, guiSessionIssuance: null, + convergeCodexCatalog: async () => ({ status: "committed", changed: false, degraded: false, notices: [] }), syncClaudeAgentDefsBestEffort: async () => {} }); + if (!response) throw new Error("missing combo response"); + return response; +} +const deps: RuntimeApiDeps = { + findLiveProxy: async () => ({ pid: null, port: 14000, source: "runtime" }), + fetchImpl: (async (_input, init) => api(init?.method ?? "GET", init?.body ? JSON.parse(String(init.body)) : undefined)) as typeof fetch, +}; +/** PUT a combo definition, optionally renaming from an existing id. */ +const put = (combo: unknown, id = "saved", renameFrom?: string) => api("PUT", { id, combo, ...(renameFrom ? { renameFrom } : {}) }); + +describe("quota config load and validation", () => { + test("partial thresholds merge without enabling quota and load through real schema", () => { + config.combos!.saved!.decisionQuotaSignals = false; + config.combos!.saved!.decisionQuotaTiers = { limited: 80 }; + saveConfig(config); + expect(getCombo(loadConfig(), "saved")).toMatchObject({ decisionQuotaTiers: { limited: 80, nearlyExhausted: 90 } }); + expect(getCombo(loadConfig(), "saved")).not.toHaveProperty("decisionQuotaSignals"); + expect(configSchema.safeParse(config).success).toBe(true); + config.combos!.saved!.decisionQuotaTiers = { limited: 95 }; + expect(configSchema.safeParse(config).success).toBe(false); + }); + test("only JEV strategy accepts policy, null clears and invalid values reject", () => { + for (const combo of [{ strategy: "failover", targets, decisionQuotaSignals: true }, { strategy: "jev", targets, decisionQuotaSignals: "on" }, { strategy: "jev", targets, decisionQuotaTiers: { limited: 90 } }, { strategy: "jev", targets, decisionQuotaTiers: { moderate: 70 } }]) expect(comboConfigIssues("saved", combo, config.providers).length).toBeGreaterThan(0); + expect(comboConfigIssues("saved", { strategy: "jev", targets, decisionQuotaSignals: null, decisionQuotaTiers: null }, config.providers)).toEqual([]); + }); +}); +describe("actual API quota persistence", () => { + test("GET/omitted GUI-style PUT/rename preserve advanced fields; clear/switch remove", async () => { + expect((await put({ strategy: "jev", targets })).status).toBe(200); + const listed = await (await api("GET")).json(); + expect(listed.combos[0]).toMatchObject({ decisionQuotaSignals: true, decisionQuotaTiers: { moderate: 40, limited: 70, nearlyExhausted: 90 } }); + expect((await put({ strategy: "jev", targets }, "renamed", "saved")).status).toBe(200); + expect(config.combos?.saved).toBeUndefined(); + expect(loadConfig().combos?.renamed?.decisionQuotaSignals).toBe(true); + expect((await put({ strategy: "jev", targets, decisionQuotaSignals: false }, "renamed")).status).toBe(200); + expect(config.combos?.renamed).not.toHaveProperty("decisionQuotaSignals"); + expect(config.combos?.renamed).toHaveProperty("decisionQuotaTiers"); + expect((await put({ strategy: "jev", targets, decisionQuotaSignals: null, decisionQuotaTiers: null }, "renamed")).status).toBe(200); + expect(config.combos?.renamed).not.toHaveProperty("decisionQuotaTiers"); + expect((await put({ strategy: "jev", targets, decisionQuotaSignals: true, decisionQuotaTiers: {} }, "renamed")).status).toBe(200); + expect((await put({ strategy: "failover", targets }, "renamed")).status).toBe(200); + expect(loadConfig().combos?.renamed).not.toHaveProperty("decisionQuotaSignals"); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8")).combos.renamed).not.toHaveProperty("decisionQuotaTiers"); + }); + test("invalid overrides do not mutate persisted policy", async () => { + const before = readFileSync(getConfigPath(), "utf8"); + expect((await put({ strategy: "jev", targets, decisionQuotaTiers: { limited: 91 } })).status).toBe(400); + expect(readFileSync(getConfigPath(), "utf8")).toBe(before); + }); +}); +describe("actual CLI quota round trip", () => { + test("partial update, raw target replacement, rename, off, explicit clear and strategy switch", async () => { + expect(await handleComboCommand(["set", "saved", "--decision-quota-tiers", '{"limited":80}', "--json"], deps)).toBe(0); + expect(config.combos?.saved).toMatchObject({ decisionQuotaSignals: true, decisionQuotaTiers: { limited: 80, nearlyExhausted: 90 } }); + expect(await handleComboCommand(["set", "saved", "--targets", "fixture/m", "--strategy", "jev", "--json"], deps)).toBe(0); + expect(config.combos?.saved?.decisionQuotaSignals).toBe(true); + expect(await handleComboCommand(["set", "renamed", "--rename-from", "saved", "--json"], deps)).toBe(0); + expect(config.combos?.renamed?.decisionQuotaSignals).toBe(true); + expect(await handleComboCommand(["set", "renamed", "--decision-quota-signals", "off", "--json"], deps)).toBe(0); + expect(config.combos?.renamed).not.toHaveProperty("decisionQuotaSignals"); + expect(config.combos?.renamed).toHaveProperty("decisionQuotaTiers"); + expect(await handleComboCommand(["set", "renamed", "--decision-quota-signals", "on", "--decision-quota-tiers", "-", "--json"], deps)).toBe(0); + expect(config.combos?.renamed).not.toHaveProperty("decisionQuotaTiers"); + expect(await handleComboCommand(["set", "renamed", "--decision-quota-signals", "-", "--json"], deps)).toBe(0); + expect(config.combos?.renamed).not.toHaveProperty("decisionQuotaSignals"); + expect(await handleComboCommand(["set", "renamed", "--decision-quota-signals", "on", "--json"], deps)).toBe(0); + expect(await handleComboCommand(["set", "renamed", "--strategy", "failover", "--json"], deps)).toBe(0); + expect(config.combos?.renamed).not.toHaveProperty("decisionQuotaSignals"); + }); + test("malformed CLI overrides fail before writing", async () => { + for (const args of [["--decision-quota-signals", "yes"], ["--decision-quota-tiers", "{"], ["--decision-quota-tiers", '{"limited":100}']]) expect(await handleComboCommand(["set", "saved", ...args, "--json"], deps)).not.toBe(0); + expect(config.combos?.saved?.decisionQuotaTiers).toEqual({ moderate: 40 }); + const path = home.path("targets.json"); writeFileSync(path, JSON.stringify(targets)); + expect(await handleComboCommand(["set", "saved", "--targets-file", path, "--json"], deps)).toBe(0); + expect(config.combos?.saved?.decisionQuotaSignals).toBe(true); + }); +}); diff --git a/tests/routing/jev-quota-publication.test.ts b/tests/routing/jev-quota-publication.test.ts new file mode 100644 index 00000000000..818c73a0cb3 --- /dev/null +++ b/tests/routing/jev-quota-publication.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { publishDecisionAccountQuota, publishDecisionQuotaRoster, readLoadedDecisionQuotaPool, publishDecisionKeyQuota, readLoadedDecisionKeyQuota, invalidateDecisionKeyQuotas, setDecisionAccountUsable } from "../../src/providers/quota-decision-snapshot"; +import { publishAnthropicDecisionQuota, publishCodexDecisionQuota } from "../../src/providers/quota-decision-publication"; +import { observeMainDecisionCredentialUsable, clearMainAccountInfoCache, observeMainQuotaIdentity, observeMainQuotaCredential, mainDecisionQuotaWriterGeneration, setMainAccountCredentialPresence } from "../../src/codex/main-account-cache"; +import { saveCodexAccountCredential, capturePoolQuotaWriter, removeCodexAccountCredential } from "../../src/codex/account-store"; +import { applyAccountQuotaFromUpstreamHeaders, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { saveCredential, getAccountSet, getAccountCredential, credentialGeneration, saveAccountCredential, setAccountPaused, markAccountNeedsReauth, removeAccount } from "../../src/oauth/store"; +import { probeAnthropicQuotaWithRecovery, setAnthropicQuotaAfterSettlementForTests } from "../../src/providers/quota/anthropic-cooldown-recovery"; +import { rawAnthropicUsageWindows, rawAnthropicHeaderWindows } from "../../src/providers/quota/anthropic-decision-windows"; +import { fetchAnthropicUsageQuota } from "../../src/providers/quota/vendor-probes-oauth"; +import { recordAnthropicAccountQuotaFromHeaders } from "../../src/providers/quota/account-cache"; +import { jevQuotaSignalFromWindows } from "../../src/combos/jev-quota"; +import { keyReport, publishCollectedDecisionQuota } from "../../src/providers/quota/report-cache"; +import { parseOllamaCloudBalance } from "../../src/providers/quota"; +import { resolveProviderApiKey, invalidateResolvedProviderKeyCache } from "../../src/providers/api-key-resolve"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import type { OcxConfig } from "../../src/types"; + +let home: TempHome; +const now = 2_000_000_000_000; +let clock: ReturnType; +beforeEach(() => { home = createTempHome("ocx-jq-pub-"); clock = spyOn(Date, "now").mockReturnValue(now); clearMainAccountInfoCache(); invalidateDecisionKeyQuotas(); publishDecisionQuotaRoster("anthropic", []); publishDecisionQuotaRoster("codex", []); }); +afterEach(() => { setAnthropicQuotaAfterSettlementForTests(undefined); clock.mockRestore(); invalidateDecisionKeyQuotas(); home.remove(); }); +const credential = { access: "fixture-access", refresh: "fixture-refresh", expires: now + 3_600_000 }; +/** Save a fixture Anthropic credential and return its account id. */ +async function anthropicAccount(): Promise { + await saveCredential("anthropic", credential); + return getAccountSet("anthropic")!.accounts[0]!.id; +} + +test("auth mutation owner publishes pause, reauth, replacement and deletion without JEV loading", async () => { + const id = await anthropicAccount(); + const generation = credentialGeneration(getAccountCredential("anthropic", id)!); + publishAnthropicDecisionQuota(id, generation, { weeklyPercent: 90, updatedAt: now }); + expect(readLoadedDecisionQuotaPool("anthropic")?.[0]).toMatchObject({ usable: true, windows: [{ window: "weekly", percent: 90, observedAt: now }] }); + await setAccountPaused("anthropic", id, true); + expect(readLoadedDecisionQuotaPool("anthropic")?.[0]?.usable).toBe(false); + await setAccountPaused("anthropic", id, false); + await markAccountNeedsReauth("anthropic", id, true); + expect(readLoadedDecisionQuotaPool("anthropic")?.[0]?.usable).toBe(false); + await saveAccountCredential("anthropic", id, { ...credential, access: "replacement" }); + expect(readLoadedDecisionQuotaPool("anthropic")?.[0]).toMatchObject({ usable: true }); + expect(readLoadedDecisionQuotaPool("anthropic")?.[0]?.windows).toBeUndefined(); + publishAnthropicDecisionQuota(id, generation, { weeklyPercent: 99, updatedAt: now }); + expect(readLoadedDecisionQuotaPool("anthropic")?.[0]?.windows).toBeUndefined(); + await removeAccount("anthropic", id); + expect(readLoadedDecisionQuotaPool("anthropic")).toEqual([]); +}); + +test("Anthropic probe publication is captured-generation bound and raw family clocks stay old", async () => { + const id = await anthropicAccount(); + const generation = credentialGeneration(credential); + publishAnthropicDecisionQuota(id, generation, { updatedAt: now - 1000, customWindows: [{ label: "Opus", scope: "model", percent: 90 }] }); + const probe = await probeAnthropicQuotaWithRecovery(id, credential.access, async () => ({ weeklyPercent: 20, updatedAt: now }), () => true); + probe!.publishDecisionQuota(); + expect(readLoadedDecisionQuotaPool("anthropic")?.[0]?.windows).toContainEqual({ window: "opus", percent: 90, observedAt: now - 1000 }); + await saveAccountCredential("anthropic", id, { ...credential, access: "replacement" }); + probe!.publishDecisionQuota(); + expect(readLoadedDecisionQuotaPool("anthropic")?.[0]?.windows).toBeUndefined(); +}); + +test("Codex credential owner and accepted writer reject late generations and display-only writes", () => { + const cred = { accessToken: "fixture-access", refreshToken: "fixture-refresh", expiresAt: now + 3_600_000, chatgptAccountId: "fixture-subject" }; + const generation = saveCodexAccountCredential("fixture", cred); + const writer = capturePoolQuotaWriter("fixture", { ...cred, generation }); + expect(writer?.credentialGeneration).toBe(generation); + setAccountQuotaFromParsed("fixture", { weeklyPercent: 90 }); + expect(readLoadedDecisionQuotaPool("codex")?.[0]?.windows).toBeUndefined(); + setAccountQuotaFromParsed("fixture", { weeklyPercent: 90 }, undefined, undefined, null, { writer: writer!, source: "wham", raw: { weeklyPercent: 90 }, observedAt: now }); + expect(readLoadedDecisionQuotaPool("codex")?.[0]?.windows?.[0]?.percent).toBe(90); + saveCodexAccountCredential("fixture", { ...cred, accessToken: "replacement" }); + setAccountQuotaFromParsed("fixture", { weeklyPercent: 99 }, undefined, undefined, null, { writer: writer!, source: "wham", raw: { weeklyPercent: 99 }, observedAt: now }); + expect(readLoadedDecisionQuotaPool("codex")?.[0]?.windows).toBeUndefined(); + removeCodexAccountCredential("fixture"); + expect(readLoadedDecisionQuotaPool("codex")?.[0]?.usable).toBe(false); +}); + +test("partial Codex headers and credits preserve omitted window clocks; full observation replaces", () => { + publishDecisionQuotaRoster("codex", [{ id: "fixture", generation: 1, usable: true }]); + publishCodexDecisionQuota("fixture", 1, { weeklyPercent: 90, shortPercent: 50 }, now - 1000); + publishCodexDecisionQuota("fixture", 1, { shortPercent: 20 }, now); + publishCodexDecisionQuota("fixture", 1, { credits: { unlimited: true, observedAt: now } }, now); + expect(readLoadedDecisionQuotaPool("codex")?.[0]?.windows).toContainEqual({ window: "weekly", percent: 90, observedAt: now - 1000 }); + publishCodexDecisionQuota("fixture", 1, { weeklyPercent: 20 }, now, "codex", false); + expect(readLoadedDecisionQuotaPool("codex")?.[0]?.windows).toEqual([{ window: "weekly", percent: 20, observedAt: now }]); +}); + +test("main writers bind bearer generation and physical disappearance clears evidence", () => { + observeMainQuotaIdentity("fixture-main"); setMainAccountCredentialPresence(true); + const writer = observeMainQuotaCredential("fixture-access", "fixture-main")!; + const generation = mainDecisionQuotaWriterGeneration(writer)!; + publishCodexDecisionQuota("__main__", generation, { monthlyPercent: 90 }, now, "codex-main"); + expect(readLoadedDecisionQuotaPool("codex-main")?.[0]?.windows?.[0]?.percent).toBe(90); + observeMainQuotaCredential("replacement", "fixture-main"); + expect(mainDecisionQuotaWriterGeneration(writer)).toBeUndefined(); + expect(readLoadedDecisionQuotaPool("codex-main")?.[0]?.windows).toBeUndefined(); + setMainAccountCredentialPresence(false); + setMainAccountCredentialPresence(true); + expect(mainDecisionQuotaWriterGeneration(writer)).toBeUndefined(); + expect(readLoadedDecisionQuotaPool("codex-main")?.[0]?.windows).toBeUndefined(); + expect(readLoadedDecisionQuotaPool("codex-main")?.[0]?.usable).toBe(false); + const current = observeMainQuotaCredential("replacement", "fixture-main")!; + applyAccountQuotaFromUpstreamHeaders("__main__", new Headers({ "x-codex-tertiary-used-percent": "90" }), undefined, current); + expect(readLoadedDecisionQuotaPool("codex-main")?.[0]?.windows?.[0]?.window).toBe("monthly"); + observeMainDecisionCredentialUsable(false); + expect(readLoadedDecisionQuotaPool("codex-main")?.[0]?.usable).toBe(false); + observeMainDecisionCredentialUsable(true); +}); + +test("accepted sole-key collector excludes display reports and detects owner-observed env replacement", () => { + const provider = { adapter: "openai-chat" as const, baseUrl: "https://fixture.invalid/v1", apiKey: "$JEV_FIXTURE_QUOTA_KEY" }; + const config: OcxConfig = { port: 0, defaultProvider: "fixture", providers: { fixture: provider } }; + const old = process.env.JEV_FIXTURE_QUOTA_KEY; + try { + process.env.JEV_FIXTURE_QUOTA_KEY = "fixture-one"; + const measured = { weeklyPercent: 90, updatedAt: now }; + const row = keyReport("fixture", "fixture", measured, provider, resolveProviderApiKey(provider.apiKey)!, measured)!; + publishCollectedDecisionQuota([row], config); + expect(readLoadedDecisionKeyQuota("fixture", provider)?.[0]?.percent).toBe(90); + process.env.JEV_FIXTURE_QUOTA_KEY = "fixture-two"; + resolveProviderApiKey(provider.apiKey); + expect(readLoadedDecisionKeyQuota("fixture", provider)).toBeUndefined(); + publishCollectedDecisionQuota([row], config); + expect(readLoadedDecisionKeyQuota("fixture", provider)).toBeUndefined(); + publishDecisionKeyQuota("fixture", provider, [{ window: "weekly", percent: 90, observedAt: now }]); + const display = keyReport("fixture", "fixture", measured, provider, "fixture-two")!; + publishCollectedDecisionQuota([display], config); + expect(readLoadedDecisionKeyQuota("fixture", provider)).toBeUndefined(); + } finally { if (old === undefined) delete process.env.JEV_FIXTURE_QUOTA_KEY; else process.env.JEV_FIXTURE_QUOTA_KEY = old; invalidateResolvedProviderKeyCache(); } +}); + +test("sole-key credits, USD and unscoped custom meters never become advisory windows", () => { + const provider = { adapter: "openai-chat" as const, baseUrl: "https://fixture.invalid/v1", apiKey: "fixture-meter-key" }; + const config: OcxConfig = { port: 0, defaultProvider: "fixture", providers: { fixture: provider } }; + const meters = { + creditsUsd: { used: 99, limit: 100, remaining: 1, percent: 99 }, + customWindows: [{ label: "API credits ($1.00 of $100.00 remaining)", percent: 99 }, { label: "Opus", percent: 99 }, { label: "Total subscription credits", percent: 99 }], + kiroCreditsUsed: 99, kiroCreditsLimit: 100, updatedAt: now, + }; + publishCollectedDecisionQuota([keyReport("fixture", "fixture", meters, provider, "fixture-meter-key", meters)!], config); + expect(readLoadedDecisionKeyQuota("fixture", provider) ?? []).toEqual([]); + const subscription = { ...meters, fiveHourPercent: 12, updatedAt: now }; + publishCollectedDecisionQuota([keyReport("fixture", "fixture", subscription, provider, "fixture-meter-key", subscription)!], config); + expect(readLoadedDecisionKeyQuota("fixture", provider)?.map(row => [row.window, row.percent])).toEqual([["5h", 12]]); +}); + +test("Ollama Cloud balance windows are advisory; the included-credit meter is not", () => { + const provider = { adapter: "openai-chat" as const, baseUrl: "https://fixture.invalid/v1", apiKey: "fixture-ollama-key" }; + const config: OcxConfig = { port: 0, defaultProvider: "fixture", providers: { fixture: provider } }; + /** Publish a collected Ollama Cloud balance body through the real collector path. */ + const publish = (body: Record) => { + const quota = parseOllamaCloudBalance(body)!; + publishCollectedDecisionQuota([keyReport("fixture", "fixture", quota, provider, "fixture-ollama-key", quota)!], config); + }; + publish({ included: { session: { remaining_percent: 40 }, weekly: { remaining_percent: 25 }, allowance_usd: 10, balance_usd: 0.1 } }); + expect(readLoadedDecisionKeyQuota("fixture", provider)?.map(row => [row.window, row.percent])).toEqual([["5h", 60], ["weekly", 75]]); + invalidateDecisionKeyQuotas(); + publish({ included: { allowance_usd: 10, balance_usd: 0.1 } }); + expect(readLoadedDecisionKeyQuota("fixture", provider) ?? []).toEqual([]); +}); + +test("snapshot reads cannot mutate retained evidence and config-root changes discard rows", () => { + publishDecisionQuotaRoster("codex", [{ id: "fixture", generation: 1, usable: true }]); + publishDecisionAccountQuota("codex", "fixture", 1, [{ window: "weekly", percent: 90, observedAt: now }]); + const window = readLoadedDecisionQuotaPool("codex")![0]!.windows![0]!; + window.percent = 0; + expect(readLoadedDecisionQuotaPool("codex")![0]!.windows![0]!.percent).toBe(90); + const previous = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home.path("other"); + try { expect(readLoadedDecisionQuotaPool("codex")).toBeUndefined(); } finally { process.env.OPENCODEX_HOME = previous; } +}); + +test("raw Anthropic producer preserves exact percent and invalid reset/overage stays unknown", async () => { + const id = await anthropicAccount(); + const generation = credentialGeneration(credential); + const fetch = spyOn(globalThis, "fetch").mockImplementation((async () => Response.json({ + five_hour: { utilization: -1 }, seven_day: { utilization: 69.999 }, + seven_day_opus: { utilization: 120 }, + limits: [{ kind: "weekly_scoped", scope: { model: { display_name: "Opus" } }, percent: 89.999 }], + })) as unknown as typeof globalThis.fetch); + try { + const result = await probeAnthropicQuotaWithRecovery(id, credential.access, fresh => fetchAnthropicUsageQuota(credential.access, fresh), () => true); + result!.publishDecisionQuota(); + const windows = readLoadedDecisionQuotaPool("anthropic")![0]!.windows; + expect(jevQuotaSignalFromWindows(windows, "claude-opus-4", now).usedPercent).toBe(89.999); + expect(jevQuotaSignalFromWindows(windows, "claude-sonnet-4", now).usedPercent).toBe(69.999); + } finally { fetch.mockRestore(); } + const headers = new Headers({ "anthropic-ratelimit-unified-7d-utilization": "0.699999", "anthropic-ratelimit-unified-7d_oi-utilization": "1.01" }); + recordAnthropicAccountQuotaFromHeaders(id, headers, Number.MAX_SAFE_INTEGER, 200, "claude-sonnet-4", generation); + expect(jevQuotaSignalFromWindows(readLoadedDecisionQuotaPool("anthropic")![0]!.windows, "claude-sonnet-4", now).usedPercent).toBeCloseTo(69.9999, 8); + recordAnthropicAccountQuotaFromHeaders(id, new Headers({ "anthropic-ratelimit-unified-7d-utilization": "0.9" }), Number.MAX_SAFE_INTEGER, 200); + expect(jevQuotaSignalFromWindows(readLoadedDecisionQuotaPool("anthropic")![0]!.windows, "claude-sonnet-4", now).usedPercent).toBeCloseTo(69.9999, 8); + expect(rawAnthropicHeaderWindows(headers, now).some(row => row.window === "fable")).toBe(false); + expect(jevQuotaSignalFromWindows(rawAnthropicUsageWindows({ seven_day: { utilization: 90, resets_at: "invalid" } }, now), "a", now).tier).toBe("unknown"); +}); + +test("bounded rosters never hide unknown usable accounts; account-wide quarantine survives replacement", () => { + publishDecisionQuotaRoster("codex", [{ id: "fixture", generation: 1, usable: true }]); + setDecisionAccountUsable("codex", "fixture", false); + publishDecisionQuotaRoster("codex", [{ id: "fixture", generation: 2, usable: true }]); + expect(readLoadedDecisionQuotaPool("codex")?.[0]?.usable).toBe(false); + setDecisionAccountUsable("codex", "fixture", true, 2); + expect(readLoadedDecisionQuotaPool("codex")?.[0]?.usable).toBe(false); + setDecisionAccountUsable("codex", "fixture", true); + expect(readLoadedDecisionQuotaPool("codex")?.[0]?.usable).toBe(true); + const duplicates = Array.from({ length: 12 }, (_, i) => ({ window: "weekly" as const, percent: i === 11 ? 90 : 10, observedAt: now })); + publishDecisionAccountQuota("codex", "fixture", 2, duplicates); + expect(jevQuotaSignalFromWindows(readLoadedDecisionQuotaPool("codex")?.[0]?.windows, "a", now).usedPercent).toBe(90); + publishDecisionQuotaRoster("codex", Array.from({ length: 1025 }, (_, i) => ({ id: String(i), generation: 1, usable: true }))); + expect(readLoadedDecisionQuotaPool("codex")).toBeUndefined(); +}); diff --git a/tests/routing/jev-quota-signals.test.ts b/tests/routing/jev-quota-signals.test.ts new file mode 100644 index 00000000000..7ba30b2517e --- /dev/null +++ b/tests/routing/jev-quota-signals.test.ts @@ -0,0 +1,243 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { jevQuotaTier, jevQuotaRank, normalizeJevQuotaTiers, normalizeJevQuotaSummary } from "../../src/combos/jev-quota-config"; +import { jevQuotaSignalFromWindows, jevQuotaSignalForTarget, jevQuotaPoolSignal, JEV_QUOTA_SIGNAL_MAX_AGE_MS, JEV_QUOTA_INSTRUCTION } from "../../src/combos/jev-quota"; +import { providerDecisionWindows, publishCodexDecisionQuota } from "../../src/providers/quota-decision-publication"; +import { publishDecisionAccountQuota, publishDecisionQuotaRoster, publishDecisionKeyQuota, invalidateDecisionKeyQuotas, setDecisionAccountUsable, type DecisionQuotaWindow } from "../../src/providers/quota-decision-snapshot"; +import { boundedJevQuotaPayload } from "../../src/combos/jev-quota-route"; +import { buildJevState, buildJevRouteQuestion, JEV_API_URL, resolveJevDecision, type JevCandidate } from "../../src/combos/jev"; +import { resolveJevComboDecision } from "../../src/combos/jev-dispatch"; +import { buildJevModelPrompt, JEV_MODEL_INSTRUCTIONS } from "../../src/combos/jev-model-backend"; +import { normalizePersistedJevDecision } from "../../src/usage/jev-stats"; +import * as auth from "../../src/oauth/store"; +import * as disk from "../../src/codex/quota"; +import * as keys from "../../src/providers/api-key-resolve"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import type { OcxConfig } from "../../src/types"; + +const now = 2_000_000_000_000; +/** Build a weekly decision window with the given percentage. */ +const window = (percent: number, extra: Partial = {}): DecisionQuotaWindow => ({ window: "weekly", percent, observedAt: now, ...extra }); +const candidates: JevCandidate[] = [{ key: "p/a", provider: "p", model: "a", reasoningEfforts: ["low"] }, { key: "p/b", provider: "p", model: "b", reasoningEfforts: ["low"] }]; +const fallback = { targetKey: "p/a", effort: "low" as const }; +let home: TempHome; +let config: OcxConfig; +beforeEach(() => { + home = createTempHome("ocx-jq-"); + invalidateDecisionKeyQuotas(); + publishDecisionQuotaRoster("anthropic", []); publishDecisionQuotaRoster("codex", []); publishDecisionQuotaRoster("codex-main", []); + config = { port: 0, defaultProvider: "p", providers: { p: { adapter: "openai-chat", baseUrl: "https://p.example.test/v1", apiKey: "fixture-key" }, jev: { adapter: "jev-decision", baseUrl: JEV_API_URL, apiKey: "fixture-judge" } } }; +}); +afterEach(() => { invalidateDecisionKeyQuotas(); home.remove(); }); + +describe("JEV quota policy", () => { + test("inclusive raw boundaries and optional moderate", () => { + const four = normalizeJevQuotaTiers({ moderate: 40 })!; + expect(four).toEqual({ moderate: 40, limited: 70, nearlyExhausted: 90 }); + expect([39.99, 40, 69.99, 70, 89.99, 90].map(n => jevQuotaTier(n, four))).toEqual(["healthy", "moderate", "moderate", "limited", "limited", "nearly_exhausted"]); + expect(jevQuotaTier(40)).toBe("healthy"); + expect(jevQuotaTier(79.99, normalizeJevQuotaTiers({ limited: 80 })!)).toBe("healthy"); + expect(jevQuotaTier(80, normalizeJevQuotaTiers({ limited: 80 })!)).toBe("limited"); + for (const invalid of [null, [], { limited: NaN }, { nearlyExhausted: Infinity }, { moderate: -1 }, { limited: 101 }, { limited: 90 }, { moderate: 70 }, { moderate: 60, limited: 50 }, { private: 40 }, { limited: undefined }]) expect(normalizeJevQuotaTiers(invalid)).toBeUndefined(); + expect(jevQuotaRank("unknown")).toBe(jevQuotaRank("healthy")); + }); + test("worst relevant window preserves raw percentage and producer scope", () => { + const windows = providerDecisionWindows({ updatedAt: now, fiveHourPercent: 40, weeklyPercent: 69.99, monthlyPercent: 50, creditsUsd: { used: 100, limit: 100, remaining: 0, percent: 100 }, customWindows: [ + { label: "Opus", percent: 100 }, { label: "Spark", percent: 100 }, { label: "API usage", percent: 100 }, { label: "Opus", scope: "model", percent: 90 }, { label: "Sonnet", scope: "model", percent: 80 }, + ] }); + expect(jevQuotaSignalFromWindows(windows, "claude-opus-4", now)).toMatchObject({ tier: "nearly_exhausted", usedPercent: 90, window: "opus" }); + expect(jevQuotaSignalFromWindows(windows, "claude-sonnet-4", now)).toMatchObject({ tier: "limited", usedPercent: 80 }); + expect(jevQuotaSignalFromWindows(windows, "other", now)).toMatchObject({ tier: "healthy", usedPercent: 69.99, window: "weekly" }); + }); + test("freshness, invalid numbers and reset expiry are excluded", () => { + for (const row of [window(90, { observedAt: -1 }), window(90, { observedAt: Infinity }), window(90, { observedAt: now + 1 }), window(90, { observedAt: now - JEV_QUOTA_SIGNAL_MAX_AGE_MS - 1 }), window(NaN), window(-1), window(101), window(90, { resetAt: now }), window(90, { resetAt: NaN })]) expect(jevQuotaSignalFromWindows([row], "a", now).tier).toBe("unknown"); + expect(jevQuotaSignalFromWindows([window(90, { observedAt: now - JEV_QUOTA_SIGNAL_MAX_AGE_MS, resetAt: now + 1001 })], "a", now)).toMatchObject({ tier: "nearly_exhausted", resetsInSeconds: 2 }); + expect(jevQuotaSignalFromWindows([window(90, { resetAt: now }), window(30)], "a", now).usedPercent).toBe(30); + }); + test("pool uses best usable tier, unknown healthy rank and stable ties", () => { + const high = { tier: "nearly_exhausted" as const, usedPercent: 95 }; + const healthy = { tier: "healthy" as const, usedPercent: 30 }; + expect(jevQuotaPoolSignal([high, { tier: "unknown" }]).tier).toBe("unknown"); + expect(jevQuotaPoolSignal([high, high]).tier).toBe("nearly_exhausted"); + expect(jevQuotaPoolSignal([healthy, { tier: "unknown" }])).toBe(healthy); + expect(jevQuotaPoolSignal([]).tier).toBe("unknown"); + }); +}); + +describe("loaded quota evidence", () => { + test("cold path does not call credential, auth, disk or network readers", () => { + const spies = [spyOn(auth, "getAccountSet"), spyOn(auth, "getAccountCredential"), spyOn(auth, "loadAuthStore"), spyOn(disk, "getAccountQuota"), spyOn(keys, "resolveProviderApiKey"), spyOn(globalThis, "fetch").mockImplementation((() => { throw new Error("forbidden reader"); }) as unknown as typeof fetch)]; + try { + for (const spy of spies.slice(0, -1)) spy.mockImplementation((() => { throw new Error("forbidden reader"); }) as never); + config.providers.anthropic = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" }; + config.anthropicAccountPool = { enabled: true }; + config.providers.openai = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool" }; + for (const provider of ["p", "anthropic", "openai"]) expect(jevQuotaSignalForTarget(config, provider, "a", now).tier).toBe("unknown"); + for (const spy of spies) expect(spy).not.toHaveBeenCalled(); + } finally { spies.forEach(spy => spy.mockRestore()); } + }); + test("sole-key publication invalidates on config, key epoch and multi-key changes", () => { + const provider = config.providers.p!; + publishDecisionKeyQuota("p", provider, [window(90)]); + expect(jevQuotaSignalForTarget(config, "p", "a", now).tier).toBe("nearly_exhausted"); + provider.apiKey = "rotated-key"; + expect(jevQuotaSignalForTarget(config, "p", "a", now).tier).toBe("unknown"); + publishDecisionKeyQuota("p", provider, [window(90)]); + keys.invalidateResolvedProviderKeyCache(); + expect(jevQuotaSignalForTarget(config, "p", "a", now).tier).toBe("unknown"); + provider.apiKeyPool = [{ id: "a", key: "a" }, { id: "b", key: "b" }]; + publishDecisionKeyQuota("p", provider, [window(90)]); + expect(jevQuotaSignalForTarget(config, "p", "a", now).tier).toBe("unknown"); + }); + test("Anthropic roster usability, model allowlist and credential-generation fence", () => { + config.providers.anthropic = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" }; + config.anthropicAccountPool = { enabled: true }; + publishDecisionQuotaRoster("anthropic", [{ id: "a", generation: "g1", usable: true }, { id: "b", generation: "g2", usable: false }]); + publishDecisionAccountQuota("anthropic", "a", "g1", [window(90)]); + publishDecisionAccountQuota("anthropic", "b", "g2", [window(10)]); + expect(jevQuotaSignalForTarget(config, "anthropic", "claude-sonnet-4", now).tier).toBe("nearly_exhausted"); + publishDecisionQuotaRoster("anthropic", [{ id: "a", generation: "g1", usable: true }, { id: "b", generation: "g2", usable: true }]); + expect(jevQuotaSignalForTarget(config, "anthropic", "claude-sonnet-4", now).tier).toBe("healthy"); + config.anthropicAccountPool.routes = [{ name: "sonnet", match: "claude-sonnet-4", accounts: ["a"] }]; + expect(jevQuotaSignalForTarget(config, "anthropic", "claude-sonnet-4", now).tier).toBe("nearly_exhausted"); + publishDecisionQuotaRoster("anthropic", [{ id: "a", generation: "g3", usable: true }]); + publishDecisionAccountQuota("anthropic", "a", "g1", [window(90)]); + expect(jevQuotaSignalForTarget(config, "anthropic", "claude-sonnet-4", now).tier).toBe("unknown"); + config.anthropicAccountPool.enabled = false; + expect(jevQuotaSignalForTarget(config, "anthropic", "claude-sonnet-4", now).tier).toBe("unknown"); + }); + test("Codex raw windows, pause, reauth and reserve unknown", () => { + config.providers.openai = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool" }; + config.codexAccounts = [{ id: "a", isMain: false, email: "fixture@example.test" }, { id: "b", isMain: false, email: "fixture@example.test" }]; + config.pausedCodexAccountIds = ["__main__", "b"]; + publishDecisionQuotaRoster("codex", [{ id: "a", generation: 1, usable: true }, { id: "b", generation: 1, usable: true }]); + publishCodexDecisionQuota("a", 1, { weeklyPercent: 70, shortPercent: 90, shortResetAt: (now + 1000) / 1000, credits: { unlimited: true, observedAt: now } }, now); + publishCodexDecisionQuota("b", 1, { weeklyPercent: 10 }, now); + expect(jevQuotaSignalForTarget(config, "openai", "gpt-6-astra", now).tier).toBe("nearly_exhausted"); + expect(jevQuotaSignalForTarget(config, "openai", "gpt-reserve", now).tier).toBe("unknown"); + config.pausedCodexAccountIds = ["__main__"]; + expect(jevQuotaSignalForTarget(config, "openai", "gpt-6-astra", now).tier).toBe("healthy"); + setDecisionAccountUsable("codex", "b", false, 1); + publishDecisionQuotaRoster("codex", [{ id: "a", generation: 1, usable: true }, { id: "b", generation: 1, usable: true }]); + expect(jevQuotaSignalForTarget(config, "openai", "gpt-6-astra", now).tier).toBe("nearly_exhausted"); + publishCodexDecisionQuota("a", 1, { credits: { unlimited: true, observedAt: now } }, now + 1); + expect(jevQuotaSignalForTarget(config, "openai", "gpt-6-astra", now + 1).tier).toBe("nearly_exhausted"); + expect(jevQuotaSignalForTarget(config, "openai", "gpt-6-astra", now + JEV_QUOTA_SIGNAL_MAX_AGE_MS + 1).tier).toBe("unknown"); + }); +}); + +describe("quota route payload and bounded telemetry", () => { + test("off preserves question bytes and service destination denial precedes evidence", async () => { + const base = JSON.stringify(buildJevRouteQuestion(candidates)); + expect(JSON.stringify(buildJevRouteQuestion(candidates))).toBe(base); + let posted = ""; + const options = { body: { input: "Fixture task" }, config, candidates, fallback, now: () => now, post: async (_name: string, _provider: unknown, _url: string, init: RequestInit) => { posted = String(init.body); return Response.json({ answers: { route: { choice: "p/a:low" } } }); } }; + const off = await resolveJevDecision(options); + const offBytes = posted; + const falseDecision = await resolveJevDecision({ ...options, decisionQuotaSignals: false }); + expect(posted).toBe(offBytes); expect(off.quota).toBeUndefined(); expect(falseDecision.quota).toBeUndefined(); + publishDecisionKeyQuota("p", config.providers.p!, [window(90)]); + const on = await resolveJevDecision({ ...options, decisionQuotaSignals: true }); + expect(JSON.parse(posted).questions.route.criteria["p/a:low"].quota).toMatchObject({ tier: "nearly_exhausted", used_percent: 90 }); + expect(on.quota).toMatchObject({ nearly_exhausted: 2, selected: "nearly_exhausted" }); + posted = ""; + const denied = await resolveJevDecision({ ...options, decisionQuotaSignals: true, isDestinationAllowed: () => false }); + expect(posted).toBe(""); expect(denied.quota).toBeUndefined(); + }); + test("model backend adds quota only with opt-in and keeps configured target order", async () => { + publishDecisionKeyQuota("p", config.providers.p!, [window(90)]); + const seen: { instructions: string; input: string }[] = []; + const base = { body: { input: "Fixture task" }, config, candidates, fallback, now: () => now, decisionModel: "p/router", invokeModel: async (request: { instructions: string; input: string }) => { seen.push(request); return { text: '{"choice":"p/b:low"}' }; } }; + const off = await resolveJevComboDecision(base); + const on = await resolveJevComboDecision({ ...base, decisionQuotaSignals: true }); + expect(seen[0]!.instructions).toBe(JEV_MODEL_INSTRUCTIONS); + expect(seen[0]!.input).not.toContain("Quota"); expect(off.quota).toBeUndefined(); + expect(seen[1]!.input).toContain("Quota nearly_exhausted"); + expect(Object.keys(JSON.parse(seen[1]!.input).options)).toEqual(["p/a:low", "p/b:low"]); + expect(on.quota?.selected).toBe("nearly_exhausted"); + }); + test("overflow retries without quota before ordinary invalid gate", () => { + const quotaRows = candidates.map(row => ({ ...row, quota: { tier: "limited" as const } })); + const attempts: boolean[] = []; + const result = boundedJevQuotaPayload(quotaRows, rows => { attempts.push(rows.some(row => row.quota)); return "x".repeat(rows.some(row => row.quota) ? 65_537 : 10); }, 65_536); + expect(attempts).toEqual([true, false]); expect(result.body).toBe("x".repeat(10)); expect(result.candidates.some(row => row.quota)).toBe(false); + }); + test("telemetry accepts only bounded enums/counts and drops account/evidence fields", () => { + const quota = { unknown: 1, healthy: 1, moderate: 0, limited: 0, nearly_exhausted: 0, selected: "unknown" as const }; + expect(normalizeJevQuotaSummary({ ...quota, accounts: ["private"], windows: ["private"] })).toEqual(quota); + for (const invalid of [{ ...quota, healthy: 64 }, { ...quota, limited: -1 }, { ...quota, limited: NaN }]) expect(normalizeJevQuotaSummary(invalid)).toBeUndefined(); + const row = normalizePersistedJevDecision({ version: 1, comboId: "auto", selected: { provider: "p", model: "a", effort: "low" }, gate: "apply", latencyMs: 1, quota: { ...quota, accountId: "private" } }); + expect(row?.quota).toEqual(quota); expect(JSON.stringify(row)).not.toContain("private"); + }); +}); + +test("System One quota descriptions stay scalar and denied destinations never read quota", async () => { + config.providers.local = { adapter: "jev-decision", baseUrl: "http://127.0.0.1:11434/v1/systemone", allowPrivateNetwork: true, defaultModel: "fixture-judge" }; + publishDecisionQuotaRoster("anthropic", [{ id: "private-account-fixture", generation: "private-generation-fixture", usable: true }]); + config.providers.anthropic = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" }; + config.anthropicAccountPool = { enabled: true }; + publishDecisionAccountQuota("anthropic", "private-account-fixture", "private-generation-fixture", [window(90)]); + const rows = candidates.map((row, i) => ({ ...row, key: `anthropic/${i}`, provider: "anthropic", model: `claude-sonnet-${i}` })); + let posted = ""; + const options = { body: { input: "Fixture task" }, config, candidates: rows, fallback: { targetKey: rows[0]!.key, effort: "low" as const }, decisionProvider: "local", decisionQuotaSignals: true, now: () => now, + post: async (_name: string, _provider: unknown, _url: string, init: RequestInit) => { posted = String(init.body); return Response.json({ answers: { route: { choice: `${rows[0]!.key}:low` } } }); } }; + expect((await resolveJevDecision(options)).gate).toBe("apply"); + const criteria = JSON.parse(posted).questions.route.criteria; + expect(typeof criteria[`${rows[0]!.key}:low`]).toBe("string"); + expect(posted).toContain("Quota nearly_exhausted"); + expect(posted).not.toContain("private-account-fixture"); expect(posted).not.toContain("private-generation-fixture"); + let reads = 0; + Object.defineProperty(config, "anthropicAccountPool", { + /** Count and reject any quota-pool read made before the destination is authorized. */ + get() { reads++; throw new Error("quota read before destination authorization"); }, + configurable: true, + }); + posted = ""; + const denied = await resolveJevDecision({ ...options, isDestinationAllowed: () => false }); + expect(denied.gate).toBe("invalid"); expect(reads).toBe(0); expect(posted).toBe(""); +}); + +test("real service and model serializers remove quota at byte cap and retain ordinary refusal", async () => { + const rows = Array.from({ length: 42 }, (_, i) => ({ key: `p/${i}`, provider: "p", model: "界".repeat(180) + i, reasoningEfforts: ["low" as const] })); + const input = { input: "Fixture task" }; + const fallback = { targetKey: rows[0]!.key, effort: "low" as const }; + const encoder = new TextEncoder(); + // Find a valid ordinary payload just below the cap; quota must be the only overflow cause. + let serviceRows = rows; + for (let count = 2; count <= 42; count++) { + const candidates = rows.slice(0, count); + const bytes = encoder.encode(JSON.stringify({ model: "jev-latest", state: buildJevState(input, candidates), questions: buildJevRouteQuestion(candidates) })).byteLength; + if (bytes < 65_536 && bytes > 62_000) { serviceRows = candidates; break; } + } + // Tune profile size to approach 64 KiB without touching production limits. + let tuned = serviceRows; + for (let size = 0; size <= 512; size++) { + const next = serviceRows.map(row => ({ ...row, modelProfile: "界".repeat(size) })); + const state = buildJevState(input, next); + const ordinary = encoder.encode(JSON.stringify({ model: "jev-latest", state, questions: buildJevRouteQuestion(next) })).byteLength; + const enriched = encoder.encode(JSON.stringify({ model: "jev-latest", state, questions: buildJevRouteQuestion(next.map(row => ({ ...row, quota: { tier: "nearly_exhausted" as const, usedPercent: 90, window: "weekly" as const } }))) })).byteLength; + if (ordinary <= 65_536 && enriched > 65_536) { tuned = next; break; } + } + publishDecisionKeyQuota("p", config.providers.p!, [window(90)]); + let sent = ""; + const result = await resolveJevDecision({ body: input, config, candidates: tuned, fallback, decisionQuotaSignals: true, now: () => now, + post: async (_name: string, _provider: unknown, _url: string, init: RequestInit) => { sent = String(init.body); return Response.json({ answers: { route: { choice: `${tuned[0]!.key}:low` } } }); } }); + expect(result.gate).toBe("apply"); expect(encoder.encode(sent).byteLength).toBeLessThanOrEqual(65_536); + expect(sent).not.toContain('"quota"'); expect(result.quota).toBeUndefined(); + const excessive = rows.map(row => ({ ...row, modelProfile: "界".repeat(512) })); + sent = ""; + expect((await resolveJevDecision({ body: input, config, candidates: excessive, fallback, decisionQuotaSignals: true, now: () => now, post: async () => { sent = "unexpected"; return Response.json({}); } })).gate).toBe("invalid"); + expect(sent).toBe(""); + let modelRequest = ""; + let modelRows = rows; + for (let size = 100; size <= 510; size++) { + const next = rows.map((row, i) => ({ ...row, model: "界".repeat(size) + i })); + const state = buildJevState(input, next); + const ordinary = encoder.encode(JEV_MODEL_INSTRUCTIONS + buildJevModelPrompt(state, next)).byteLength; + const enriched = encoder.encode(JEV_MODEL_INSTRUCTIONS + " " + JEV_QUOTA_INSTRUCTION + buildJevModelPrompt(state, next.map(row => ({ ...row, quota: { tier: "nearly_exhausted" as const, usedPercent: 90, window: "weekly" as const } })))).byteLength; + if (ordinary <= 65_536 && enriched > 65_536) { modelRows = next; break; } + } + const model = await resolveJevComboDecision({ body: input, config, candidates: modelRows, fallback, decisionModel: "p/judge", decisionQuotaSignals: true, now: () => now, + invokeModel: async request => { modelRequest = request.instructions + request.input; return { text: `{"choice":"${modelRows[0]!.key}:low"}` }; } }); + expect(model.gate).toBe("apply"); expect(encoder.encode(modelRequest).byteLength).toBeLessThanOrEqual(65_536); + expect(modelRequest).not.toContain("Quota nearly_exhausted"); expect(model.quota).toBeUndefined(); +}); diff --git a/tests/server/server-jev-model-quota-budget.test.ts b/tests/server/server-jev-model-quota-budget.test.ts new file mode 100644 index 00000000000..fca25063d81 --- /dev/null +++ b/tests/server/server-jev-model-quota-budget.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { buildJevState, JEV_MAX_REQUEST_BYTES, type JevCandidate } from "../../src/combos/jev"; +import { buildJevModelPrompt, JEV_MODEL_INSTRUCTIONS, type JevModelInvoke } from "../../src/combos/jev-model-backend"; +import { serializeJevModelRequest } from "../../src/combos/jev-model-request"; +import { resolveJevComboDecision } from "../../src/combos/jev-dispatch"; +import { jevQuotaCandidates } from "../../src/combos/jev-quota-route"; +import { JEV_QUOTA_INSTRUCTION } from "../../src/combos/jev-quota"; +import { createJevModelInvoker } from "../../src/server/responses/jev-model-invoke"; +import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import { invalidateDecisionKeyQuotas, publishDecisionKeyQuota } from "../../src/providers/quota-decision-snapshot"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import type { OcxConfig } from "../../src/types"; + +const now = 2_000_000_000_000; +const body = { input: 'Fixture "task" \\ with UTF-8 界' }; +/** UTF-8 byte length of a string. */ +const bytes = (text: string) => new TextEncoder().encode(text).byteLength; +let home: TempHome; +let clock: ReturnType; +beforeEach(() => { + home = createTempHome("ocx-jq-budget-"); + clock = spyOn(Date, "now").mockReturnValue(now); + invalidateDecisionKeyQuotas(); + resetLifecycleDrainStateForTests(); +}); +afterEach(() => { invalidateDecisionKeyQuotas(); clock.mockRestore(); home.remove(); }); +/** Minimal single-provider config for decision-model budget tests. */ +function config(reasoning = false): OcxConfig { + return { port: 0, defaultProvider: "p", providers: { p: { + adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", apiKey: "fixture-key", models: ["judge"], + ...(reasoning ? { modelReasoningEfforts: { judge: ["low", "high"] } } : {}), + } } }; +} +/** Build the largest candidate set whose decision request still fits the byte limit. */ +function nearLimitCandidates(): JevCandidate[] { + for (let size = 100; size <= 500; size++) { + const candidates = Array.from({ length: 42 }, (_, i) => ({ key: `p/${i}`, provider: "p", model: '界'.repeat(size) + i, reasoningEfforts: ["low"] as const })); + const input = buildJevModelPrompt(buildJevState(body, candidates), candidates); + const sizeBytes = bytes(serializeJevModelRequest({ model: "p/judge", instructions: JEV_MODEL_INSTRUCTIONS, input })); + const rows = candidates.map(row => ({ ...row, quota: { tier: "nearly_exhausted" as const, usedPercent: 90, window: "weekly" as const } })); + const quotaRequest = { model: "p/judge", instructions: JEV_MODEL_INSTRUCTIONS + " " + JEV_QUOTA_INSTRUCTION, input: buildJevModelPrompt(buildJevState(body, candidates), rows) }; + if (sizeBytes < JEV_MAX_REQUEST_BYTES && bytes(quotaRequest.instructions + quotaRequest.input) <= JEV_MAX_REQUEST_BYTES + && bytes(serializeJevModelRequest(quotaRequest)) > JEV_MAX_REQUEST_BYTES) return candidates; + } + throw new Error("fixture must straddle the serialized quota boundary"); +} +/** Common decision options for the budget tests. */ +function options(cfg: OcxConfig, candidates: JevCandidate[], invokeModel: JevModelInvoke) { + return { body, config: cfg, candidates, fallback: { targetKey: "p/0", effort: "low" as const }, decisionModel: "p/judge", invokeModel, now: () => now }; +} + +for (const reasoning of [false, true]) { + test(`quota-only overflow strips before the actual detached invoker (reasoning ${reasoning})`, async () => { + const cfg = config(reasoning); + const candidates = nearLimitCandidates(); + const seen: string[] = []; + const settled: unknown[] = []; + const invoker = createJevModelInvoker({ + req: new Request("http://localhost/v1/responses", { headers: { authorization: "Bearer fixture-parent", "chatgpt-account-id": "fixture-parent" } }), + config: cfg, options: {}, + handleResponses: async (request, _config, log, childOptions) => { + seen.push(await request.text()); + expect(request.headers.get("authorization")).toBeNull(); + expect(request.headers.get("chatgpt-account-id")).toBeNull(); + expect(childOptions).toMatchObject({ callerDirectAuth: null, openAiSidecarAuth: null, nativeCallerAuth: null, internalDecisionCall: true }); + expect(childOptions!.turnAdmissionLease).toBeDefined(); + expect(childOptions!.sendBudget).toBeDefined(); + const settle = spyOn({ settle: (_usage?: unknown) => {} }, "settle").mockImplementation(usage => { settled.push(usage); }); + // The invoker only consumes settle from this synthetic detached tracker. + log.spendTracker = { settle } as unknown as NonNullable; + return Response.json({ status: "completed", output: [{ type: "message", content: [{ type: "output_text", text: '{"choice":"p/0:low"}' }] }], usage: { input_tokens: 7, output_tokens: 2 } }); + }, + }); + const base = options(cfg, candidates, invoker); + publishDecisionKeyQuota("p", cfg.providers.p!, [{ window: "weekly", percent: 90, observedAt: now }]); + const quotaRows = jevQuotaCandidates({ ...base, decisionQuotaSignals: true }); + const quotaRequest = { model: "p/judge", instructions: JEV_MODEL_INSTRUCTIONS + " " + JEV_QUOTA_INSTRUCTION, input: buildJevModelPrompt(buildJevState(body, candidates), quotaRows) }; + expect(bytes(quotaRequest.instructions + quotaRequest.input)).toBeLessThanOrEqual(JEV_MAX_REQUEST_BYTES); + expect(bytes(serializeJevModelRequest(quotaRequest, reasoning ? "low" : undefined))).toBeGreaterThan(JEV_MAX_REQUEST_BYTES); + expect((await resolveJevComboDecision(base)).gate).toBe("apply"); + // The ordinary route owner has now observed its key; publish against that live policy. + publishDecisionKeyQuota("p", cfg.providers.p!, [{ window: "weekly", percent: 90, observedAt: now }]); + const on = await resolveJevComboDecision({ ...base, decisionQuotaSignals: true }); + expect(on.gate).toBe("apply"); + expect(on.quota).toBeUndefined(); + expect(seen).toHaveLength(2); + expect(seen[1]).toBe(seen[0]); + const input = buildJevModelPrompt(buildJevState(body, candidates), candidates); + expect(seen[0]).toBe(JSON.stringify({ model: "p/judge", stream: true, store: false, instructions: JEV_MODEL_INSTRUCTIONS, + input: [{ role: "user", content: [{ type: "input_text", text: input }] }], tools: [], max_output_tokens: 1024, + ...(reasoning ? { reasoning: { effort: "low" } } : {}), + })); + expect(bytes(seen[1]!)).toBeLessThanOrEqual(JEV_MAX_REQUEST_BYTES); + expect(settled).toEqual([{ inputTokens: 7, outputTokens: 2 }, { inputTokens: 7, outputTokens: 2 }]); + expect(on.usage).toEqual({ input_tokens: 7, output_tokens: 2 }); + }); +} + +test("reasoning envelope itself participates in pre-invocation quota stripping", async () => { + const cfg = config(true); + const seen: string[] = []; + const invoker = createJevModelInvoker({ req: new Request("http://localhost/v1/responses"), config: cfg, options: {}, handleResponses: async request => { + seen.push(await request.text()); + return Response.json({ status: "completed", output: [{ type: "message", content: [{ type: "output_text", text: '{"choice":"p/0:low"}' }] }] }); + } }); + publishDecisionKeyQuota("p", cfg.providers.p!, [{ window: "weekly", percent: 90, observedAt: now }]); + const candidates = nearLimitCandidates(); + // Shrink one description so the quota request fits only when the reasoning field is omitted. + let selected: JevCandidate[] | undefined; + for (let cut = 1; cut < candidates[0]!.model.length; cut++) { + const rows = candidates.map((row, i) => i ? row : { ...row, model: row.model.slice(cut) }); + const quota = jevQuotaCandidates({ ...options(cfg, rows, invoker), decisionQuotaSignals: true }); + const request = { model: "p/judge", instructions: JEV_MODEL_INSTRUCTIONS + " " + JEV_QUOTA_INSTRUCTION, input: buildJevModelPrompt(buildJevState(body, rows), quota) }; + if (bytes(serializeJevModelRequest(request)) <= JEV_MAX_REQUEST_BYTES && bytes(serializeJevModelRequest(request, "low")) > JEV_MAX_REQUEST_BYTES) { selected = rows; break; } + } + expect(selected).toBeDefined(); + const result = await resolveJevComboDecision({ ...options(cfg, selected!, invoker), decisionQuotaSignals: true }); + expect(result.gate).toBe("apply"); + expect(result.quota).toBeUndefined(); + expect(seen).toHaveLength(1); + expect(seen[0]).not.toContain("Quota nearly_exhausted"); + expect(JSON.parse(seen[0]!).reasoning).toEqual({ effort: "low" }); +}); + +test("arbitrary malformed output is never retried without quota", async () => { + const cfg = config(); + let calls = 0; + const invoker = createJevModelInvoker({ req: new Request("http://localhost/v1/responses"), config: cfg, options: {}, handleResponses: async () => { + calls++; + return Response.json({ status: "incomplete", output: [] }); + } }); + const rows = [{ key: "p/0", provider: "p", model: "first", reasoningEfforts: ["low"] as const }]; + publishDecisionKeyQuota("p", cfg.providers.p!, [{ window: "weekly", percent: 90, observedAt: now }]); + const result = await resolveJevComboDecision({ ...options(cfg, rows, invoker), decisionQuotaSignals: true }); + expect(result.gate).toBe("malformed"); + expect(calls).toBe(1); +});