diff --git a/src/codex/catalog/auto-review.ts b/src/codex/catalog/auto-review.ts new file mode 100644 index 0000000000..3487272b84 --- /dev/null +++ b/src/codex/catalog/auto-review.ts @@ -0,0 +1,507 @@ +import { redactSecretString } from "../../lib/redact"; +import type { OcxConfig } from "../../types"; +import { encodeRoutedModelId } from "../../providers/slug-codec"; +import { canonicalAutoReviewModelKey, isValidAutoReviewModel as isValidAutoReviewTarget } from "../../config/provider-validation"; +import { readConfiguredAutoReviewModel } from "./parsing"; +import type { RawEntry } from "./parsing"; +import { configuredCatalogEntry } from "./subagent-roster"; + +const AUTO_REVIEW_ROOT_MARKER = "opencodex_auto_review_root"; + +interface RootAutoReviewStamp { + slug: string; + original: string | null; + applied: string; +} + +function rootAutoReviewStamp(entry: RawEntry): RootAutoReviewStamp | undefined { + const value = entry[AUTO_REVIEW_ROOT_MARKER]; + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const stamp = value as Record; + if (stamp.slug !== entry.slug || typeof stamp.slug !== "string" + || typeof stamp.applied !== "string" + || (stamp.original !== null && typeof stamp.original !== "string")) return undefined; + return stamp as unknown as RootAutoReviewStamp; +} + + +/** True when the value is a valid Codex catalog auto-review selector. */ +export function isValidAutoReviewModel(value: unknown): value is string { + return isValidAutoReviewTarget(value); +} + +export type AutoReviewModelOverrideResult = "absent" | "applied" | "invalid" | "unresolved"; + +/** True when a catalog row was synthesized by opencodex instead of coming from upstream. */ +function isRoutedCatalogEntry(entry: RawEntry): boolean { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + return slug.includes("/") + || (typeof entry.description === "string" && entry.description.startsWith("Routed via opencodex → ")); +} + +/** Restore an owned native value, retaining provenance to avoid legacy reclassification. */ +function clearAutoReviewOverrideValue(entry: RawEntry): void { + const stamp = rootAutoReviewStamp(entry); + if (stamp) { + if (entry.auto_review_model_override === stamp.applied) entry.auto_review_model_override = stamp.original; + } else { + entry.auto_review_model_override = null; + delete entry[AUTO_REVIEW_ROOT_MARKER]; + } +} + +/** + * Legacy whole-catalog root stamp: releases before AUTO_REVIEW_ROOT_MARKER wrote root stamps that + * are textually identical to an upstream value, so the only way to recognize one is the uniform + * signature the no-provider path relies on — a single value that a routed row also carries. + * Returns the stamped values when the observed rows match that shape. + */ +function legacyRootStampValues(observedModels: readonly RawEntry[]): ReadonlySet | undefined { + if (observedModels.some(entry => entry?.[AUTO_REVIEW_ROOT_MARKER] !== undefined)) return undefined; + const configuredValues = new Set(observedModels.flatMap(entry => { + const value = entry?.auto_review_model_override; + return typeof value === "string" && value.trim() ? [value] : []; + })); + const globalStamp = configuredValues.size === 1 + && observedModels.some(entry => { + const value = entry.auto_review_model_override; + return isRoutedCatalogEntry(entry) + && typeof value === "string" + && value.trim().length > 0 + && configuredValues.has(value); + }) + && observedModels.every(entry => { + const value = entry?.auto_review_model_override; + return value === null + || value === undefined + || (typeof value === "string" && configuredValues.has(value)); + }); + return globalStamp ? configuredValues : undefined; +} + +/** + * Sweep legacy root stamps off the rows a root removal owns, before provider plans land. + * + * Root removal reaches marker-tagged native rows on its own, but a catalog written before the + * marker only carries the legacy signature — and provider stamping rewrites that signature before + * the root pass could read it, so the sweep has to run first. + */ +function clearLegacyRootStamps(models: readonly RawEntry[], sourceModels: readonly RawEntry[] = []): void { + const legacyStamp = legacyRootStampValues([...models, ...sourceModels]); + if (legacyStamp === undefined) return; + for (const entry of models) { + if (!entry || typeof entry !== "object") continue; + const current = entry.auto_review_model_override; + if (entry[AUTO_REVIEW_ROOT_MARKER] === undefined + && typeof current === "string" && legacyStamp.has(current)) clearAutoReviewOverrideValue(entry); + } +} + +/** + * Clear the root selector from every row this path owns: routed rows, rows stamped by a release + * that writes the provenance marker, and the legacy whole-catalog stamp that predates it. + */ +function clearAutoReviewModelOverride( + models: readonly RawEntry[], + sourceModels: readonly RawEntry[] = [], +): void { + const legacyStamp = legacyRootStampValues([...models, ...sourceModels]); + for (const entry of models) { + if (!entry || typeof entry !== "object") continue; + const current = entry.auto_review_model_override; + if (isRoutedCatalogEntry(entry) + || (entry[AUTO_REVIEW_ROOT_MARKER] === true || rootAutoReviewStamp(entry) !== undefined) + || (legacyStamp !== undefined && typeof current === "string" && legacyStamp.has(current))) { + clearAutoReviewOverrideValue(entry); + } + } +} + +/** Warn once about a malformed or unresolvable root auto-review selector. */ +function warnAutoReviewModelDiagnostic( + reason: "invalid" | "unresolved", + configured: string, +): void { + const safeConfigured = JSON.stringify(redactSecretString(configured)); + const detail = reason === "unresolved" + ? "the selector was not found in the final catalog" + : "the selector format is invalid"; + console.warn( + `[opencodex] auto_review_model ${detail} (${safeConfigured}); preserving normal upstream auto-review behavior.`, + ); +} + +/** Warn once about a malformed or unresolvable provider-scoped auto-review selector. */ +function warnProviderAutoReviewModelDiagnostic( + reason: "invalid" | "unresolved", + provider: string, + configured: string, +): void { + const safeProvider = JSON.stringify(redactSecretString(provider)); + const safeConfigured = JSON.stringify(redactSecretString(configured)); + const detail = reason === "unresolved" + ? "the selector was not found in the final catalog" + : "the selector format is invalid"; + console.warn( + `[opencodex] auto_review_model for provider ${safeProvider} ${detail} (${safeConfigured}); using the next valid provider/root selector or upstream behavior.`, + ); +} + +/** + * Note once when a bare selector resolves to a row outside the provider it was configured on. + * + * That is how a native model is named as a reviewer, so it stays usable, but a mistyped target must + * not be silent: the operator sees which catalog row actually supplies the reviewer. + */ +function warnProviderAutoReviewForeignTarget(provider: string, configured: string, target: string): void { + const safeProvider = JSON.stringify(redactSecretString(provider)); + const safeConfigured = JSON.stringify(redactSecretString(configured)); + const safeTarget = JSON.stringify(redactSecretString(target)); + console.warn( + `[opencodex] auto_review_model for provider ${safeProvider} (${safeConfigured}) resolved to ${safeTarget}, which is not a row of that provider; that catalog row supplies the reviewer.`, + ); +} + +/** Preserve native upstream overrides and the root-derived provenance marker from source rows. */ +function preserveNativeAutoReviewModelOverrides( + models: readonly RawEntry[], + sourceModels: readonly RawEntry[], +): void { + const existing = new Map(); + for (const entry of sourceModels) { + const slug = typeof entry.slug === "string" ? entry.slug : undefined; + const value = entry.auto_review_model_override; + if (!slug || isRoutedCatalogEntry(entry)) continue; + if (typeof value === "string" || value === null) { + existing.set(slug, { value, root: rootAutoReviewStamp(entry) ?? (entry[AUTO_REVIEW_ROOT_MARKER] === true ? true : undefined) }); + } + } + for (const entry of models) { + const slug = typeof entry.slug === "string" ? entry.slug : undefined; + if (!slug || isRoutedCatalogEntry(entry) || !existing.has(slug)) continue; + const saved = existing.get(slug)!; + entry.auto_review_model_override = saved.value; + if (saved.root) entry[AUTO_REVIEW_ROOT_MARKER] = structuredClone(saved.root); + else delete entry[AUTO_REVIEW_ROOT_MARKER]; + } +} + +/** Stamp a root-derived override and mark native rows so later root removal is durable. */ +function stampRootAutoReviewOverride(entry: RawEntry, target: string): void { + if (!isRoutedCatalogEntry(entry)) { + const previous = rootAutoReviewStamp(entry); + const current = entry.auto_review_model_override; + entry[AUTO_REVIEW_ROOT_MARKER] = { + slug: typeof entry.slug === "string" ? entry.slug : "", + original: previous && current === previous.applied + ? previous.original : typeof current === "string" ? current : null, + applied: target, + } satisfies RootAutoReviewStamp; + } else { + delete entry[AUTO_REVIEW_ROOT_MARKER]; + } + entry.auto_review_model_override = target; +} + +/** Stamp a provider-derived override; provider stamps never fall under root removal. */ +function stampProviderAutoReviewOverride(entry: RawEntry, target: string): void { + entry.auto_review_model_override = target; + delete entry[AUTO_REVIEW_ROOT_MARKER]; +} + +/** + * Apply the root Codex auto-review selector to every catalog row, or clear it when the value is + * absent, blank, malformed, or does not resolve against the assembled catalog. + */ +export function applyAutoReviewModelOverride( + models: RawEntry[] | undefined, + autoReviewModel: string | null | undefined, + sourceModels: readonly RawEntry[] = [], +): AutoReviewModelOverrideResult { + if (!models || !Array.isArray(models)) return "absent"; + if (autoReviewModel === null || autoReviewModel === undefined) { + clearAutoReviewModelOverride(models, sourceModels); + return "absent"; + } + const trimmed = autoReviewModel.trim(); + if (!trimmed) { + clearAutoReviewModelOverride(models, sourceModels); + return "absent"; + } + if (!isValidAutoReviewModel(trimmed)) { + clearAutoReviewModelOverride(models, sourceModels); + warnAutoReviewModelDiagnostic("invalid", trimmed); + return "invalid"; + } + if (!configuredCatalogEntry(models, trimmed)) { + clearAutoReviewModelOverride(models, sourceModels); + warnAutoReviewModelDiagnostic("unresolved", trimmed); + return "unresolved"; + } + for (const entry of models) { + if (entry && typeof entry === "object") { + stampRootAutoReviewOverride(entry, trimmed); + } + } + return "applied"; +} + +/** Validated provider-scoped target with both the configured spelling and catalog slug. */ +interface ValidProviderReviewTarget { + configured: string; + target: string; +} + +/** One provider's resolved provider-wide and per-model auto-review targets. */ +interface ProviderReviewPlan { + wide?: ValidProviderReviewTarget; + perModel: Map; +} + +/** Public provider namespace of a routed catalog row, when it has one. */ +function catalogEntryProviderName(entry: RawEntry): string | undefined { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + const slash = slug.indexOf("/"); + return slash > 0 && isRoutedCatalogEntry(entry) ? slug.slice(0, slash) : undefined; +} + +/** Encoded model-id segment of a routed catalog row, when it has one. */ +function catalogEntryModelSegment(entry: RawEntry): string | undefined { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + const slash = slug.indexOf("/"); + return slash > 0 ? slug.slice(slash + 1) : undefined; +} + +/** Case-preserving encoded key used to match per-model override maps. */ +function providerModelKey(modelId: string): string { + return canonicalAutoReviewModelKey(modelId); +} + +/** + * True when another routed row of this provider already carries `alias` as its own model id. + * + * The alias API validates against whatever ids discovery has reported so far, so on a cold start an + * alias can be persisted that later turns out to name a different row. A key using it is then not + * an alternate spelling of the aliased model — it is that row's id — and must not be propagated. + */ +function aliasNamesAnotherRoutedRow(models: readonly RawEntry[], provider: string, alias: string): boolean { + const encoded = encodeRoutedModelId(alias); + return models.some(entry => isRoutedCatalogEntry(entry) + && catalogEntryProviderName(entry) === provider + && catalogEntryModelSegment(entry) === encoded); +} + +/** Resolve one configured target against the assembled catalog; bare values name a model of the same provider. */ +function resolveProviderReviewTarget( + models: readonly RawEntry[], + provider: string, + configuredRaw: unknown, +): { kind: "valid"; value: ValidProviderReviewTarget; foreign?: boolean } | { kind: "invalid"; configured: string } | { kind: "unresolved"; configured: string } | { kind: "absent" } { + if (typeof configuredRaw !== "string") return { kind: "absent" }; + const configured = configuredRaw.trim(); + if (!configured) return { kind: "absent" }; + if (!isValidAutoReviewModel(configured)) return { kind: "invalid", configured }; + const prefix = `${provider}/`; + let match: RawEntry | undefined; + const sameProviderCandidate = (rawModelId: string): RawEntry | undefined => models.find(entry => { + if (!isRoutedCatalogEntry(entry) || typeof entry.slug !== "string" || !entry.slug.startsWith(prefix)) return false; + const segment = catalogEntryModelSegment(entry); + return segment !== undefined && segment === encodeRoutedModelId(rawModelId); + }); + // A bare selector names a model of this provider. A full selector that resolves in the + // assembled catalog already names the exact row, including a same-provider encoded slug. + if (!configured.includes("/")) { + match = sameProviderCandidate(configured); + } + match ??= configuredCatalogEntry(models, configured); + if (!match && configured.startsWith(prefix)) { + match = sameProviderCandidate(configured.slice(prefix.length)); + } + if (!match) { + // A raw model id may itself contain "/" (for example zenmux moonshotai/kimi-k3). + // After the full-selector lookup misses, try that spelling as a same-provider id. + match = sameProviderCandidate(configured); + } + if (!match) return { kind: "unresolved", configured }; + const target = typeof match.slug === "string" ? match.slug : configured; + // A qualified selector may name another provider's row on purpose; only a bare value that lands + // outside this provider is worth reporting. + const foreign = !configured.includes("/") && catalogEntryProviderName(match) !== provider; + return { kind: "valid", value: { configured, target }, ...(foreign ? { foreign: true } : {}) }; +} + +/** Build resolved per-provider plans and emit one diagnostic per bad selector. */ +function buildProviderReviewPlans( + models: readonly RawEntry[], + config: Pick, +): { plans: Map; failure?: "invalid" | "unresolved" } { + const plans = new Map(); + let failure: "invalid" | "unresolved" | undefined; + const warned = new Set(); + const recordFailure = (kind: "invalid" | "unresolved", provider: string, configured: string): void => { + const signature = `${provider}\u0000${configured}`; + if (warned.has(signature)) return; + warned.add(signature); + warnProviderAutoReviewModelDiagnostic(kind, provider, configured); + failure ??= kind; + }; + const recordForeignTarget = (provider: string, configured: string, target: string): void => { + const signature = `${provider}\u0000foreign\u0000${configured}`; + if (warned.has(signature)) return; + warned.add(signature); + warnProviderAutoReviewForeignTarget(provider, configured, target); + }; + for (const [name, provider] of Object.entries(config.providers ?? {})) { + if (provider.autoReviewModel === undefined && provider.autoReviewModelOverrides === undefined) continue; + const plan: ProviderReviewPlan = { perModel: new Map() }; + if (provider.autoReviewModel !== undefined) { + const resolved = resolveProviderReviewTarget(models, name, provider.autoReviewModel); + if (resolved.kind === "valid") { + plan.wide = resolved.value; + if (resolved.foreign) recordForeignTarget(name, resolved.value.configured, resolved.value.target); + } + else if (resolved.kind !== "absent") recordFailure(resolved.kind, name, resolved.configured); + } + if (provider.autoReviewModelOverrides !== undefined) { + for (const [modelId, rawTarget] of Object.entries(provider.autoReviewModelOverrides)) { + const resolved = resolveProviderReviewTarget(models, name, rawTarget); + if (resolved.kind === "valid") { + plan.perModel.set(providerModelKey(modelId), resolved.value); + if (resolved.foreign) recordForeignTarget(name, resolved.value.configured, resolved.value.target); + } else if (resolved.kind !== "absent") { + recordFailure(resolved.kind, name, resolved.configured); + } + } + } + // `modelAliases` publishes a second public name for a model id, and a routed row's slug always + // carries the upstream id — so accept an override key written in either spelling. + for (const [modelId, alias] of Object.entries(provider.modelAliases ?? {})) { + if (typeof alias !== "string" || !alias.trim()) continue; + if (aliasNamesAnotherRoutedRow(models, name, alias)) continue; + const idKey = providerModelKey(modelId); + const aliasKey = providerModelKey(alias); + if (idKey === aliasKey) continue; + const fromId = plan.perModel.get(idKey); + const fromAlias = plan.perModel.get(aliasKey); + if (fromId !== undefined && fromAlias === undefined) plan.perModel.set(aliasKey, fromId); + else if (fromAlias !== undefined && fromId === undefined) plan.perModel.set(idKey, fromAlias); + } + if (plan.wide !== undefined || plan.perModel.size > 0) plans.set(name, plan); + } + return { plans, failure }; +} + +/** Apply or clear the root selector only on rows without a provider stamp. */ +function applyRootSelectorToRemaining( + models: readonly RawEntry[], + rootValue: string | null | undefined, + providerStamped: ReadonlySet, +): AutoReviewModelOverrideResult { + const clearRemaining = (): void => { + for (const entry of models) { + if (!entry || providerStamped.has(entry)) continue; + // Native rows written by releases before the root marker cannot be told apart from upstream + // values once provider stamps diverge. clearLegacyRootStamps sweeps the ones the legacy + // uniform signature still recognizes before provider plans land, because provider stamping + // destroys that signature; a catalog that no longer matches it needs a one-off manual sync. + if (isRoutedCatalogEntry(entry) || entry[AUTO_REVIEW_ROOT_MARKER] === true || rootAutoReviewStamp(entry)) clearAutoReviewOverrideValue(entry); + } + }; + if (rootValue === null || rootValue === undefined) { + clearRemaining(); + return "absent"; + } + const trimmed = rootValue.trim(); + if (!trimmed) { + clearRemaining(); + return "absent"; + } + if (!isValidAutoReviewModel(trimmed)) { + clearRemaining(); + warnAutoReviewModelDiagnostic("invalid", trimmed); + return "invalid"; + } + if (!configuredCatalogEntry(models, trimmed)) { + clearRemaining(); + warnAutoReviewModelDiagnostic("unresolved", trimmed); + return "unresolved"; + } + for (const entry of models) { + if (!entry || providerStamped.has(entry)) continue; + stampRootAutoReviewOverride(entry, trimmed); + } + return "applied"; +} + +/** Provider-aware variant: provider rows win and the root selector is the fallback. */ +export function applyConfiguredAutoReviewModelOverride( + models: RawEntry[] | undefined, + rootAutoReviewModel: string | null | undefined, + config: Pick, + sourceModels: readonly RawEntry[] = [], +): AutoReviewModelOverrideResult { + if (!models || !Array.isArray(models)) return "absent"; + // Runs unconditionally because the sweep only fires on the uniform legacy signature. A resolved + // root selector restamps every row it touches below, so the call is behavior-preserving there; + // with the root absent, invalid, or unresolved those clears are final — which is the point, and + // also the limit: the legacy heuristic cannot tell a root stamp from an identical upstream value. + clearLegacyRootStamps(models, sourceModels); + const { plans, failure } = buildProviderReviewPlans(models, config); + const providerStamped = new Set(); + for (const entry of models) { + if (!entry || typeof entry !== "object") continue; + const provider = catalogEntryProviderName(entry); + if (!provider) continue; + const plan = plans.get(provider); + if (!plan) continue; + const modelSegment = catalogEntryModelSegment(entry); + const perModel = modelSegment === undefined ? undefined : plan.perModel.get(providerModelKey(modelSegment)); + const selected = perModel ?? plan.wide; + if (!selected) continue; + stampProviderAutoReviewOverride(entry, selected.target); + providerStamped.add(entry); + } + const rootResult = applyRootSelectorToRemaining(models, rootAutoReviewModel, providerStamped); + const providerApplied = [...providerStamped].some(entry => typeof entry.auto_review_model_override === "string"); + if (providerApplied) { + if (rootResult === "invalid" || rootResult === "unresolved") return rootResult; + return failure ?? "applied"; + } + return failure ?? rootResult; +} + +/** True when any provider row configures a provider-scoped auto-review selector. */ +function configHasProviderAutoReview(config: Pick): boolean { + return Object.values(config.providers ?? {}).some(provider => + provider.autoReviewModel !== undefined || provider.autoReviewModelOverrides !== undefined); +} + +/** Apply the root Codex auto-review selector after the final catalog merge. */ +export function finalizeAutoReviewModelOverride( + models: RawEntry[] | undefined, + sourceModels: readonly RawEntry[] = [], + config?: Pick, +): AutoReviewModelOverrideResult { + if (models && sourceModels.length > 0) preserveNativeAutoReviewModelOverrides(models, sourceModels); + if (config && configHasProviderAutoReview(config)) { + return applyConfiguredAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), config, sourceModels); + } + return applyAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), sourceModels); +} +/** + * Why an account-gated native model stopped being offered, but only when the answer is one the + * operator can act on. + * + * Suppression is an omission: the row is never built, so there is no catalog entry for a reason + * to ride on and no downstream consumer that could explain it later. #4212's reporter watched + * their models disappear and reasonably concluded the proxy was broken, because every surface + * that changed said nothing about the account that caused it. + * + * Returns `undefined` for the ordinary case — an account that is simply not entitled to a gated + * model. That is the default state for most installations, it is not news, and warning about it + * on every sync would bury the one case that matters. A credential the operator must repair is + * the case that matters, so that is the only one this speaks up about. + * + * Accounts are named with the durable `p`-prefixed log label, the same identifier the dashboard + * shows, never the raw pool id or the email. + */ diff --git a/src/codex/catalog/build-entries.ts b/src/codex/catalog/build-entries.ts new file mode 100644 index 0000000000..2372c599e3 --- /dev/null +++ b/src/codex/catalog/build-entries.ts @@ -0,0 +1,981 @@ +import { CODEX_REASONING_LEVELS } from "../../reasoning-effort"; +import { clearModelCache } from "../model-cache"; +import { routedSlug, slugEquivalenceKey } from "../../providers/slug-codec"; +import { COMBO_NAMESPACE } from "../../combos"; +import { + CODEX_CUSTOM_MODEL_CATALOG_KIND, + CODEX_PROVIDER_MODEL_CATALOG_KIND, + applyMultiAgentMode, + applyNativeOpenAiContextOverride, + catalogModelSlug, + ensureStrictCatalogFields, + isRoutedModelCompatibilityExcluded, + normalizeServiceTiers, +} from "./parsing"; +import type { CatalogModel, MultiAgentMode, RawEntry } from "./parsing"; +import { + CODEX_NATIVE_ALIAS_CATALOG_KIND, + NATIVE_OPENAI_MODELS, + SUPPORTED_NATIVE_OPENAI_SLUGS, + applyNativeVisibility, + isNativeAliasCatalogEntry, + isUnsupportedOpenAiNativeSlug, + shouldUpgradeToUpstreamEntry, + upstreamNativeEntry, + type NativeContextLimitsInput, +} from "./metadata"; +import { resetBundledCatalogCacheForTests } from "./bundled"; +import { isMultiAgentV2Enabled } from "../features"; +import { ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort"; +import { clearGatherRoutedModelsInflight, lastDropWarnSignature } from "./provider-fetch"; +import { + accountSelectorShadowCollisionWarnings, + clearLastComboCatalogOmissions, + comboCatalogWarningSignatures, + comboMasqueradeCollisionWarnings, + comboUnrestorableShadowWarnings, + openAiApiCollisionWarnings, + resolveSlugAliasCollisions, + slugAliasCollisionWarnings, + warnAccountSelectorShadowedProviderOnce, + warnComboMasqueradeCollisionOnce, + warnComboUnrestorableShadowOnce, +} from "./aggregation"; +import { accountBoundNativeDisplayName, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug } from "./account-models"; +import { NATIVE_RESERVE_MODEL } from "./native-models"; +import { isReserveCatalogProjection, type ReserveCatalogProjection } from "./reserve"; +import { deriveEntry, finishUpstreamNativeEntry, isExactComboCatalogEntry } from "./derive-entry"; +import { PICKER_ORDER_PRIORITY_BASE, SPAWN_PRIORITY_FIELD } from "./subagent-roster"; + +export interface ObservedCatalogEntryBuildInput { + readonly template: RawEntry | null; + readonly gptSlugs: readonly string[]; + readonly goModels: readonly CatalogModel[]; + readonly featured?: readonly string[]; + /** Optional full picker ordering (config.modelPickerOrder); orders non-featured rows. */ + readonly modelPickerOrder?: readonly string[]; + readonly wsEnabled: boolean; + readonly multiAgentMode: MultiAgentMode; + readonly exactComboSlugs: ReadonlySet; + readonly accountSelectors: readonly string[]; + readonly suppressedBareNativeSlugs: ReadonlySet; + readonly disabledNativeAccountSlugs: ReadonlySet; + readonly multiAgentV2Enabled: boolean; + readonly keepNativeChatGptOnV1?: boolean; + readonly openaiContextCap?: NativeContextLimitsInput; + /** Additional native ids to clone under account selectors, without creating bare rows. */ + readonly accountNativeSlugs?: readonly string[]; + /** Per-selector account ids; unknown observations must not be copied to unrelated accounts. */ + readonly accountNativeSlugsBySelector?: ReadonlyMap; + /** Codex-only manual selector metadata; deliberately independent of live permission. */ + readonly reserve?: ReserveCatalogProjection; +} + +/** Build entries with the process-observed Codex feature state. */ +export function buildCatalogEntries( + template: RawEntry | null, + gptSlugs: string[], + goModels: CatalogModel[], + featured?: string[], + wsEnabled = false, + multiAgentMode: MultiAgentMode = "default", + exactComboSlugs: ReadonlySet = new Set(), + accountSelectors: readonly string[] = [], + suppressedBareNativeSlugs: ReadonlySet = new Set(), + disabledNativeAccountSlugs: ReadonlySet = new Set(), + contextCap?: NativeContextLimitsInput, + accountNativeSlugs?: readonly string[], + accountNativeSlugsBySelector?: ReadonlyMap, + keepNativeChatGptOnV1 = false, + modelPickerOrder: readonly string[] = [], +): RawEntry[] { + const entries = buildCatalogEntriesFromObservedState({ + template, + gptSlugs, + goModels, + featured, + modelPickerOrder, + wsEnabled, + multiAgentMode, + exactComboSlugs, + accountSelectors, + suppressedBareNativeSlugs, + disabledNativeAccountSlugs, + multiAgentV2Enabled: isMultiAgentV2Enabled(), + keepNativeChatGptOnV1, + openaiContextCap: contextCap, + accountNativeSlugs, + accountNativeSlugsBySelector, + }); + applyFullModelPickerOrder(entries, modelPickerOrder); + return entries; +} + +/** Build entries solely from caller-observed inputs, with no feature-state filesystem read. */ +export function buildCatalogEntriesFromObservedState({ + template, + gptSlugs, + goModels, + featured, + modelPickerOrder, + wsEnabled, + multiAgentMode, + exactComboSlugs, + accountSelectors, + suppressedBareNativeSlugs, + disabledNativeAccountSlugs, + multiAgentV2Enabled, + keepNativeChatGptOnV1, + openaiContextCap, + accountNativeSlugs, + accountNativeSlugsBySelector, + reserve, +}: ObservedCatalogEntryBuildInput): RawEntry[] { + // Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible + // models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog + // ARRAY order is discarded — so "featuring" a model = giving it the LOWEST priority (0..N-1) so + // it sorts to the front. This works for native gpt slugs AND routed slugs alike. + const rank = new Map((featured ?? []).map((slug, i) => [slug, i] as const)); + const priorityStride = Math.max(accountSelectors.length, 1); + // Optional full picker order (#1649). Independent of the 5-slot spawn_agent cap: it only + // rewrites the Codex-visible display `priority` of listed non-featured routed rows so a >5 + // catalog stays put across rebuilds. Featured rows keep their existing 0..N-1 band; when + // modelPickerOrder is unset the helper is a no-op and every priority below is byte-identical to + // before. The spawn_agent candidate window is derived separately from SPAWN_PRIORITY_FIELD, so + // this display reorder does not change OpenCodex's guidance candidate calculation. + const pickerOrder = normalizeModelPickerOrder(modelPickerOrder); + const pickerOrderRank = new Map(pickerOrder.map((slug, i) => [slug, i] as const)); + const pickerOrderActive = pickerOrder.length > 0; + // The display band reuses the existing high priority tier (>= PICKER_ORDER_PRIORITY_BASE, the + // same 1_000+ neighborhood account rows occupy), keeping listed rows visually after the featured + // band. OpenCodex guidance membership does not depend on this — see SPAWN_PRIORITY_FIELD. + /** + * Priority for a non-featured routed row that is explicitly LISTED in modelPickerOrder. Listed + * slugs sort in declared order within the high picker-order display tier + * (>= PICKER_ORDER_PRIORITY_BASE). This sets the Codex-visible `priority` only; the caller records + * the row's natural priority in SPAWN_PRIORITY_FIELD for OpenCodex's unchanged guidance window. + * Returns undefined when the feature is off or the row is not listed, so those rows + * keep their original assignment (default 5 / account 1_000+) untouched. + * + * Scope: only the generic routed `/` rows call this (see the goModels loop + * below). Native passthrough rows and account-qualified native rows keep their own priority + * logic and are intentionally not reordered in this legacy builder pass. The final merge can + * apply complete ordering when the configured list includes a bare id. + */ + const pickerOrderPriority = (slug: string, altSlug?: string): number | undefined => { + if (!pickerOrderActive) return undefined; + const hit = pickerOrderRank.get(slug) ?? (altSlug !== undefined ? pickerOrderRank.get(altSlug) : undefined); + if (hit === undefined) return undefined; + return PICKER_ORDER_PRIORITY_BASE + hit * priorityStride; + }; + const out: RawEntry[] = []; + const nativeEntries: RawEntry[] = []; + const collisionSkipped = resolveSlugAliasCollisions([...goModels]); + const emittedNativeAliases = new Set(); + const emittedNativeAliasSlugs = new Set(); + const nativeAliasesBySlug = new Map(); + for (const model of goModels) { + if (model.provider !== COMBO_NAMESPACE + || model.nativeAlias !== true + || typeof model.alias !== "string" + || model.alias.includes("/")) continue; + if (nativeAliasesBySlug.has(model.alias)) { + collisionSkipped.add(model); + if (!slugAliasCollisionWarnings.has(model.alias)) { + slugAliasCollisionWarnings.add(model.alias); + console.warn( + `[opencodex] native combo alias collision on "${model.alias}": keeping the first configured combo and omitting later duplicates from the catalog.`, + ); + } + continue; + } + nativeAliasesBySlug.set(model.alias, model); + } + const comboPublicSlugs = new Set(goModels + .filter(model => model.provider === COMBO_NAMESPACE) + .map(catalogModelSlug)); + for (const slug of gptSlugs) { + const native = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9, undefined, new Set(), openaiContextCap); + if (rank.has(slug)) native.priority = rank.get(slug)!; + nativeEntries.push(native); + const nativeAlias = nativeAliasesBySlug.get(slug); + if (!nativeAlias || collisionSkipped.has(nativeAlias)) { + if (!suppressedBareNativeSlugs.has(slug)) out.push(native); + continue; + } + const routed = deriveEntry( + template, + slug, + `Routed via opencodex → ${nativeAlias.provider} (${nativeAlias.owned_by ?? nativeAlias.provider}).`, + 5, + nativeAlias, + exactComboSlugs, + ); + routed.opencodex_catalog_kind = CODEX_NATIVE_ALIAS_CATALOG_KIND; + const rankHit = rank.get(slug) ?? rank.get(`${nativeAlias.provider}/${nativeAlias.id}`); + if (rankHit !== undefined) routed.priority = rankHit * priorityStride; + else if (accountSelectors.length > 0) routed.priority = 1_000 + (typeof routed.priority === "number" ? routed.priority : 5); + out.push(routed); + emittedNativeAliases.add(nativeAlias); + emittedNativeAliasSlugs.add(slug); + } + const nativeEntriesBySlug = new Map(nativeEntries.map(entry => [String(entry.slug), entry] as const)); + for (const [selectorIndex, selector] of accountSelectors.entries()) { + const selectorNativeSlugs = accountNativeSlugsBySelector?.get(selector) + ?? accountNativeSlugs + ?? gptSlugs; + const accountNativeEntries = selectorNativeSlugs.filter(slug => slug !== NATIVE_RESERVE_MODEL).map(slug => ( + nativeEntriesBySlug.get(slug) + ?? deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9, undefined, new Set(), openaiContextCap) + )); + if (reserve?.mainSelectors.includes(selector)) accountNativeEntries.push(reserve.source); + for (const [nativeIndex, native] of accountNativeEntries.entries()) { + const nativeSlug = String(native.slug); + if (disabledNativeAccountSlugs.has(nativeSlug)) continue; + const e = JSON.parse(JSON.stringify(native)) as RawEntry; + const catalogSlug = `${selector}/${nativeSlug}`; + if (nativeSlug === NATIVE_RESERVE_MODEL && disabledNativeAccountSlugs.has(catalogSlug)) continue; + e.slug = catalogSlug; + e.display_name = accountBoundNativeDisplayName(selector, native); + // Codex ignores this OpenCodex extension; preserve the native comp_hash unchanged. + e.opencodex_catalog_kind = CODEX_ACCOUNT_BOUND_CATALOG_KIND; + const exactRank = rank.get(catalogSlug); + // A bare featured id belongs to the compatibility combo once shadowed. Exact + // account-qualified picks still rank normally, but the account clone must not + // inherit the bare alias rank and consume another top spawn_agent slot. + const inheritedRank = emittedNativeAliasSlugs.has(nativeSlug) ? undefined : rank.get(nativeSlug); + const featuredRank = exactRank ?? inheritedRank; + e.priority = featuredRank !== undefined + ? featuredRank * priorityStride + selectorIndex + : ((featured?.length ?? 0) + nativeIndex) * accountSelectors.length + selectorIndex; + e.visibility = "list"; + out.push(e); + } + } + for (const m of goModels) { + if (collisionSkipped.has(m) || emittedNativeAliases.has(m)) continue; + const slug = catalogModelSlug(m); + if (m.provider !== COMBO_NAMESPACE && comboPublicSlugs.has(slug)) { + warnComboMasqueradeCollisionOnce(slug); + continue; + } + // Provider rows use the one-slash slug codec; combo aliases intentionally override that + // public slug and may be bare. + const e = deriveEntry( + template, + slug, + `Routed via opencodex → ${m.provider} (${m.owned_by ?? m.provider}).`, + 5, + m, + exactComboSlugs, + ); + if (m.provider === COMBO_NAMESPACE && m.nativeAlias === true && !slug.includes("/")) { + e.opencodex_catalog_kind = CODEX_NATIVE_ALIAS_CATALOG_KIND; + } + // Featured picks may be stored raw (legacy) or encoded — honor both. + const rankHit = rank.get(slug) ?? rank.get(`${m.provider}/${m.id}`); + // Natural priority: what the row would get WITHOUT modelPickerOrder. This is the value the + // spawn_agent candidate window is derived from (see effectiveSubagentRoster), so it must never + // move when modelPickerOrder reorders the picker. + if (rankHit !== undefined) e.priority = rankHit * priorityStride; + else if (accountSelectors.length > 0) { + // Keep the generated account rows together in Codex's priority-sorted flat picker. + e.priority = 1_000 + (typeof e.priority === "number" ? e.priority : 5); + } + // The legacy routed-only builder pass keeps featured ranks and records natural priority + // before changing non-featured display priority. The final complete-order pass may move + // featured display rows too; OpenCodex guidance continues to use their natural ranks. + if (rankHit === undefined) { + const pickerPriority = pickerOrderPriority(slug, `${m.provider}/${m.id}`); + if (pickerPriority !== undefined) { + e[SPAWN_PRIORITY_FIELD] = typeof e.priority === "number" ? e.priority : 5; + e.priority = pickerPriority; + } + } + out.push(e); + } + // Central capability override (phase 120.4): the advertised flag must match the implemented WS + // endpoint. Overrides both the routed strip (normalizeRoutedCatalogEntry) and any native template + // leak (deriveEntry clones the template as-is for native slugs). + for (const entry of out) { + if (wsEnabled) entry.supports_websockets = true; + else { + delete entry.supports_websockets; + // Snapshot-backed native entries carry prefer_websockets: never advertise a preference + // for an endpoint ocx has disabled. + delete entry.prefer_websockets; + } + } + return applyMultiAgentMode(out, multiAgentMode, multiAgentV2Enabled, { + keepNativeChatGptOnV1, + preserveDefaultMultiAgentVersion: isReserveCatalogProjection, + }); +} + +export function resetCatalogRuntimeStateForTests(): void { + resetBundledCatalogCacheForTests(); + lastDropWarnSignature.clear(); + openAiApiCollisionWarnings.clear(); + comboCatalogWarningSignatures.clear(); + slugAliasCollisionWarnings.clear(); + comboMasqueradeCollisionWarnings.clear(); + comboUnrestorableShadowWarnings.clear(); + accountSelectorShadowCollisionWarnings.clear(); + clearLastComboCatalogOmissions(); + clearModelCache(undefined, "eviction"); + clearGatherRoutedModelsInflight(); +} + +export function orderForSubagents(goModels: CatalogModel[], featured?: string[]): CatalogModel[] { + if (!featured || featured.length === 0) return goModels; + const rank = new Map(featured.map((id, i) => [id, i])); + // Featured picks may be stored raw (legacy) or encoded — match both forms. + const rankOf = (m: CatalogModel) => + (m.alias ? rank.get(m.alias) : undefined) + ?? rank.get(`${m.provider}/${m.id}`) + ?? rank.get(routedSlug(m.provider, m.id)) + ?? Number.MAX_SAFE_INTEGER; + return [...goModels].sort((a, b) => { + return rankOf(a) - rankOf(b); + }); +} + +/** Routed discovery projection; native groups and alias ownership belong to the caller. */ +export function orderForModelPicker( + models: readonly CatalogModel[], + order: readonly string[] = [], + featured: readonly string[] = [], +): CatalogModel[] { + const pickerOrder = normalizeModelPickerOrder(order); + if (pickerOrder.length === 0) return [...models]; + const pickerRank = modelPickerRank(pickerOrder); + const featuredRank = modelPickerRank(featured); + const complete = pickerOrder.some(slug => !slug.includes("/")); + const rank = (model: CatalogModel): number => { + const slug = catalogModelSlug(model); + const featuredIndex = featuredRank(slug) ?? featuredRank(`${model.provider}/${model.id}`); + const natural = featuredIndex ?? 5; + const index = pickerRank(slug) ?? pickerRank(`${model.provider}/${model.id}`); + if (complete) return index ?? pickerOrder.length + natural; + // Preserve the legacy featured/alias bands, including unlisted rows before listed rows. + if (featuredIndex !== undefined || model.nativeAlias === true) return natural; + return index === undefined ? natural : PICKER_ORDER_PRIORITY_BASE + index; + }; + return [...models].sort((a, b) => rank(a) - rank(b)); +} + +/** + * True when an existing catalog row was authored by OpenCodex routing (#855). + * Every generated routed row — current full-slug form, the June–July 2026 + * provider-name form, and legacy combo aliases — carries the stable + * description prefix `Routed via opencodex → `; foreign rows from Cursor or + * user tooling do not. `owned_by` cannot serve as the signal (upstream + * ownership), and `comp_hash` defaults to "opencodex" for every normalized + * row. + */ +function isOcxAuthoredRoutedEntry(entry: RawEntry): boolean { + if (isNativeAliasCatalogEntry(entry)) return true; + const desc = typeof entry.description === "string" ? entry.description : ""; + const slug = typeof entry.slug === "string" ? entry.slug : ""; + return slug.includes("/") && desc.startsWith("Routed via opencodex → "); +} + +function recoverableNativeSlug(entry: RawEntry): string | null { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + return SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug) + && !isNativeAliasCatalogEntry(entry) + && entry.owned_by !== COMBO_NAMESPACE + ? slug + : null; +} + +/** Undo our display overlay before native metadata normalization and template reuse. */ +function restoreNativeDisplayName(entry: RawEntry): RawEntry { + const saved = entry.opencodex_native_display_name; + delete entry.opencodex_native_display_name; + if (saved && typeof saved === "object" && !Array.isArray(saved)) { + const label = saved as Record; + if (recoverableNativeSlug(entry) === label.slug + && typeof label.original === "string" && entry.display_name === label.applied) { + entry.display_name = label.original; + } + } + return entry; +} + +/** Append missing supported native rows from trusted catalog sources only. */ +export function mergeCatalogModelsWithNativeRecovery( + primaryCatalogModels: readonly RawEntry[], + nativeRecoverySources: readonly (readonly RawEntry[])[], +): RawEntry[] { + const merged = [...primaryCatalogModels]; + const recoveredNativeSlugs = new Set(primaryCatalogModels.flatMap(entry => { + const slug = recoverableNativeSlug(entry); + return slug === null ? [] : [slug]; + })); + for (const source of nativeRecoverySources) { + for (const entry of source) { + const slug = recoverableNativeSlug(entry); + if (slug === null || recoveredNativeSlugs.has(slug)) continue; + merged.push(structuredClone(entry) as RawEntry); + recoveredNativeSlugs.add(slug); + } + } + return merged; +} + +export interface ObservedCatalogMergePolicy { + /** Required observed/fixed set; the core merge never consults ambient catalog state. */ + readonly nativeBackfillSlugs: readonly string[]; + /** Whether unsupported OpenAI-family bare rows survive the merge. */ + readonly unsupportedNativeEntries: "preserve" | "drop"; + /** Whether merge-policy collision/preservation warnings belong to this caller's flow. */ + readonly warningPolicy: "emit" | "suppress"; +} + +/** Content policy shared by every writer of the canonical Codex model catalog. */ +export const CANONICAL_NATIVE_CATALOG_CONTENT_POLICY: Readonly< + Pick +> = Object.freeze({ + nativeBackfillSlugs: Object.freeze([...NATIVE_OPENAI_MODELS]), + unsupportedNativeEntries: "drop", +}); + +function normalizeModelPickerOrder(order: unknown): string[] { + return Array.isArray(order) + ? order.filter((id): id is string => typeof id === "string" && id.trim().length > 0) + : []; +} + +/** Preserve exact-id precedence while accepting the existing raw/encoded slug spellings. */ +function modelPickerRank(order: readonly string[]): (slug: string) => number | undefined { + const exact = new Map(order.map((slug, index) => [slug, index])); + const equivalent = new Map(order.map((slug, index) => [slugEquivalenceKey(slug), index])); + return slug => exact.get(slug) ?? equivalent.get(slugEquivalenceKey(slug)); +} + +/** Complete display ordering retains natural ranks for OpenCodex's separate guidance projection. */ +export function applyFullModelPickerOrder(entries: RawEntry[], order: readonly string[]): void { + const pickerOrder = normalizeModelPickerOrder(order); + if (!pickerOrder.some(slug => !slug.includes("/"))) return; + const rankOf = modelPickerRank(pickerOrder); + for (const entry of entries) { + const natural = entry[SPAWN_PRIORITY_FIELD] ?? entry.priority ?? 9; + entry[SPAWN_PRIORITY_FIELD] = natural; + entry.priority = rankOf(String(entry.slug)) ?? pickerOrder.length + Number(natural); + } +} + +export interface ObservedCatalogMergeInput { + readonly catalogModels: readonly RawEntry[]; + readonly baselineCatalogModels: readonly RawEntry[]; + readonly routedEntries: readonly RawEntry[]; + readonly baseline: ReadonlyMap; + readonly featured: readonly string[]; + readonly modelPickerOrder?: readonly string[]; + readonly accountSelectors?: readonly string[]; + readonly wsEnabled: boolean; + readonly template: RawEntry | null; + readonly disabledModels: ReadonlySet; + readonly selectedModelsByProvider: ReadonlyMap>; + readonly gatheredProviderNames: ReadonlySet; + readonly pendingProviderNames?: ReadonlySet; + readonly degradedProviderNames: ReadonlySet; + readonly legacyCustomModelSlugs: ReadonlySet; + readonly multiAgentMode: MultiAgentMode; + readonly multiAgentV2Enabled: boolean; + readonly keepNativeChatGptOnV1?: boolean; + readonly exactComboSlugs: ReadonlySet; + readonly hasPhysicalComboProvider: boolean; + readonly includeNativeOpenAi: boolean; + readonly accountBoundEntries: readonly RawEntry[]; + readonly suppressedBareNativeSlugs?: ReadonlySet; + readonly policy: ObservedCatalogMergePolicy; + readonly openaiContextCap?: NativeContextLimitsInput; + /** Exact display-only labels for bare native OpenAI models. */ + readonly nativeDisplayNames?: Readonly>; +} + +/** + * Deterministically merge one fully observed catalog state. + * + * Every non-catalog input is explicit so evidence-bound convergence cannot + * accidentally fall back to process-ambient catalog discovery or merge-policy warnings. + */ +export function mergeCatalogEntriesFromObservedState({ + catalogModels, + baselineCatalogModels, + routedEntries, + baseline, + featured, + modelPickerOrder = [], + accountSelectors = [], + wsEnabled, + template, + disabledModels, + selectedModelsByProvider, + gatheredProviderNames, + pendingProviderNames = new Set(), + degradedProviderNames, + legacyCustomModelSlugs, + multiAgentMode, + multiAgentV2Enabled, + keepNativeChatGptOnV1, + exactComboSlugs, + hasPhysicalComboProvider, + includeNativeOpenAi, + accountBoundEntries, + suppressedBareNativeSlugs = new Set(), + policy, + openaiContextCap, + nativeDisplayNames, +}: ObservedCatalogMergeInput): RawEntry[] { + // Raw catalog rows contain nested arrays/objects that normalization mutates. Detach every row at + // the observed-core boundary so callers can safely retain evidence objects or repeat the merge. + const detachedCatalogModels = catalogModels + .map(entry => restoreNativeDisplayName(structuredClone(entry) as RawEntry)); + const detachedBaselineCatalogModels = baselineCatalogModels + .map(entry => restoreNativeDisplayName(structuredClone(entry) as RawEntry)); + const detachedRoutedEntries = routedEntries.map(entry => structuredClone(entry) as RawEntry); + // Track this invocation's generated custom rows, not ownership markers read from disk. + // Their builder already finalized exact native ladders and ordinary routed mock tiers. + const freshCustomEntries = new Set(detachedRoutedEntries.filter(entry => + entry.opencodex_catalog_kind === CODEX_CUSTOM_MODEL_CATALOG_KIND)); + const detachedAccountBoundEntries = accountBoundEntries + .map(entry => structuredClone(entry) as RawEntry); + const disabledModelKeys = new Set([...disabledModels].map(slugEquivalenceKey)); + const legacyCustomModelKeys = new Set( + [...legacyCustomModelSlugs].map(slugEquivalenceKey), + ); + const selectedModelKeysByProvider = new Map([...selectedModelsByProvider].map(([provider, models]) => ( + [provider, new Set([...models].map(model => slugEquivalenceKey(routedSlug(provider, model))))] as const + ))); + const freshAccountKeys = new Set(detachedAccountBoundEntries.flatMap(entry => ( + typeof entry.slug === "string" ? [slugEquivalenceKey(entry.slug)] : [] + ))); + const wouldSurviveUnreplaced = (entry: RawEntry): boolean => { + if (entry.owned_by === COMBO_NAMESPACE + || trustedAccountBoundNativeCatalogSlug(entry) !== undefined + || entry.opencodex_catalog_kind === CODEX_CUSTOM_MODEL_CATALOG_KIND + || isOcxAuthoredRoutedEntry(entry) + || typeof entry.slug !== "string") return false; + const slug = entry.slug; + if (!slug.includes("/")) { + if (!includeNativeOpenAi || policy.nativeBackfillSlugs.includes(slug)) return false; + return policy.unsupportedNativeEntries === "preserve" || !isUnsupportedOpenAiNativeSlug(slug); + } + if (isRoutedModelCompatibilityExcluded(slug)) return false; + if (!hasPhysicalComboProvider && slug.startsWith(`${COMBO_NAMESPACE}/`)) return false; + const key = slugEquivalenceKey(slug); + if (freshAccountKeys.has(key)) return false; + if (disabledModelKeys.has(key)) return false; + const slash = slug.indexOf("/"); + const provider = slug.slice(0, slash); + if (pendingProviderNames.has(provider)) return false; + const selected = selectedModelKeysByProvider.get(provider); + if (selected !== undefined && !selected.has(key)) return false; + return !gatheredProviderNames.has(provider) || degradedProviderNames.has(provider); + }; + const validRoutedEntries = detachedRoutedEntries.filter(entry => { + return !isExactComboCatalogEntry(entry, exactComboSlugs) + || (Array.isArray(entry.input_modalities) && entry.input_modalities.length > 0); + }); + const restorableCatalogKeys = new Set(detachedBaselineCatalogModels.flatMap(entry => ( + wouldSurviveUnreplaced(entry) && typeof entry.slug === "string" + ? [slugEquivalenceKey(entry.slug)] + : [] + ))); + const unrestorableCatalogKeys = new Set(detachedCatalogModels.flatMap(entry => { + if (!wouldSurviveUnreplaced(entry) || typeof entry.slug !== "string") return []; + const key = slugEquivalenceKey(entry.slug); + return restorableCatalogKeys.has(key) ? [] : [key]; + })); + const admittedRoutedEntries = validRoutedEntries.filter(entry => { + if (!isExactComboCatalogEntry(entry, exactComboSlugs)) return true; + const slug = entry.slug as string; + const key = slugEquivalenceKey(slug); + if (!unrestorableCatalogKeys.has(key)) return true; + if (policy.warningPolicy === "emit") warnComboUnrestorableShadowOnce(slug); + return false; + }); + // A fresh non-custom row authoritatively resolves a historically ambiguous slug as a normal + // provider model. Persist that classification so the durable deletion evidence cannot remove + // the legitimate row during a later degraded refresh. + for (const entry of admittedRoutedEntries) { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + if (!slug + || entry.opencodex_catalog_kind !== undefined + || entry.owned_by === COMBO_NAMESPACE + || !isOcxAuthoredRoutedEntry(entry) + || !legacyCustomModelKeys.has(slugEquivalenceKey(slug))) continue; + entry.opencodex_catalog_kind = CODEX_PROVIDER_MODEL_CATALOG_KIND; + } + const freshExactComboEntries = new Set(admittedRoutedEntries.filter(entry => ( + isExactComboCatalogEntry(entry, exactComboSlugs) + && typeof entry.description === "string" + && entry.description.startsWith(`Routed via opencodex → ${COMBO_NAMESPACE} (`) + ))); + const rank = new Map(featured.map((slug, i) => [slug, i] as const)); + const freshEquivalentKeys = new Set(admittedRoutedEntries.flatMap(entry => ( + typeof entry.slug === "string" ? [slugEquivalenceKey(entry.slug)] : [] + ))); + const freshEquivalent = (slug: string): boolean => ( + freshEquivalentKeys.has(slugEquivalenceKey(slug)) + ); + const freshBareComboAliases = new Set(admittedRoutedEntries.flatMap(entry => ( + typeof entry.slug === "string" + && !entry.slug.includes("/") + && entry.owned_by === COMBO_NAMESPACE + ? [entry.slug] + : [] + ))); + const staleComboKeys = new Set(detachedCatalogModels.flatMap(entry => ( + typeof entry.slug === "string" + && entry.owned_by === COMBO_NAMESPACE + && !freshEquivalent(entry.slug) + ? [slugEquivalenceKey(entry.slug)] + : [] + ))); + const currentNonComboKeys = new Set(detachedCatalogModels.flatMap(entry => ( + entry.owned_by !== COMBO_NAMESPACE && typeof entry.slug === "string" + ? [slugEquivalenceKey(entry.slug)] + : [] + ))); + const restoredComboShadows = detachedBaselineCatalogModels.filter(entry => { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + if (!slug || entry.owned_by === COMBO_NAMESPACE) return false; + const key = slugEquivalenceKey(slug); + return staleComboKeys.has(key) && !currentNonComboKeys.has(key); + }); + const catalogModelsForMerge = [...detachedCatalogModels, ...restoredComboShadows]; + const nativePriority = (slug: string, fallback: unknown): number => { + const base = baseline.get(slug) + ?? (typeof fallback === "number" ? fallback : 9); + if (rank.has(slug)) return rank.get(slug)!; + return featured.length > 0 ? Math.max(base, featured.length + 100) : base; + }; + const nativeSourceEntries = includeNativeOpenAi + ? catalogModelsForMerge + .filter(m => typeof m.slug === "string" + && !(m.slug as string).includes("/") + && m.owned_by !== COMBO_NAMESPACE + && (policy.unsupportedNativeEntries === "preserve" + || policy.nativeBackfillSlugs.includes(m.slug as string) + || !isUnsupportedOpenAiNativeSlug(m.slug as string))) + .map(m => { + const slug = m.slug as string; + // Fallback-quality entries (ocx synthesis / codex-rs model_info fallback: display_name + // stamped with the bare slug) are upgraded to the pinned upstream snapshot entry so a + // previously synthesized ladder (e.g. luna advertising ultra) self-heals on sync. A + // genuine catalog entry (real display name) is preserved untouched. + if (shouldUpgradeToUpstreamEntry(m)) { + const upstream = upstreamNativeEntry(slug)!; + const finished = finishUpstreamNativeEntry(upstream, 9, openaiContextCap); + finished.priority = nativePriority(slug, upstream.priority); + return finished; + } + const preserved = normalizeServiceTiers({ ...m, priority: nativePriority(slug, m[SPAWN_PRIORITY_FIELD] ?? m.priority) }); + // Recompute spawn rank from current featured models, not a prior picker override. + delete preserved[SPAWN_PRIORITY_FIELD]; + // Older natives kept from disk still need the mock top tiers (max + ultra always + // for subagent max spawns; wire-clamped to the model's real top rung). + if (!isGpt56NativeSlug(slug) && slug !== NATIVE_RESERVE_MODEL) ensureUltraReasoningLevel(preserved); + return preserved; + }) + : []; + const native = nativeSourceEntries.filter(entry => + typeof entry.slug !== "string" + || (!freshBareComboAliases.has(entry.slug) && !suppressedBareNativeSlugs.has(entry.slug)) + ); + + // Backfill any native OpenAI slug that the on-disk catalog is missing (e.g. gpt-5.5), so a + // routed provider exposing the same id can never delete the native OpenAI/Codex base row. + // Skip when no enabled canonical openai provider exists (#636) — bare gpt-* would 404. + const nativeSlugs = new Set(native.flatMap(m => typeof m.slug === "string" ? [m.slug] : [])); + if (includeNativeOpenAi) { + for (const slug of policy.nativeBackfillSlugs) { + if (nativeSlugs.has(slug) || freshBareComboAliases.has(slug) || suppressedBareNativeSlugs.has(slug)) continue; + nativeSlugs.add(slug); + const entry = deriveEntry( + template ? JSON.parse(JSON.stringify(template)) : null, + slug, + "OpenAI native model (Codex OAuth passthrough).", + nativePriority(slug, upstreamNativeEntry(slug)?.priority), + undefined, + new Set(), + openaiContextCap, + ); + entry.priority = nativePriority(slug, upstreamNativeEntry(slug)?.priority); + native.push(entry); + } + } + + const nativeSourceBySlug = new Map([...nativeSourceEntries, ...native].flatMap(entry => + typeof entry.slug === "string" ? [[entry.slug, entry] as const] : [] + )); + const alignedAccountBoundEntries = detachedAccountBoundEntries.map(entry => { + // The explicit Reserve source is already chosen (actual row or documented Luna adaptation). + // A generic native merge must not replace its provenance or capability ladder. + if (isReserveCatalogProjection(entry)) return entry; + const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry); + const source = nativeSlug === undefined ? undefined : nativeSourceBySlug.get(nativeSlug); + if (!source) return entry; + const aligned = JSON.parse(JSON.stringify(source)) as RawEntry; + aligned.slug = entry.slug; + aligned.display_name = entry.display_name; + aligned.priority = entry.priority; + aligned.visibility = "list"; + aligned.opencodex_catalog_kind = CODEX_ACCOUNT_BOUND_CATALOG_KIND; + return aligned; + }); + + const freshSlugs = new Set( + admittedRoutedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []), + ); + const existingRoutedEntries = catalogModelsForMerge.filter(m => + typeof m.slug === "string" + && (m.slug.includes("/") || isNativeAliasCatalogEntry(m)) + && trustedAccountBoundNativeCatalogSlug(m) === undefined + ); + const preservedRoutedEntries = existingRoutedEntries.filter(entry => { + const slug = entry.slug as string; + if (freshEquivalent(slug)) return false; + if (isNativeAliasCatalogEntry(entry)) return exactComboSlugs.has(slug); + // Current custom rows are always regenerated from config, even while provider discovery is + // degraded. A marked row absent from the fresh projection is therefore an intentional delete. + if (entry.opencodex_catalog_kind === CODEX_CUSTOM_MODEL_CATALOG_KIND) return false; + // Before custom rows had a marker, a config deletion could otherwise be mistaken for a + // provider outage. Only explicit save-boundary evidence may classify an unmarked OpenCodex + // row; foreign and future-marked rows fail closed and remain preserved. + if (entry.opencodex_catalog_kind === undefined + && entry.owned_by !== COMBO_NAMESPACE + && isOcxAuthoredRoutedEntry(entry) + && legacyCustomModelKeys.has(slugEquivalenceKey(slug))) return false; + const provider = slug.slice(0, slug.indexOf("/")); + if (gatheredProviderNames.has(provider)) { + // A provider-local degraded observation preserves only that namespace. Authoritative empty + // catalogs and successful removals still delete stale rows even when another provider fails. + return degradedProviderNames.has(provider); + } + // Deleted/disabled providers cannot retain OpenCodex-authored ghosts. Foreign catalog rows + // remain outside provider ownership and survive unless a fresh row replaces their exact slug. + return !isOcxAuthoredRoutedEntry(entry); + }); + // Retained rows bypass the builder. Recompute managed spawn ranks from current config + // before either display-order mode; a saved display override is not current roster authority. + const pickerOrder = normalizeModelPickerOrder(modelPickerOrder); + const fullPickerOrder = pickerOrder.some(slug => !slug.includes("/")); + const rankOf = modelPickerRank(pickerOrder); + const featuredRankOf = modelPickerRank(featured); + const priorityStride = Math.max(accountSelectors.length, 1); + for (const entry of preservedRoutedEntries) { + const natural = entry[SPAWN_PRIORITY_FIELD]; + if (typeof natural === "number") { + entry.priority = natural; + delete entry[SPAWN_PRIORITY_FIELD]; + } + const slug = String(entry.slug); + if (!isOcxAuthoredRoutedEntry(entry) || isNativeAliasCatalogEntry(entry)) continue; + const featuredRank = featuredRankOf(slug); + entry.priority = featuredRank !== undefined + ? featuredRank * priorityStride + : (accountSelectors.length > 0 ? 1_000 : 0) + 5; + if (featuredRank !== undefined || fullPickerOrder) continue; + const pickerIndex = rankOf(slug); + if (pickerIndex !== undefined) { + entry[SPAWN_PRIORITY_FIELD] = entry.priority; + entry.priority = PICKER_ORDER_PRIORITY_BASE + pickerIndex * priorityStride; + } + } + let finalRoutedEntries = [...admittedRoutedEntries, ...preservedRoutedEntries]; + finalRoutedEntries = finalRoutedEntries.filter(entry => { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + if (!slug.includes("/")) return true; + if (disabledModelKeys.has(slugEquivalenceKey(slug))) return false; + // Provider allowlists own provider rows, not a current combo's public alias. Exempt only an + // identity from this gather's generated combo projection: provider discovery may supply a + // spoofed `owned_by`, and persisted combo-shaped rows are not fresh authority. + if (freshExactComboEntries.has(entry)) return true; + const slash = slug.indexOf("/"); + const provider = slug.slice(0, slash); + if (pendingProviderNames.has(provider)) return false; + const selected = selectedModelKeysByProvider.get(provider); + return selected === undefined || selected.has(slugEquivalenceKey(slug)); + }); + if (!hasPhysicalComboProvider) { + finalRoutedEntries = finalRoutedEntries.filter(entry => { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + const comboOwned = slug.startsWith(`${COMBO_NAMESPACE}/`) || entry.owned_by === COMBO_NAMESPACE; + const retainedNativeAlias = isNativeAliasCatalogEntry(entry) && exactComboSlugs.has(slug); + return !comboOwned || freshSlugs.has(slug) || retainedNativeAlias; + }); + } + finalRoutedEntries = finalRoutedEntries.filter(entry => { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + const retainedNativeAlias = isNativeAliasCatalogEntry(entry) && exactComboSlugs.has(slug); + return retainedNativeAlias + || !isExactComboCatalogEntry(entry, exactComboSlugs) + || (Array.isArray(entry.input_modalities) && entry.input_modalities.length > 0); + }); + // Reapply final catalog policy to rows preserved from disk. Those rows bypass + // gatherRoutedModels, so filtering only the freshly gathered list can resurrect an excluded id. + finalRoutedEntries = finalRoutedEntries.filter(entry => + typeof entry.slug !== "string" || !isRoutedModelCompatibilityExcluded(entry.slug) + ); + const accountBoundSlugs = new Set(alignedAccountBoundEntries.flatMap(entry => + typeof entry.slug === "string" ? [entry.slug] : [] + )); + finalRoutedEntries = finalRoutedEntries.filter(entry => { + if (typeof entry.slug !== "string" || !accountBoundSlugs.has(entry.slug)) return true; + if (freshSlugs.has(entry.slug) && policy.warningPolicy === "emit") { + warnAccountSelectorShadowedProviderOnce(entry.slug); + } + return false; + }); + const finalRoutedEntrySet = new Set(finalRoutedEntries); + const degradedPreservedCount = preservedRoutedEntries.filter(entry => { + if (!finalRoutedEntrySet.has(entry)) return false; + const slug = entry.slug as string; + const provider = slug.slice(0, slug.indexOf("/")); + return gatheredProviderNames.has(provider) && degradedProviderNames.has(provider); + }).length; + if (degradedPreservedCount > 0 && policy.warningPolicy === "emit") { + console.warn(`[opencodex] catalog sync: provider discovery degraded; preserving ${degradedPreservedCount} existing routed entr${degradedPreservedCount === 1 ? "y" : "ies"} on disk.`); + } + + const managedEntries = [...finalRoutedEntries, ...alignedAccountBoundEntries]; + const observedNativeSlugs = new Set(alignedAccountBoundEntries.flatMap(entry => { + const slug = trustedAccountBoundNativeCatalogSlug(entry); + return slug === undefined ? [] : [slug]; + })); + for (const slug of policy.nativeBackfillSlugs) observedNativeSlugs.add(slug); + const mergedEntries = [...native, ...managedEntries].map(m => { + const reserveProjection = isReserveCatalogProjection(m); + const normalized = reserveProjection ? m : normalizeServiceTiers(m); + if (!reserveProjection && !isNativeAliasCatalogEntry(normalized)) applyNativeOpenAiContextOverride(normalized, openaiContextCap); + const exactCombo = isExactComboCatalogEntry(m, exactComboSlugs); + const e = reserveProjection ? normalized : ensureStrictCatalogFields(normalized, { + preserveExactInputModalities: exactCombo, + isRouted: finalRoutedEntrySet.has(m), + }); + // Mock-max universality (260709): preserved routed entries from disk may predate + // the max rung — ensure it here so subagent max spawns validate on every + // reasoning-capable entry. max only: 5.6 exact ladders (luna: no ultra) stay intact. + if (!freshCustomEntries.has(m) && !exactCombo && !reserveProjection && !String(e.slug ?? "").startsWith("opencode-go/")) { + const levels = Array.isArray(e.supported_reasoning_levels) + ? e.supported_reasoning_levels as Array<{ effort?: string }> + : []; + if (levels.length > 0 && !levels.some(level => level.effort === "max")) { + levels.push(CODEX_REASONING_LEVELS.find(level => level.effort === "max") + ?? { effort: "max", description: "Maximum reasoning depth for the hardest problems" }); + e.supported_reasoning_levels = levels; + } + } + if (wsEnabled) e.supports_websockets = true; + else { + delete e.supports_websockets; + // Match buildCatalogEntries: never advertise a websocket preference while WS is off. + delete e.prefer_websockets; + } + return e; + }); + // Native enable/disable runs as the LAST pass so the upstream-upgrade branch above can never + // clobber a hide flag back to list. Bare ids disable every account clone; qualified ids disable + // only their generated account row. + const versionedEntries = applyMultiAgentMode( + applyNativeVisibility(mergedEntries, disabledModels, alignedAccountBoundEntries.length > 0, observedNativeSlugs), + multiAgentMode, + multiAgentV2Enabled, + { keepNativeChatGptOnV1, preserveDefaultMultiAgentVersion: isReserveCatalogProjection }, + ); + applyFullModelPickerOrder(versionedEntries, modelPickerOrder); + for (const entry of versionedEntries) { + // Templates and account clones must not inherit the native row's overlay marker. + delete entry.opencodex_native_display_name; + const slug = recoverableNativeSlug(entry); + if (slug !== null) { + const label = nativeDisplayNames && Object.hasOwn(nativeDisplayNames, slug) + ? nativeDisplayNames[slug]?.trim() : undefined; + if (label && label !== entry.display_name) { + entry.opencodex_native_display_name = { slug, original: entry.display_name, applied: label }; + entry.display_name = label; + } + } + const kind = entry.opencodex_catalog_kind; + if (trustedAccountBoundNativeCatalogSlug(entry) === undefined + && kind !== CODEX_CUSTOM_MODEL_CATALOG_KIND + && kind !== CODEX_PROVIDER_MODEL_CATALOG_KIND) continue; + // Canonicalize extension-field order after every normalizer. This keeps an unchanged catalog + // byte-idempotent whether an owned row was freshly built or retained from the prior pass. + delete entry.opencodex_catalog_kind; + entry.opencodex_catalog_kind = kind; + } + return versionedEntries; +} + +/** Merge retained-sync rows using the process-observed Codex feature state. */ +export function mergeCatalogEntriesForSync( + catalogModels: RawEntry[], + routedEntries: RawEntry[], + baseline: Map, + featured: string[], + wsEnabled: boolean, + _goIds: Set = new Set(), + template: RawEntry | null = null, + disabledModels: ReadonlySet = new Set(), + gatheredProviderNames?: Set, + multiAgentMode: MultiAgentMode = "default", + exactComboSlugs: ReadonlySet = new Set(), + hasPhysicalComboProvider = false, + includeNativeOpenAi = true, + accountBoundEntries: readonly RawEntry[] = [], + legacyCustomModelSlugs: ReadonlySet = new Set(), + suppressedBareNativeSlugs: ReadonlySet = new Set( + routedEntries.flatMap(entry => ( + isNativeAliasCatalogEntry(entry) && typeof entry.slug === "string" ? [entry.slug] : [] + )), + ), + openaiContextCap?: NativeContextLimitsInput, + keepNativeChatGptOnV1 = false, +): RawEntry[] { + // Retained for source compatibility with the original helper contract. Raw provider ids must + // not suppress same-named native rows; actual admitted combo entries own that decision now. + void _goIds; + const effectiveGatheredProviderNames = gatheredProviderNames ?? new Set( + routedEntries.flatMap(entry => { + // A slashed combo alias is not evidence that its public prefix is an authoritative provider + // namespace. Treating it as one would let the combo replace an unrestorable foreign row. + if (isExactComboCatalogEntry(entry, exactComboSlugs)) return []; + const slug = typeof entry.slug === "string" ? entry.slug : ""; + const slash = slug.indexOf("/"); + return slash > 0 ? [slug.slice(0, slash)] : []; + }), + ); + return mergeCatalogEntriesFromObservedState({ + catalogModels, + baselineCatalogModels: [], + routedEntries, + baseline, + featured, + wsEnabled, + template, + disabledModels, + selectedModelsByProvider: new Map(), + gatheredProviderNames: effectiveGatheredProviderNames, + degradedProviderNames: new Set(), + legacyCustomModelSlugs, + multiAgentMode, + multiAgentV2Enabled: isMultiAgentV2Enabled(), + keepNativeChatGptOnV1, + exactComboSlugs, + hasPhysicalComboProvider, + includeNativeOpenAi, + accountBoundEntries, + suppressedBareNativeSlugs, + openaiContextCap, + policy: { + ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, + warningPolicy: "emit", + }, + }); +} diff --git a/src/codex/catalog/derive-entry.ts b/src/codex/catalog/derive-entry.ts new file mode 100644 index 0000000000..8b1d5290c3 --- /dev/null +++ b/src/codex/catalog/derive-entry.ts @@ -0,0 +1,229 @@ +import type { OcxConfig } from "../../types"; +import { effectiveProviderAlias } from "../../providers/default-aliases"; +import { identifyRoutedModel } from "../../adapters/identity"; +import { COMBO_NAMESPACE } from "../../combos"; +import { + CODEX_CUSTOM_MODEL_CATALOG_KIND, + applyCatalogMetadata, + applyNativeOpenAiContextOverride, + applyRoutedCodexToolMode, + catalogModelSlug, + ensureStrictCatalogFields, + normalizeRoutedCatalogEntry, + normalizeServiceTiers, +} from "./parsing"; +import type { CatalogModel, RawEntry } from "./parsing"; +import { + hasNativeOpenAiCapabilityMetadata, + upstreamNativeEntry, + type NativeContextLimitsInput, +} from "./metadata"; +import { + applyCatalogModelMetadata, + applyReasoningLevels, + ensureGpt56ReasoningLevels, + ensureUltraReasoningLevel, + isGpt56NativeSlug, +} from "./effort"; +import { CATALOG_INACTIVE_REASON_FIELD, SPAWN_PRIORITY_FIELD } from "./subagent-roster"; + +export function finishUpstreamNativeEntry(clone: RawEntry, priority: number, contextCap?: NativeContextLimitsInput): RawEntry { + if (priority !== 9) clone.priority = priority; + applyNativeOpenAiContextOverride(clone, contextCap); + // GPT-5.6 natives keep their exact upstream ladders (e.g. luna has max but no ultra). + // Older natives (gpt-5.5) get mock max + ultra + // (wire-clamped to xhigh). Ultra is always advertised regardless of v2 toggle. + if (!isGpt56NativeSlug(String(clone.slug ?? ""))) ensureUltraReasoningLevel(clone); + return ensureStrictCatalogFields(normalizeServiceTiers(clone)); +} + +export function isExactComboCatalogModel( + model: CatalogModel | undefined, + exactComboSlugs: ReadonlySet, +): boolean { + return model?.provider === COMBO_NAMESPACE && exactComboSlugs.has(catalogModelSlug(model)); +} + +export function isExactComboCatalogEntry( + entry: RawEntry, + exactComboSlugs: ReadonlySet, +): boolean { + return entry.owned_by === COMBO_NAMESPACE + && typeof entry.slug === "string" + && exactComboSlugs.has(entry.slug); +} + +/** + * Friendly Codex-picker label for a routed `provider/model` slug. Command Code's two config + * ids differ by a single dash (`command-code` vs `commandcode`), so relabel them to the + * lowercase-dash style the opencode presets use: `commandcode-auth/x` and `commandcode-api/x`. + * The model-id portion also carries a redundant `-` prefix (`deepseek-deepseek-v4-flash`) + * that is dropped for display. Google Antigravity is relabeled to the compact `agy/` prefix for + * the same reason: `google-antigravity/` alone consumes most of the picker row. That prefix comes + * from the row's own `providerAlias`, decided once per gather flight; `null` means a cross-provider + * collision suppressed it and the canonical slug stands. This is the raw-slug path only -- a + * configured `modelAliases` entry is labeled by the effective-alias path in + * catalog/provider-fetch.ts (#2960) and keeps the canonical provider name. All other providers + * keep the raw slug exactly as before. + */ +function routedDisplayName(slug: string, model?: CatalogModel, config?: Pick): string { + const slash = slug.indexOf("/"); + if (slash <= 0) return slug; + const provider = slug.slice(0, slash); + let modelId = slug.slice(slash + 1); + if (provider === "google-antigravity") { + if (model?.providerAlias === null) return slug; + const alias = (typeof model?.providerAlias === "string" && model.providerAlias.trim().length > 0) + ? model.providerAlias.trim() + : effectiveProviderAlias(provider, undefined, config); + return alias ? `${alias}/${modelId}` : slug; + } + if (provider === "command-code" || provider === "commandcode") { + const m = modelId.match(/^([a-z0-9]+)-([a-z0-9]+(?:-[a-z0-9]+)+)$/i); + if (m && modelId.startsWith(`${m[1]}-${m[1]}-`)) modelId = modelId.slice(m[1]!.length + 1); + return `${provider === "command-code" ? "commandcode-auth" : "commandcode-api"}/${modelId}`; + } + return slug; +} + +function preservePinnedNativeCustomReasoning(model?: CatalogModel): boolean { + return model !== undefined + && model.catalogKind === CODEX_CUSTOM_MODEL_CATALOG_KIND + && hasNativeOpenAiCapabilityMetadata(model.id) + && Array.isArray(model.reasoningEfforts); +} + +/** + * Cria uma entrada nativa ou roteada a partir do snapshot upstream, de um clone + * do template ou de campos mínimos. Aplica os metadados e limites pertinentes + * sem alterar o template nem herdar sua marca de nome ou histórico de prioridade. + */ +export function deriveEntry( + template: RawEntry | null, + slug: string, + desc: string, + priority: number, + model?: CatalogModel, + exactComboSlugs: ReadonlySet = new Set(), + contextCap?: NativeContextLimitsInput, +): RawEntry { + const preserveExact = isExactComboCatalogModel(model, exactComboSlugs); + // Go exposes model-specific upstream enums; synthetic tiers mislead subagent overrides. + const preserveExactReasoning = preserveExact || model?.provider === "opencode-go"; + const codexForwardNativeCapabilityAlias = model?.codexForwardNativeCapabilityAlias === true + ? upstreamNativeEntry(model.id) + : null; + const isRouted = model !== undefined; + if (!isRouted && !slug.includes("/")) { + // Supported native slug covered by the upstream snapshot: use the REAL entry (exact + // reasoning ladder — e.g. luna has no ultra — default effort, identity, model_messages) + // instead of cloning an older template. + const upstream = upstreamNativeEntry(slug); + if (upstream) return finishUpstreamNativeEntry(upstream, priority, contextCap); + } + if (template || codexForwardNativeCapabilityAlias) { + const e = JSON.parse(JSON.stringify(codexForwardNativeCapabilityAlias ?? template)) as RawEntry; + delete e.opencodex_native_display_name; + // A cached template may carry display-order history; each new row owns its natural rank. + delete e[SPAWN_PRIORITY_FIELD]; + e.slug = slug; + e.display_name = routedDisplayName(slug, model); + e.description = desc; + e.priority = priority; + e.visibility = "list"; + if ("upgrade" in e) e.upgrade = null; + delete e.availability_nux; // don't replay another model's "now available" NUX + // Routed (namespaced) models inherit the gpt template — correct its OpenAI/GPT identity + // and advertise the reasoning ladder Codex accepts. + if (isRouted) { + // A routed model is NOT the native template: never inherit its context + // window when /models omits context metadata (#992). Known metadata + // restores exact values below; an enabled Context cap fills the gap; + // otherwise the strict-fields fallback supplies the 128k triple. + if (!codexForwardNativeCapabilityAlias) { + delete e.context_window; + delete e.max_context_window; + delete e.auto_compact_token_limit; + } + // Native id for identity text + metadata lookups — the slug may be an encoded + // alias (`provider/vendor-model`); the model object carries the native id. + const modelName = model?.id ?? slug.slice(slug.indexOf("/") + 1); + if (typeof e.base_instructions === "string") { + // Proxy-neutral: keep the GPT-5/OpenAI disclaimer but never advertise the opencodex proxy + // (leaking that into base_instructions is a non-first-party signature → ToS risk). + e.base_instructions = identifyRoutedModel(e.base_instructions, modelName); + } + applyReasoningLevels( + e, + model?.reasoningEfforts, + model?.defaultReasoningEffort, + preserveExactReasoning + || codexForwardNativeCapabilityAlias !== null + || preservePinnedNativeCustomReasoning(model), + ); + // This exact provider/model pair is the ChatGPT/Codex forward surface. Keep the pinned + // native tool/search/responses-lite contract while preserving the routed slug and wire id. + if (!codexForwardNativeCapabilityAlias) { + normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true, model?.codexToolMode); + } else if (model?.codexToolMode !== undefined) { + applyRoutedCodexToolMode(e, model.codexToolMode); + } + if (model) applyCatalogMetadata(e, model.provider, model.id, model.contextCap); + applyCatalogModelMetadata(e, model); + if (model?.catalogKind) e.opencodex_catalog_kind = model.catalogKind; + // Additive only. `visibility` is untouched: an inactive row must still be OFFERED, which is + // the whole point of #1711 — operator disable is what removes rows, and it stays a separate + // path from this one. + if (model?.quotaInactiveReason) e[CATALOG_INACTIVE_REASON_FIELD] = model.quotaInactiveReason; + } else { + applyNativeOpenAiContextOverride(e, contextCap); + if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(e); + else ensureUltraReasoningLevel(e); + // Older natives do not support Responses Lite. A newer template must not enable + // reasoning.context or WebSockets on those models. + if (!isGpt56NativeSlug(slug)) { + delete e.use_responses_lite; + delete e.supports_websockets; + } + } + return ensureStrictCatalogFields(normalizeServiceTiers(e), { + preserveExactInputModalities: preserveExact, + isRouted, + }); + } + // Fallback when no template is available (best-effort; strict parser may need more). + // Routed fallbacks default to code-mode tool exposure (or shell mode when codexToolMode === "shell"); + // otherwise the nested catalog expands into `exec.description` and can exceed Cursor's 120 KB serialized tool limit (#1830). + // Cursor still omits hosted web-search metadata because runTurn bypasses that separate sidecar. + const isCursorFallback = isRouted && model?.provider === "cursor"; + const entry: RawEntry = { + slug, display_name: routedDisplayName(slug, model), description: desc, + shell_type: "unified_exec", visibility: "list", supported_in_api: true, + priority, base_instructions: "You are a helpful coding assistant.", + ...(isRouted + ? isCursorFallback + ? { supports_search_tool: true } + : { web_search_tool_type: "text_and_image", supports_search_tool: true } + : {}), + }; + if (isRouted) { + applyRoutedCodexToolMode(entry, model?.codexToolMode); + applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExactReasoning || preservePinnedNativeCustomReasoning(model)); + } + else { + applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]); + if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(entry); + } + if (model && isRouted) applyCatalogMetadata(entry, model.provider, model.id, model.contextCap); + applyCatalogModelMetadata(entry, model); + if (model?.catalogKind) entry.opencodex_catalog_kind = model.catalogKind; + // Same additive stamp as the templated path above. A routed row that reaches the no-template + // fallback is still a served row, so omitting it here would make the field depend on whether a + // template happened to be cached — which is exactly what the regression test caught. + if (model?.quotaInactiveReason) entry[CATALOG_INACTIVE_REASON_FIELD] = model.quotaInactiveReason; + if (!isRouted) applyNativeOpenAiContextOverride(entry, contextCap); + return ensureStrictCatalogFields(normalizeServiceTiers(entry), { + preserveExactInputModalities: preserveExact, + isRouted, + }); +} diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index e3909263c0..0e5901c2a4 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -39,7 +39,6 @@ import { nativeOpenAiCapabilitySourceSlug, SELF_DESCRIBED_NATIVE_OPENAI_MODELS, import { isReserveCatalogProjection } from "./reserve"; import { loadBundledCodexCatalog } from "./bundled"; import type { BundledCatalogDeps, ReadonlyRawCatalog } from "./bundled"; -import { deriveEntry } from "./sync"; import { formatClampLogLines, formatRuntimeLogLine, diff --git a/src/codex/catalog/gated-native-warn.ts b/src/codex/catalog/gated-native-warn.ts new file mode 100644 index 0000000000..cc12834e54 --- /dev/null +++ b/src/codex/catalog/gated-native-warn.ts @@ -0,0 +1,63 @@ +import type { OcxConfig } from "../../types"; +import { codexModelEntitlementStateForAccount, type CodexModelEntitlementSnapshot } from "../model-entitlements"; +import { codexAccountLogLabel, fallbackCodexAccountLogLabel } from "../account-label"; +import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; + +export function gatedNativeReauthSuppressionReason(args: { + snapshot: CodexModelEntitlementSnapshot; + slug: string; + eligibleAccountIds?: ReadonlySet; + needsReauth: (accountId: string) => boolean; + label: (accountId: string) => string; +}): string | undefined { + const observed = [...args.snapshot.modelsByAccount.keys()] + .filter(accountId => !args.eligibleAccountIds || args.eligibleAccountIds.has(accountId)) + // Only accounts that could actually have served THIS model. An account upstream positively + // denied is not why the model is missing, and blaming it would send the operator to repair a + // credential that was never going to help. `unknown` has to stay in: an account whose roster + // could not be confirmed reports `unknown` rather than `granted`, and a credential stuck on + // a failed refresh is exactly that account. + .filter(accountId => ( + codexModelEntitlementStateForAccount(args.snapshot, accountId, args.slug) !== "denied" + )); + const stuck = observed.filter(accountId => args.needsReauth(accountId)); + if (stuck.length === 0) return undefined; + const names = stuck.map(accountId => args.label(accountId)).sort().join(", "); + return stuck.length === observed.length + ? `every Codex account that could serve it needs reauthentication (${names})` + : `${stuck.length} of ${observed.length} Codex accounts that could serve it need reauthentication (${names})`; +} + +/** Durable, operator-facing label for a pool account id; never the raw id or the email. */ +export function gatedNativeAccountLabel(config: OcxConfig, accountId: string): string { + // Direct mode narrows eligibility to the native main credential, so this is the account most + // likely to be named here. `codexAuthContextLogLabel` calls it "main" everywhere else; hashing + // it into a `p`-prefixed digest would name the one account the operator cannot look up. + if (accountId === MAIN_CODEX_ACCOUNT_ID) return "main"; + const account = (config.codexAccounts ?? []).find(candidate => candidate.id === accountId); + return account ? codexAccountLogLabel(account) : fallbackCodexAccountLogLabel(accountId); +} + +const warnedGatedNativeSuppression = new Set(); + +/** Test seam: the warn-once memory is process-global, so a case needs to be able to clear it. */ +export function resetGatedNativeSuppressionWarningsForTests(): void { + warnedGatedNativeSuppression.clear(); +} + +export function warnGatedNativeSuppressedOnce(slug: string, reason: string): void { + const signature = `${slug}\u0000${reason}`; + if (warnedGatedNativeSuppression.has(signature)) return; + warnedGatedNativeSuppression.add(signature); + console.warn( + `[opencodex] catalog sync: ${slug} is not being offered because ${reason}. ` + + "Sign in again to restore it.", + ); +} + +/** + * Mescla o catálogo retido com os modelos visíveis e as configurações atuais, + * incluindo os nomes nativos. Tenta preservar o backup original e usa a permissão + * de escrita para publicar o resultado apenas se os bytes mudarem, retornando + * a contagem de entradas roteadas e por conta, o caminho e o estado da gravação. + */ diff --git a/src/codex/catalog/restore.ts b/src/codex/catalog/restore.ts new file mode 100644 index 0000000000..5393d58410 --- /dev/null +++ b/src/codex/catalog/restore.ts @@ -0,0 +1,132 @@ +import { readConfigDiagnostics } from "../../config"; +import { getCodexHome } from "../paths"; +import { readCatalog, readCatalogBackup, readCodexCatalogPath } from "./parsing"; +import type { RawEntry } from "./parsing"; +import { RETIRED_NATIVE_OPENAI_MODELS, SUPPORTED_NATIVE_OPENAI_SLUGS } from "./metadata"; +import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; +import { + withCatalogWriteSerialization, + type CatalogWritePermit, +} from "../catalog-write-serialization"; +import { replaceActiveCodexCatalog } from "../internal/catalog-writer"; + +function visibleAccountReplacementNatives( + models: readonly RawEntry[], + disabledModels: ReadonlySet | null, +): Map { + const replacements = new Map(); + for (const entry of models) { + const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry); + if (nativeSlug === undefined || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug)) continue; + const exactSlug = typeof entry.slug === "string" ? entry.slug : ""; + const visible = entry.visibility === "list" + || (disabledModels !== null + && (disabledModels.has(nativeSlug) || disabledModels.has(exactSlug))); + replacements.set(nativeSlug, (replacements.get(nativeSlug) ?? true) && visible); + } + return replacements; +} + +function restoreAccountHiddenBareNatives( + entries: readonly RawEntry[], + replacementVisibility: ReadonlyMap, + disabledModels: ReadonlySet | null, +): RawEntry[] { + return entries.map(entry => { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + if ( + entry.visibility !== "hide" + || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug) + || replacementVisibility.get(slug) !== true + || disabledModels === null + || disabledModels.has(slug) + ) { + return entry; + } + return { ...entry, visibility: "list" }; + }); +} + +function currentDisabledModelsForRestore(): Set | null { + try { + const diagnostics = readConfigDiagnostics(); + if (diagnostics.source === "fallback" || diagnostics.error !== null) return null; + return new Set(diagnostics.config.disabledModels ?? []); + } catch { + // An unreadable config cannot safely authorize a visibility change during restore. + return null; + } +} + +export function restoreCodexCatalogWithPermit( + permit: CatalogWritePermit, + owningCodexHome: string, + /** + * The catalog this injection actually wrote, when it is known (#1798). + * + * Re-resolving from the CURRENT config is wrong after a Codex app rewrite that dropped + * `model_catalog_json`: that sends restore to the default catalog while the routed file we + * really wrote is left untouched. The recorded path is the file whose routing is ours. + */ + injectedCatalogPath?: string | null, +): { removed: number; kept: number; path: string } { + const catalogPath = injectedCatalogPath ?? readCodexCatalogPath(); + const catalog = readCatalog(catalogPath); + if (!catalog || !Array.isArray(catalog.models)) return { removed: 0, kept: 0, path: catalogPath }; + const disabledModels = currentDisabledModelsForRestore(); + const replacementVisibility = visibleAccountReplacementNatives(catalog.models, disabledModels); + const backup = readCatalogBackup(catalogPath); + if (backup && Array.isArray(backup.models)) { + const removed = (catalog.models ?? []).filter(m => typeof m.slug === "string" + && (m.slug.includes("/") || RETIRED_NATIVE_OPENAI_MODELS.has(m.slug))).length; + const backupSlugs = new Set(backup.models.flatMap(m => typeof m.slug === "string" ? [m.slug] : [])); + const userNativeAdditions = restoreAccountHiddenBareNatives( + (catalog.models ?? []).filter(m => + typeof m.slug === "string" && !m.slug.includes("/") && !backupSlugs.has(m.slug) + && !RETIRED_NATIVE_OPENAI_MODELS.has(m.slug) + ), + replacementVisibility, + disabledModels, + ); + const restored = { + ...backup, + // A pristine backup predates retirement; it must not revive withdrawn native rows. + models: [...backup.models.filter(m => typeof m.slug !== "string" + || !RETIRED_NATIVE_OPENAI_MODELS.has(trustedAccountBoundNativeCatalogSlug(m) ?? m.slug)), ...userNativeAdditions], + }; + replaceActiveCodexCatalog(permit, owningCodexHome, { + path: catalogPath, + content: `${JSON.stringify(restored, null, 2)}\n`, + }); + return { removed, kept: restored.models.length, path: catalogPath }; + } + const before = catalog.models.length; + const native = restoreAccountHiddenBareNatives( + catalog.models.filter(m => !(typeof m.slug === "string" + && (m.slug.includes("/") || RETIRED_NATIVE_OPENAI_MODELS.has(m.slug)))), + replacementVisibility, + disabledModels, + ); + const removed = before - native.length; + if (removed > 0) { + catalog.models = native; + replaceActiveCodexCatalog(permit, owningCodexHome, { + path: catalogPath, + content: `${JSON.stringify(catalog, null, 2)}\n`, + }); + } + return { removed, kept: native.length, path: catalogPath }; +} + +export function restoreCodexCatalog(): { removed: number; kept: number; path: string } { + const owningCodexHome = getCodexHome(); + const outcome = withCatalogWriteSerialization( + owningCodexHome, + permit => restoreCodexCatalogWithPermit(permit, owningCodexHome), + ); + return outcome.kind === "completed" + ? outcome.value + : { removed: 0, kept: 0, path: readCodexCatalogPath() }; +} + +/** Force Codex's models_cache stale from the on-disk catalog. Returns whether a cache write occurred. */ diff --git a/src/codex/catalog/retained-sync.ts b/src/codex/catalog/retained-sync.ts new file mode 100644 index 0000000000..21daf32714 --- /dev/null +++ b/src/codex/catalog/retained-sync.ts @@ -0,0 +1,706 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { loadConfig, websocketsEnabled } from "../../config"; +import { shouldSyncCodexOnStart } from "../desired-state"; +import { legacyCustomModelCatalogSlugs } from "../custom-model-catalog-migration"; +import { getCodexHome } from "../paths"; +import type { OcxConfig } from "../../types"; +import { pendingModelSelectionProviders } from "../../providers/initial-model-selection"; +import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; +import { providerCodexAccountMode } from "../../providers/registry"; +import { COMBO_NAMESPACE } from "../../combos"; +import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../account-namespaces"; +import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; +import { + availableAccountGatedNativeModels, + codexModelEntitlementStateForAccount, + isCodexModelEntitlementSnapshotCurrent, + resolveCodexModelEntitlements, + type CodexModelEntitlementSnapshot, +} from "../model-entitlements"; +import { isAccountNeedsReauth } from "../account-runtime-state"; +import { codexRuntimeStatePath } from "../runtime"; +import { + activeCodexModelsCachePath, + catalogBackupPathFor, + catalogHasRoutedEntries, + findNativeTemplate, + findSupportedNativeTemplate, + isDefaultCatalogPath, + legacyCatalogBackupPath, + readCatalog, + readCatalogBackup, + readCodexCatalogPath, + readCodexCatalogPathForHome, + readNativeBaseline, +} from "./parsing"; +import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing"; +import { + accountBoundNativeOpenAiSlugsBySelector, + desktopAllowlistSuppressedNativeSlugs, + disabledNativeSlugs, + nativeContextLimits, + observedAccountBoundNativeEntries, + observedReserveCatalogSource, + shouldIncludeAccountBoundNativeOpenAi, + shouldIncludeNativeOpenAi, + upstreamNativeEntry, +} from "./metadata"; +import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; +import { bundledCatalogCacheState, loadBundledCodexCatalog } from "./bundled"; +import { isMultiAgentV2Enabled } from "../features"; +import { clampCatalogModelsToCodexSupport } from "./effort"; +import { filterCatalogVisibleModels, gatherRoutedModels, type CatalogGatherProviderModelOutcome } from "./provider-fetch"; +import { exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation"; +import { + withCatalogWriteSerialization, + type CatalogWritePermit, +} from "../catalog-write-serialization"; +import { + publishHashedCodexCatalogBackup, + publishLegacyCodexCatalogBackup, + replaceActiveCodexCatalog, + replaceCodexModelsCache, +} from "../internal/catalog-writer"; +import { visibleCodexAccountSelectors } from "./account-models"; +import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "./native-models"; +import { createReserveCatalogProjection, RESERVE_LUNA_METADATA_SOURCE, RESERVE_SOURCE_CATALOG_FIELD } from "./reserve"; +import { + CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, + buildCatalogEntriesFromObservedState, + mergeCatalogEntriesFromObservedState, + mergeCatalogModelsWithNativeRecovery, + orderForSubagents, +} from "./build-entries"; +import { finishUpstreamNativeEntry } from "./derive-entry"; +import { finalizeAutoReviewModelOverride } from "./auto-review"; +import { gatedNativeAccountLabel, gatedNativeReauthSuppressionReason, warnGatedNativeSuppressedOnce } from "./gated-native-warn"; + +interface RetainedCatalogSyncRead { + readonly catalogPath: string; + readonly catalog: RawCatalog; + readonly onDiskCatalog: RawCatalog | null; + readonly modelsCache: RawCatalog | null; + readonly evidence: string; + /** + * Process-local epochs, baselined AFTER our own gather rather than with the + * filesystem bytes above. See `retainedCatalogProcessEvidence`. + */ + readonly processEvidence: string; +} + +interface RetainedCatalogSyncResult { + added: number; + path: string; + catalogWritten: boolean; + comboOmissions: ComboCatalogOmission[]; + /** Validated catalog commit (including identical bytes), or a refused refresh. */ + refreshOutcome?: "committed" | "refused"; + /** `desired_disabled` observed under K after the provider await; nothing was written. */ + skippedReason?: "desired_disabled"; +} + +/** + * Catalog/cache commit overrides. + * + * An explicit `ocx sync` is also the refresh path for side profiles that consume + * the OpenCodex catalog without injection (for example a custom `model_provider` + * that routes to the proxy). In that mode the Codex integration toggle only + * governs config/history injection; the catalog and models cache may still be + * refreshed, so `allowWhenDesiredDisabled` lets the commit path ignore the OFF + * gate that otherwise protects a fully native home. + */ +export interface CodexCatalogSyncOptions { + allowWhenDesiredDisabled?: boolean; +} + +interface RetainedCatalogSyncWrite { + readonly config: OcxConfig; + readonly goModels: CatalogModel[]; + readonly providerModelOutcomes: readonly CatalogGatherProviderModelOutcome[]; + readonly comboOmissions: ComboCatalogOmission[]; + readonly read: RetainedCatalogSyncRead; + readonly permit: CatalogWritePermit; + readonly owningCodexHome: string; + readonly modelEntitlements: CodexModelEntitlementSnapshot; +} + +function optionalFileBytes(path: string): string | null { + try { + return readFileSync(path).toString("base64"); + } catch (error) { + if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return null; + throw error; + } +} + +function loadCatalogForRetainedSync(path: string): RawCatalog | null { + const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null; + if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog; + const active = readCatalog(path); + // A valid configured custom file remains the content authority even when it has no bare native + // template. The null-template builder is deliberate; a stale backup must not replace active + // custom root metadata merely because the current file contains only routed rows. + if (active && (!isDefaultCatalogPath(path) || findNativeTemplate(active))) return active; + return readCatalog(catalogBackupPathFor(path)) + ?? (isDefaultCatalogPath(path) ? readCatalog(legacyCatalogBackupPath()) : null) + ?? readCatalog(activeCodexModelsCachePath()) + ?? active; +} + +function retainedCatalogSyncEvidence( + config: OcxConfig, + catalogPath: string, + catalog: RawCatalog, +): string { + return JSON.stringify({ + config, + catalogPath, + catalog, + catalogBytes: optionalFileBytes(catalogPath), + hashedBackupBytes: optionalFileBytes(catalogBackupPathFor(catalogPath)), + legacyBackupBytes: isDefaultCatalogPath(catalogPath) + ? optionalFileBytes(legacyCatalogBackupPath()) : null, + modelsCacheBytes: optionalFileBytes(activeCodexModelsCachePath()), + // The persisted runtime selection is a pre-await filesystem input, not a + // process epoch: another PROCESS can move runtime authority by rewriting this + // file, and that move is invisible to our in-process memo. Recorded PRESENT or + // ABSENT, because its absence is what makes the resolver fall back. + runtimeStateBytes: optionalFileBytes(codexRuntimeStatePath()), + }); +} + +/** + * The bundled-template half of the same evidence, observed separately. + * + * The runtime process memo is deliberately NOT here, and that exclusion took three + * attempts to get honest. Gathering resolves the Codex runtime lazily and under its + * own cache key, so this path cannot pre-settle that memo: baselining it before the + * await always detected our own side effect and refused every write, and baselining + * it after the await captured a runtime that ANOTHER process had moved as though it + * were ours — a catalog prepared from R1 committing after authority reached R2. + * + * Runtime authority is covered where it is actually durable instead: the persisted + * `codex-runtime.json` bytes sit in the pre-await filesystem evidence, PRESENT or + * ABSENT, so a cross-process runtime move is caught. What is left uncovered, and is + * written down rather than papered over, is a same-process in-memory runtime swap + * that never touches that file — WP11 owns the lock that makes that case decidable. + */ +function retainedCatalogProcessEvidence(): string { + return JSON.stringify({ + bundledCatalogCache: bundledCatalogCacheState(), + }); +} + +/** + * Capture every local catalog input the retained sync path consults before its + * provider await. The exact evidence is compared after K acquisition; a newer + * catalog/backup/cache or target selection makes this attempt a no-write. + */ +function readRetainedCatalogSync(config: OcxConfig): RetainedCatalogSyncRead | null { + const catalogPath = readCodexCatalogPath(); + const catalog = loadCatalogForRetainedSync(catalogPath); + if (!catalog) return null; + + // The bundled catalog is a reliable native template on the default path, but it is not the + // merge source. Preservation must inspect the file that this sync is about to overwrite; + // otherwise an empty/partial provider gather cannot see routed or user-native rows on disk. + const onDiskCatalog = readCatalog(catalogPath); + const modelsCache = readCatalog(activeCodexModelsCachePath()); + const evidence = retainedCatalogSyncEvidence(config, catalogPath, catalog); + // `processEvidence` is filled in after the provider await, not here. + return { catalogPath, catalog, onDiskCatalog, modelsCache, evidence, processEvidence: "" }; +} + +function revalidateRetainedCatalogSync( + config: OcxConfig, + prepared: RetainedCatalogSyncRead, +): RetainedCatalogSyncRead | null { + const catalogPath = readCodexCatalogPath(); + if (catalogPath !== prepared.catalogPath) return null; + const evidence = retainedCatalogSyncEvidence(config, catalogPath, prepared.catalog); + if (evidence !== prepared.evidence) return null; + if (retainedCatalogProcessEvidence() !== prepared.processEvidence) return null; + return { + catalogPath, + catalog: JSON.parse(JSON.stringify(prepared.catalog)) as RawCatalog, + onDiskCatalog: readCatalog(catalogPath), + modelsCache: readCatalog(activeCodexModelsCachePath()), + evidence, + processEvidence: prepared.processEvidence, + }; +} + +/** + * Exact bytes currently on disk at `path`, or null when unreadable/absent. + * + * Deliberately a Buffer rather than a decoded string: `readFileSync(path, "utf8")` + * substitutes U+FFFD for every invalid byte, so a file holding a raw 0x80 decodes + * equal to prepared content holding a legitimately encoded U+FFFD. Comparing the + * decoded strings would then classify a malformed catalog as identical, skip the + * atomic repair write, and leave the corruption on disk while reporting + * `catalogWritten: false`. + */ +function currentCatalogFileContent(path: string): Buffer | null { + try { + return readFileSync(path); + } catch { + return null; + } +} + +function pristineCatalogBytes(read: RetainedCatalogSyncRead): string | null { + if (read.onDiskCatalog && !catalogHasRoutedEntries(read.onDiskCatalog)) { + try { + return readFileSync(read.catalogPath, "utf8"); + } catch { + return null; + } + } + return catalogHasRoutedEntries(read.catalog) + ? null + : `${JSON.stringify(read.catalog, null, 2)}\n`; +} + +function catalogModelsForMergeWithNativeRecovery( + catalogPath: string, + catalog: RawCatalog, + onDiskCatalog: RawCatalog | null, +): RawEntry[] { + const primaryCatalogModels = onDiskCatalog?.models ?? catalog.models ?? []; + // Native-alias compatibility can omit disabled native rows from the effective catalog because + // Desktop's remote allowlist ignores `visibility: "hide"`. Keep current/pristine native recovery + // sources beside the on-disk rows so re-enabling a model restores its real metadata. Routed and + // user-authored rows still come only from the on-disk catalog. + return mergeCatalogModelsWithNativeRecovery(primaryCatalogModels, [ + catalog.models ?? [], + readCatalogBackup(catalogPath)?.models ?? [], + ]); +} + +function writeRetainedCatalogSync({ + config, + goModels, + providerModelOutcomes, + comboOmissions, + read, + permit, + owningCodexHome, + modelEntitlements, +}: RetainedCatalogSyncWrite): RetainedCatalogSyncResult { + const { catalogPath, catalog, onDiskCatalog } = read; + const catalogModelsForMerge = catalogModelsForMergeWithNativeRecovery( + catalogPath, + catalog, + onDiskCatalog, + ); + // Strict selector for template inheritance; the validity gate above keeps the broad one. + const template = findSupportedNativeTemplate(catalog); + + try { + // Once-only: preserve the PRISTINE pre-opencodex catalog as the native-priority baseline + // (later syncs would otherwise overwrite it with featured-modified priorities). + const pristine = pristineCatalogBytes(read); + if (pristine !== null) { + publishHashedCodexCatalogBackup(permit, owningCodexHome, { + path: catalogBackupPathFor(catalogPath), + content: pristine, + }); + if (isDefaultCatalogPath(catalogPath)) { + publishLegacyCodexCatalogBackup(permit, owningCodexHome, { + path: legacyCatalogBackupPath(), + content: pristine, + }); + } + } + } catch { /* backup best-effort */ } + + // Hide disabled models from Codex, then feature the chosen subagent models (native OR routed) + // by giving them the lowest priority — see buildCatalogEntries for why priority, not array order. + const enabledGo = filterCatalogVisibleModels(goModels, config); + const featured = config.subagentModels ?? []; + const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities + const modelPickerOrder = config.modelPickerOrder ?? []; + const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; + const exactComboSlugs = exactComboCatalogSlugs(config); + const bareEligibleAccountIds = providerCodexAccountMode( + OPENAI_CODEX_PROVIDER_ID, + config.providers[OPENAI_CODEX_PROVIDER_ID], + ) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined; + const availableBareGatedNativeSlugs = availableAccountGatedNativeModels( + modelEntitlements, + bareEligibleAccountIds, + ); + const availableAccountGatedNativeSlugs = availableAccountGatedNativeModels(modelEntitlements); + const availableBareNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug) + )); + const availableAccountNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableAccountGatedNativeSlugs.has(slug) + )); + const unavailableGatedNativeSlugs = new Set([...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(slug => ( + !availableBareGatedNativeSlugs.has(slug) + ))); + // #4212: this set is the whole record of a model vanishing, and it is a set of strings that + // nothing downstream ever asks a question of. Explain it here, while the entitlement snapshot + // that produced it is still in scope, because after this point the model is simply absent and + // no later surface can tell "never entitled" apart from "the account broke this morning". + for (const slug of unavailableGatedNativeSlugs) { + const reason = gatedNativeReauthSuppressionReason({ + snapshot: modelEntitlements, + slug, + eligibleAccountIds: bareEligibleAccountIds, + needsReauth: isAccountNeedsReauth, + label: accountId => gatedNativeAccountLabel(config, accountId), + }); + if (reason) warnGatedNativeSuppressedOnce(slug, reason); + } + const suppressedBareNativeSlugs = new Set([ + ...desktopAllowlistSuppressedNativeSlugs(config), + ...unavailableGatedNativeSlugs, + ]); + const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE); + const includeNativeOpenAi = shouldIncludeNativeOpenAi(config); + const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config); + // Both user levers. Passing only the cap here is what let a per-model window the dashboard + // had accepted get written back at full width in the on-disk catalog. + const openaiContextCap = nativeContextLimits(config); + const accountSelectors = includeAccountBoundNativeOpenAi + ? visibleCodexAccountSelectors(config) + : []; + const observedAccountNativeEntries = [ + ...(read.modelsCache?.models ?? []), + ...(onDiskCatalog?.models ?? []).filter(entry => + trustedAccountBoundNativeCatalogSlug(entry) !== undefined), + ]; + const accountTargets = new Map(codexAccountNamespaceEntries(config)); + const reserveMainSelectors = accountSelectors.filter(selector => + isMainCodexAccountTarget(accountTargets.get(selector) ?? "")); + // The active file can own a bare source even when the bundled catalog is the build base. + // A previously clamped qualified projection must not shorten a retained genuine ladder. + const reserveObservations = [ + ...(onDiskCatalog?.models ?? []), + ...(read.modelsCache?.models ?? []), + ...(catalog.models ?? []), + ]; + const retainedReserve = onDiskCatalog?.[RESERVE_SOURCE_CATALOG_FIELD]; + const retainedReserveSource = retainedReserve && typeof retainedReserve === "object" && !Array.isArray(retainedReserve) + ? observedReserveCatalogSource([retainedReserve as RawEntry], []) + : null; + const observedReserveSource = observedReserveCatalogSource( + // Cache invalidation carries historical bare observations alongside emitted models. + // Only unmarked observations are fresh enough to supersede the retained source. + reserveObservations.filter(entry => entry.slug === NATIVE_RESERVE_MODEL + && entry.opencodex_account_observed_native === undefined), reserveMainSelectors, + ) ?? retainedReserveSource ?? observedReserveCatalogSource(reserveObservations, reserveMainSelectors); + // This root is read only by OCX. Upstream ModelsResponse ignores unknown root fields. + // Retain before final runtime clamping: an omitted row must not turn into Luna next sync. + if (observedReserveSource) catalog[RESERVE_SOURCE_CATALOG_FIELD] = structuredClone(observedReserveSource); + else delete catalog[RESERVE_SOURCE_CATALOG_FIELD]; + const lunaSource = upstreamNativeEntry(RESERVE_LUNA_METADATA_SOURCE); + const reserve = createReserveCatalogProjection( + config, + reserveMainSelectors, + observedReserveSource, + lunaSource ? finishUpstreamNativeEntry(lunaSource, 9, openaiContextCap) : null, + ); + const accountNativeSlugsBySelector = accountSelectors.length > 0 + ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)].map(([selector, slugs]) => { + const target = accountTargets.get(selector); + const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target; + return [selector, slugs.filter(slug => ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) + || (accountId !== undefined + && codexModelEntitlementStateForAccount(modelEntitlements, accountId, slug) === "granted") + ))] as const; + })) + : new Map(); + const accountNativeSlugs = accountSelectors.length > 0 + ? [...new Set([...accountNativeSlugsBySelector.values()].flatMap(slugs => [...slugs]))] + : []; + // Unknown account-native ids have no safe bare/global identity. They are only projected through + // the selector map above; the no-selector catalog remains the static native/API-key surface. + const observedNativeSlugs: string[] = []; + const wsEnabled = websocketsEnabled(config); + const multiAgentV2Enabled = isMultiAgentV2Enabled(); + const goEntries = buildCatalogEntriesFromObservedState({ + template: template ? JSON.parse(JSON.stringify(template)) : null, + gptSlugs: [], + goModels: orderedGoModels, + featured, + modelPickerOrder, + wsEnabled, + multiAgentMode, + exactComboSlugs, + accountSelectors, + suppressedBareNativeSlugs, + disabledNativeAccountSlugs: new Set(), + multiAgentV2Enabled, + openaiContextCap, + }); + // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append + // routed providers as namespaced slugs. Cursor and other adopted providers can expose model ids + // like `gpt-5.5`; those must not delete the native OpenAI/Codex base row. + const baselineCatalog = readCatalogBackup(catalogPath); + const baseline = readNativeBaseline(catalogPath); + const gatheredProviderNames = new Set( + Object.entries(config.providers ?? {}) + .filter(([, prov]) => prov.disabled !== true) + .map(([name]) => name), + ); + const degradedProviderNames = new Set( + providerModelOutcomes + .filter(outcome => outcome.state === "degraded") + .map(outcome => outcome.provider), + ); + const selectedModelsByProvider = new Map>( + Object.entries(config.providers ?? {}).flatMap(([name, provider]) => ( + provider.disabled !== true + && Array.isArray(provider.selectedModels) + && provider.selectedModels.length > 0 + ? [[name, new Set(provider.selectedModels)] as const] + : [] + )), + ); + // Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to + // native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a + // native template can never leak supports_websockets while the flag is off. + // #636: when the user only configured non-OpenAI providers (e.g. kimi), do not advertise + // bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no + // providers are configured yet (fresh install / catalog bootstrap tests). + const accountBoundEntries = includeAccountBoundNativeOpenAi && accountSelectors.length > 0 + ? buildCatalogEntriesFromObservedState({ + template: template ? JSON.parse(JSON.stringify(template)) : null, + gptSlugs: availableAccountNativeSlugs, + goModels: [], + featured, + wsEnabled, + multiAgentMode, + exactComboSlugs, + accountSelectors, + suppressedBareNativeSlugs, + disabledNativeAccountSlugs: new Set([...disabledNativeSlugs(config)].filter(slug => suppressedBareNativeSlugs.has(slug))), + multiAgentV2Enabled, + keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true, + openaiContextCap, + accountNativeSlugs, + accountNativeSlugsBySelector, + reserve, + }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) + : []; + catalog.models = mergeCatalogEntriesFromObservedState({ + modelPickerOrder, + accountSelectors, + catalogModels: catalogModelsForMerge, + baselineCatalogModels: baselineCatalog?.models ?? [], + routedEntries: goEntries, + baseline, + featured, + wsEnabled, + template, + disabledModels: new Set(config.disabledModels ?? []), + selectedModelsByProvider, + gatheredProviderNames, + pendingProviderNames: pendingModelSelectionProviders(config), + degradedProviderNames, + legacyCustomModelSlugs: legacyCustomModelCatalogSlugs(config), + multiAgentMode, + multiAgentV2Enabled, + keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true, + exactComboSlugs, + hasPhysicalComboProvider, + includeNativeOpenAi, + accountBoundEntries, + suppressedBareNativeSlugs, + openaiContextCap, + nativeDisplayNames: config.providers[OPENAI_CODEX_PROVIDER_ID]?.modelDisplayNames, + policy: { + ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, + nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs], + warningPolicy: "emit", + }, + }); + clampCatalogModelsToCodexSupport(catalog.models); + finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge, config); + + const added = goEntries.length + accountBoundEntries.length; + const content = `${JSON.stringify(catalog, null, 2)}\n`; + // A byte-identical rewrite is not a catalog change, but every mtime-keyed reader + // has to treat it as one. The app-server staleness classifier (#857) is the one + // that matters: it compares this file's mtime against each running Codex's start + // time, so an ordinary `ocx start` — or any dashboard action that re-syncs an + // unchanged model set — marked every already-running Codex as holding an outdated + // in-memory catalog. Since #1407 that verdict silences opencodex's own model + // guidance entirely (no preferred model, no roster) for the rest of that Codex's + // lifetime, so a configured injectionModel stops reaching the session even though + // nothing about the catalog changed. Skipping the no-op write keeps both the mtime + // and `catalogWritten` honest; `added` still reports the routed rows the catalog + // carries, because they are on disk either way. + const onDiskBytes = currentCatalogFileContent(catalogPath); + if (onDiskBytes !== null && onDiskBytes.equals(Buffer.from(content, "utf8"))) { + return { added, path: catalogPath, catalogWritten: false, comboOmissions }; + } + + replaceActiveCodexCatalog(permit, owningCodexHome, { + path: catalogPath, + content, + }); + return { + added, + path: catalogPath, + catalogWritten: true, + comboOmissions, + }; +} + +export async function syncCatalogModels( + config: OcxConfig, + options?: CodexCatalogSyncOptions, +): Promise { + if (pendingModelSelectionProviders(config).size) { + const { resolvePendingInitialModelSelection } = await import("../../providers/initial-model-selection-runtime"); + await resolvePendingInitialModelSelection(config); + } + const owningCodexHome = getCodexHome(); + const preflightRead = readRetainedCatalogSync(config); + if (preflightRead === null) { + return { + added: 0, + path: readCodexCatalogPath(), + catalogWritten: false, + comboOmissions: [], + refreshOutcome: "refused", + }; + } + + const comboOmissions: ComboCatalogOmission[] = []; + const providerModelOutcomes: CatalogGatherProviderModelOutcome[] = []; + // Settle the bundled template, then baseline, and only then await. Reading it + // here makes the memo ours before anyone else can move it, so a bundled swap + // during the await is an outside change rather than our own side effect. + // + // The persisted runtime selection is covered by the filesystem evidence above + // rather than by a process epoch; see `retainedCatalogProcessEvidence` for why + // the in-memory runtime memo cannot be baselined honestly from this path. + loadBundledCodexCatalog(); + const prepared: RetainedCatalogSyncRead = { + ...preflightRead, + evidence: retainedCatalogSyncEvidence(config, preflightRead.catalogPath, preflightRead.catalog), + processEvidence: retainedCatalogProcessEvidence(), + }; + const [goModels, modelEntitlements] = await Promise.all([ + gatherRoutedModels(config, { + comboOmissions, + providerModelOutcomes, + }), + resolveCodexModelEntitlements(config), + ]); + const committed = withCatalogWriteSerialization(owningCodexHome, permit => { + // Desired state can flip OFF during the provider await above. The catalog + // evidence revalidation below cannot see that — intent lives in our config, + // not in the catalog files — so the policy is re-read here, under K, right + // before the only write. A lost race becomes the discriminated skip instead + // of a routed catalog/cache surviving a completed disable. An explicit + // catalog-only sync opts out of that gate: the user asked for a refresh even + // when injection is OFF, and the toggle only protects config/history writes. + if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) { + return { + added: 0, + path: prepared.catalogPath, + catalogWritten: false, + comboOmissions, + skippedReason: "desired_disabled" as const, + }; + } + const current = revalidateRetainedCatalogSync(config, prepared); + if (current === null) return null; + if (!isCodexModelEntitlementSnapshotCurrent(modelEntitlements)) return null; + return writeRetainedCatalogSync({ + config, + goModels, + providerModelOutcomes, + comboOmissions, + read: current, + permit, + owningCodexHome, + modelEntitlements, + }); + }); + if (committed.kind === "completed" && committed.value !== null) { + return { + ...committed.value, + refreshOutcome: committed.value.skippedReason ? "refused" : "committed", + }; + } + return { + added: 0, + path: prepared.catalogPath, + catalogWritten: false, + comboOmissions, + refreshOutcome: "refused", + }; +} + +export function invalidateCodexModelsCacheWithPermit( + permit: CatalogWritePermit, + owningCodexHome: string, + options?: CodexCatalogSyncOptions, +): boolean { + try { + // This permit is a REACQUISITION: refreshCodexModelCatalog's commit released + // K before this rewrite runs, so the commit-path desired-state check cannot + // cover it. A disable landing in that gap must not be overwritten by a + // routed cache write — re-read intent under this permit, same as the commit. + // The catalog-only sync override applies here too so an explicit refresh + // keeps the cache consistent with the catalog it just wrote. + if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) return false; + const catalogPath = readCodexCatalogPathForHome(owningCodexHome); + if (!existsSync(catalogPath)) return false; + const catalog = JSON.parse(readFileSync(catalogPath, "utf8")); + const models = catalog.models ?? catalog; + const cachePath = join(owningCodexHome, "models_cache.json"); + const currentCache = readCatalog(cachePath); + const existingSlugs = new Set(models.flatMap((entry: RawEntry) => + typeof entry.slug === "string" ? [entry.slug] : [])); + const currentConfig = loadConfig(); + const mainSelectors = visibleCodexAccountSelectors(currentConfig).filter(selector => { + const target = new Map(codexAccountNamespaceEntries(currentConfig)).get(selector); + return isMainCodexAccountTarget(target ?? ""); + }); + const observedAccountModels = observedAccountBoundNativeEntries(currentCache?.models ?? []) + .filter(entry => { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + return !existingSlugs.has(slug); + }) + .map(entry => ({ + ...entry, + // Keep the observation in Codex's cache without advertising a new bare picker row. The + // next OpenCodex catalog sync consumes this marker and creates only selector-qualified + // rows for the currently configured public account selectors. + visibility: "hide", + opencodex_account_observed_native: true, + opencodex_account_observed_selectors: mainSelectors, + })); + const wrapper = { + fetched_at: "2000-01-01T00:00:00Z", + client_version: "0.0.0", + models: [...models, ...observedAccountModels], + }; + replaceCodexModelsCache(permit, owningCodexHome, { + path: cachePath, + content: `${JSON.stringify(wrapper, null, 2)}\n`, + }); + return true; + } catch { + return false; + } +} + +export function invalidateCodexModelsCache(options?: CodexCatalogSyncOptions): boolean { + const owningCodexHome = getCodexHome(); + const outcome = withCatalogWriteSerialization( + owningCodexHome, + permit => invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, options), + ); + return outcome.kind === "completed" && outcome.value; +} diff --git a/src/codex/catalog/subagent-roster.ts b/src/codex/catalog/subagent-roster.ts new file mode 100644 index 0000000000..69951d70b9 --- /dev/null +++ b/src/codex/catalog/subagent-roster.ts @@ -0,0 +1,176 @@ +// Holds INV-AGENT-01 from structure/overview.md; keep the id here if this file is split or renamed. +import { slugsEquivalent } from "../../providers/slug-codec"; +import { readCatalog, readCodexCatalogPath } from "./parsing"; +import type { RawEntry } from "./parsing"; +import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "./metadata"; +import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; +import { catalogEntryEfforts } from "./effort"; + +export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5; + +// Base for config.modelPickerOrder display priorities (#1649). modelPickerOrder is a DISPLAY-ONLY +// reordering of the Codex model picker: it rewrites a row's Codex-visible `priority` but not +// OpenCodex's natural-priority guidance window. Native Codex advertisements still follow the +// visible priority and can differ from that guidance window. +export const PICKER_ORDER_PRIORITY_BASE = 1_000; + +// OpenCodex-private catalog field: the guidance candidate priority a row would have WITHOUT +// modelPickerOrder. Codex ignores unknown catalog fields (same as opencodex_catalog_kind), so this +// is invisible to Codex; effectiveSubagentRoster reads it to keep OpenCodex guidance candidates +// independent of display order. It does not freeze native advertisements. Absent on unmoved rows. +export const SPAWN_PRIORITY_FIELD = "opencodex_spawn_priority"; + +// OpenCodex-private catalog field: this row is listed but currently unable to serve (#1711). +// Codex ignores unknown catalog fields (same as opencodex_catalog_kind and the spawn priority +// above) and ensureStrictCatalogFields does not strip extras, so this is invisible to the native +// picker and cannot change what Codex offers. It never touches `visibility`. +export const CATALOG_INACTIVE_REASON_FIELD = "opencodex_inactive_reason"; + +export type SpawnAgentSurface = "v1" | "v2"; + +export type SubagentRosterExclusionReason = + | "missing_catalog_entry" + | "picker_hidden" + | "surface_incompatible" + | "outside_display_limit"; + +/** + * Whether a catalog entry may be offered as a V2 subagent model. + * + * Upstream changed this rule in codex-rs `6d4d9442c` ("Support leaf models in + * multi-agent v2"). `model_supports_multi_agent_backend` + * (core/src/tools/handlers/multi_agents_common.rs:36-42) now admits EVERY model + * except one explicitly marked `disabled`; the older `== Some(V2)` equality that + * `92938d880` introduced is gone. + * + * The field no longer answers "may I be a delegation target". It answers "does the + * CHILD get collaboration tools": `collab_tools_enabled` + * (core/src/tools/spec_plan.rs:599-610) grants a child recursive tools only when its + * own catalog value is exactly `Some(V2)`. The three-way distinction survives, but it + * now means eligible-recursive / eligible-LEAF / excluded: + * + * - `"v2"` -> eligible, and the child may itself delegate. + * - `"v1"` -> eligible LEAF worker. This is upstream's pin for `gpt-5.6-luna` + * (models-manager/models.json); excluding it here is exactly what + * kept Luna out of opencodex's roster. + * - absent/null -> eligible LEAF worker (routed or unpinned-native model). + * - `"disabled"` -> the sole capability-based exclusion. + * + * This is the roster filter only. Catalog STAMPING is a separate concern owned by + * `applyMultiAgentMode`, including the `keepNativeChatGptOnV1` policy (#1728) that + * keeps ChatGPT-native rows on `v1` so a native parent can still spawn a routed child + * despite backend-encrypted NEW_TASK bodies (#92). Recognizing those `v1` rows as + * eligible leaves here is what makes that policy usable, not a contradiction of it. + * + * Devlog: 260816_codexrs_multiagent_v2_and_history_perf/011 (C1), superseding the + * option-B decision in 260730_codex_rs_upstream_v2_live_handoff/060. + */ +export function isEligibleV2SubagentEntry(entry: RawEntry): boolean { + return entry.multi_agent_version !== "disabled"; +} + +export interface EffectiveSubagentModel { + model: string; + efforts: string[]; +} + +export interface SubagentRosterExclusion { + configured: string; + reason: SubagentRosterExclusionReason; + catalogModel?: string; +} + +export interface EffectiveSubagentRoster { + /** OpenCodex's natural-priority guidance projection, not captured native tool text. */ + candidates: EffectiveSubagentModel[]; + /** Configured models within that projection; exact-name eligibility is a separate check. */ + advertised: EffectiveSubagentModel[]; + excluded: SubagentRosterExclusion[]; +} + +export function configuredCatalogEntry(entries: readonly RawEntry[], configured: string): RawEntry | undefined { + return entries.find(entry => entry.slug === configured) + ?? entries.find(entry => typeof entry.slug === "string" && slugsEquivalent(configured, entry.slug)); +} + +function configuredSubagentModelMatchesEntry(configured: string, entry: RawEntry): boolean { + if (typeof entry.slug !== "string") return false; + if (slugsEquivalent(configured, entry.slug)) return true; + const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry); + return !configured.includes("/") + && nativeSlug !== undefined + && SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug) + && slugsEquivalent(configured, nativeSlug); +} + +export function effectiveSubagentRoster( + configuredModels: readonly string[], + surface: SpawnAgentSurface, + catalogEntries?: readonly RawEntry[], +): EffectiveSubagentRoster { + const configured = configuredModels + .filter(model => model.trim().length > 0) + .filter((model, index, all) => + !all.slice(0, index).some(previous => slugsEquivalent(previous, model)) + ); + const entries = catalogEntries ?? readCatalog(readCodexCatalogPath())?.models ?? []; + const ordered = entries + .map((entry, index) => ({ entry, index })) + .filter(({ entry }) => typeof entry.slug === "string") + .filter(({ entry }) => entry.visibility === "list") + .filter(({ entry }) => surface !== "v2" || isEligibleV2SubagentEntry(entry)) + .sort((left, right) => { + // OpenCodex guidance candidates rank by natural priority (SPAWN_PRIORITY_FIELD when present), + // so modelPickerOrder does not change this projection. Native tool advertisements differ. Rows the + // override did not move fall back to their Codex-visible `priority`. + const spawnPriorityOf = (entry: RawEntry): number => { + const spawn = entry[SPAWN_PRIORITY_FIELD]; + if (typeof spawn === "number" && Number.isFinite(spawn)) return spawn; + return typeof entry.priority === "number" && Number.isFinite(entry.priority) + ? entry.priority : Number.MAX_SAFE_INTEGER; + }; + const leftPriority = spawnPriorityOf(left.entry); + const rightPriority = spawnPriorityOf(right.entry); + return leftPriority - rightPriority || left.index - right.index; + }) + .slice(0, MAX_SPAWN_AGENT_MODEL_OVERRIDES); + const orderedEntries = new Set(ordered.map(({ entry }) => entry)); + + const candidates = ordered.map(({ entry }) => ({ + model: entry.slug as string, + efforts: catalogEntryEfforts(entry), + })); + const advertised = ordered + .filter(({ entry }) => configured.some(model => configuredSubagentModelMatchesEntry(model, entry))) + .map(({ entry }) => ({ + model: entry.slug as string, + efforts: catalogEntryEfforts(entry), + })); + const excluded = configured.flatMap((model): SubagentRosterExclusion[] => { + const matchingEntries = entries.filter(entry => configuredSubagentModelMatchesEntry(model, entry)); + if (matchingEntries.some(entry => orderedEntries.has(entry))) return []; + if (matchingEntries.length === 0) return [{ configured: model, reason: "missing_catalog_entry" }]; + const visibleCompatible = matchingEntries.find(entry => + entry.visibility === "list" + && (surface !== "v2" || isEligibleV2SubagentEntry(entry)) + ); + if (visibleCompatible) { + return [{ + configured: model, + catalogModel: visibleCompatible.slug as string, + reason: "outside_display_limit", + }]; + } + const visible = matchingEntries.find(entry => entry.visibility === "list"); + if (visible) { + return [{ + configured: model, + catalogModel: visible.slug as string, + reason: "surface_incompatible", + }]; + } + const hidden = configuredCatalogEntry(entries, model) ?? matchingEntries[0]!; + return [{ configured: model, catalogModel: hidden.slug as string, reason: "picker_hidden" }]; + }); + return { candidates, advertised, excluded }; +} diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index d3380020b0..373bcb4377 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -1,2698 +1,52 @@ -import { effectiveProviderAlias } from "../../providers/default-aliases"; -import { pendingModelSelectionProviders } from "../../providers/initial-model-selection"; -import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; -import { delimiter, dirname, join, resolve } from "node:path"; -import { expandUserPath, loadConfig, readConfigDiagnostics, websocketsEnabled } from "../../config"; -import { shouldSyncCodexOnStart } from "../desired-state"; -import { legacyCustomModelCatalogSlugs } from "../custom-model-catalog-migration"; -import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, getCodexHome, readRootTomlString, resolveCodexConfigPath } from "../paths"; -import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache"; -import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth"; -import type { OcxConfig, OcxProviderConfig } from "../../types"; -import { modelInList } from "../../types"; -import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; -import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; -import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; -import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; -import { encodeRoutedModelId, routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec"; -import { canonicalAutoReviewModelKey, isValidAutoReviewModel as isValidAutoReviewTarget } from "../../config/provider-validation"; -import { identifyRoutedModel } from "../../adapters/identity"; -import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; -import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; -import { - COMBO_NAMESPACE, - comboModelId, - getCombo, - listComboIds, - targetKey, -} from "../../combos"; -import type { NormalizedComboConfig } from "../../combos/types"; -import { providerDestinationResolvedError } from "../../lib/destination-policy"; -import { redactSecretString } from "../../lib/redact"; -import upstreamModelsSnapshot from "../data/upstream-models.json"; -import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; -import { providerCodexAccountMode } from "../../providers/registry"; -import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../account-namespaces"; -import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; -import { - availableAccountGatedNativeModels, - codexModelEntitlementStateForAccount, - isCodexModelEntitlementSnapshotCurrent, - resolveCodexModelEntitlements, - type CodexModelEntitlementSnapshot, -} from "../model-entitlements"; -import { isAccountNeedsReauth } from "../account-runtime-state"; -import { codexAccountLogLabel, fallbackCodexAccountLogLabel } from "../account-label"; - - -import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, findSupportedNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readConfiguredAutoReviewModel, readNativeBaseline } from "./parsing"; -import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing"; -import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, hasNativeOpenAiCapabilityMetadata, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, RETIRED_NATIVE_OPENAI_MODELS, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata"; -import { - bundledCatalogCacheState, - loadBundledCodexCatalog, - resetBundledCatalogCacheForTests, -} from "./bundled"; -import { isMultiAgentV2Enabled } from "../features"; -import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort"; -import { - clearGatherRoutedModelsInflight, - filterCatalogVisibleModels, - gatherRoutedModels, - lastDropWarnSignature, - type CatalogGatherProviderModelOutcome, -} from "./provider-fetch"; -import { accountSelectorShadowCollisionWarnings, clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, comboUnrestorableShadowWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnAccountSelectorShadowedProviderOnce, warnComboMasqueradeCollisionOnce, warnComboUnrestorableShadowOnce } from "./aggregation"; -import type { ComboCatalogOmission } from "./aggregation"; -import { - withCatalogWriteSerialization, - type CatalogWritePermit, -} from "../catalog-write-serialization"; -import { - publishHashedCodexCatalogBackup, - publishLegacyCodexCatalogBackup, - replaceActiveCodexCatalog, - replaceCodexModelsCache, -} from "../internal/catalog-writer"; -import { codexRuntimeStatePath } from "../runtime"; -import { accountBoundNativeDisplayName, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models"; -import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "./native-models"; -import { observedReserveCatalogSource } from "./metadata"; -import { - createReserveCatalogProjection, - isReserveCatalogProjection, - RESERVE_LUNA_METADATA_SOURCE, - RESERVE_SOURCE_CATALOG_FIELD, - type ReserveCatalogProjection, -} from "./reserve"; - -export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5; - -// Base for config.modelPickerOrder display priorities (#1649). modelPickerOrder is a DISPLAY-ONLY -// reordering of the Codex model picker: it rewrites a row's Codex-visible `priority` but not -// OpenCodex's natural-priority guidance window. Native Codex advertisements still follow the -// visible priority and can differ from that guidance window. -export const PICKER_ORDER_PRIORITY_BASE = 1_000; - -// OpenCodex-private catalog field: the guidance candidate priority a row would have WITHOUT -// modelPickerOrder. Codex ignores unknown catalog fields (same as opencodex_catalog_kind), so this -// is invisible to Codex; effectiveSubagentRoster reads it to keep OpenCodex guidance candidates -// independent of display order. It does not freeze native advertisements. Absent on unmoved rows. -export const SPAWN_PRIORITY_FIELD = "opencodex_spawn_priority"; - -// OpenCodex-private catalog field: this row is listed but currently unable to serve (#1711). -// Codex ignores unknown catalog fields (same as opencodex_catalog_kind and the spawn priority -// above) and ensureStrictCatalogFields does not strip extras, so this is invisible to the native -// picker and cannot change what Codex offers. It never touches `visibility`. -export const CATALOG_INACTIVE_REASON_FIELD = "opencodex_inactive_reason"; - -export type SpawnAgentSurface = "v1" | "v2"; - -export type SubagentRosterExclusionReason = - | "missing_catalog_entry" - | "picker_hidden" - | "surface_incompatible" - | "outside_display_limit"; - -/** - * Whether a catalog entry may be offered as a V2 subagent model. - * - * Upstream changed this rule in codex-rs `6d4d9442c` ("Support leaf models in - * multi-agent v2"). `model_supports_multi_agent_backend` - * (core/src/tools/handlers/multi_agents_common.rs:36-42) now admits EVERY model - * except one explicitly marked `disabled`; the older `== Some(V2)` equality that - * `92938d880` introduced is gone. - * - * The field no longer answers "may I be a delegation target". It answers "does the - * CHILD get collaboration tools": `collab_tools_enabled` - * (core/src/tools/spec_plan.rs:599-610) grants a child recursive tools only when its - * own catalog value is exactly `Some(V2)`. The three-way distinction survives, but it - * now means eligible-recursive / eligible-LEAF / excluded: - * - * - `"v2"` -> eligible, and the child may itself delegate. - * - `"v1"` -> eligible LEAF worker. This is upstream's pin for `gpt-5.6-luna` - * (models-manager/models.json); excluding it here is exactly what - * kept Luna out of opencodex's roster. - * - absent/null -> eligible LEAF worker (routed or unpinned-native model). - * - `"disabled"` -> the sole capability-based exclusion. - * - * This is the roster filter only. Catalog STAMPING is a separate concern owned by - * `applyMultiAgentMode`, including the `keepNativeChatGptOnV1` policy (#1728) that - * keeps ChatGPT-native rows on `v1` so a native parent can still spawn a routed child - * despite backend-encrypted NEW_TASK bodies (#92). Recognizing those `v1` rows as - * eligible leaves here is what makes that policy usable, not a contradiction of it. - * - * Devlog: 260816_codexrs_multiagent_v2_and_history_perf/011 (C1), superseding the - * option-B decision in 260730_codex_rs_upstream_v2_live_handoff/060. - */ -export function isEligibleV2SubagentEntry(entry: RawEntry): boolean { - return entry.multi_agent_version !== "disabled"; -} - -export interface EffectiveSubagentModel { - model: string; - efforts: string[]; -} - -export interface SubagentRosterExclusion { - configured: string; - reason: SubagentRosterExclusionReason; - catalogModel?: string; -} - -export interface EffectiveSubagentRoster { - /** OpenCodex's natural-priority guidance projection, not captured native tool text. */ - candidates: EffectiveSubagentModel[]; - /** Configured models within that projection; exact-name eligibility is a separate check. */ - advertised: EffectiveSubagentModel[]; - excluded: SubagentRosterExclusion[]; -} - -export function configuredCatalogEntry(entries: readonly RawEntry[], configured: string): RawEntry | undefined { - return entries.find(entry => entry.slug === configured) - ?? entries.find(entry => typeof entry.slug === "string" && slugsEquivalent(configured, entry.slug)); -} - -function configuredSubagentModelMatchesEntry(configured: string, entry: RawEntry): boolean { - if (typeof entry.slug !== "string") return false; - if (slugsEquivalent(configured, entry.slug)) return true; - const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry); - return !configured.includes("/") - && nativeSlug !== undefined - && SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug) - && slugsEquivalent(configured, nativeSlug); -} - -export function effectiveSubagentRoster( - configuredModels: readonly string[], - surface: SpawnAgentSurface, - catalogEntries?: readonly RawEntry[], -): EffectiveSubagentRoster { - const configured = configuredModels - .filter(model => model.trim().length > 0) - .filter((model, index, all) => - !all.slice(0, index).some(previous => slugsEquivalent(previous, model)) - ); - const entries = catalogEntries ?? readCatalog(readCodexCatalogPath())?.models ?? []; - const ordered = entries - .map((entry, index) => ({ entry, index })) - .filter(({ entry }) => typeof entry.slug === "string") - .filter(({ entry }) => entry.visibility === "list") - .filter(({ entry }) => surface !== "v2" || isEligibleV2SubagentEntry(entry)) - .sort((left, right) => { - // OpenCodex guidance candidates rank by natural priority (SPAWN_PRIORITY_FIELD when present), - // so modelPickerOrder does not change this projection. Native tool advertisements differ. Rows the - // override did not move fall back to their Codex-visible `priority`. - const spawnPriorityOf = (entry: RawEntry): number => { - const spawn = entry[SPAWN_PRIORITY_FIELD]; - if (typeof spawn === "number" && Number.isFinite(spawn)) return spawn; - return typeof entry.priority === "number" && Number.isFinite(entry.priority) - ? entry.priority : Number.MAX_SAFE_INTEGER; - }; - const leftPriority = spawnPriorityOf(left.entry); - const rightPriority = spawnPriorityOf(right.entry); - return leftPriority - rightPriority || left.index - right.index; - }) - .slice(0, MAX_SPAWN_AGENT_MODEL_OVERRIDES); - const orderedEntries = new Set(ordered.map(({ entry }) => entry)); - - const candidates = ordered.map(({ entry }) => ({ - model: entry.slug as string, - efforts: catalogEntryEfforts(entry), - })); - const advertised = ordered - .filter(({ entry }) => configured.some(model => configuredSubagentModelMatchesEntry(model, entry))) - .map(({ entry }) => ({ - model: entry.slug as string, - efforts: catalogEntryEfforts(entry), - })); - const excluded = configured.flatMap((model): SubagentRosterExclusion[] => { - const matchingEntries = entries.filter(entry => configuredSubagentModelMatchesEntry(model, entry)); - if (matchingEntries.some(entry => orderedEntries.has(entry))) return []; - if (matchingEntries.length === 0) return [{ configured: model, reason: "missing_catalog_entry" }]; - const visibleCompatible = matchingEntries.find(entry => - entry.visibility === "list" - && (surface !== "v2" || isEligibleV2SubagentEntry(entry)) - ); - if (visibleCompatible) { - return [{ - configured: model, - catalogModel: visibleCompatible.slug as string, - reason: "outside_display_limit", - }]; - } - const visible = matchingEntries.find(entry => entry.visibility === "list"); - if (visible) { - return [{ - configured: model, - catalogModel: visible.slug as string, - reason: "surface_incompatible", - }]; - } - const hidden = configuredCatalogEntry(entries, model) ?? matchingEntries[0]!; - return [{ configured: model, catalogModel: hidden.slug as string, reason: "picker_hidden" }]; - }); - return { candidates, advertised, excluded }; -} - -export function finishUpstreamNativeEntry(clone: RawEntry, priority: number, contextCap?: NativeContextLimitsInput): RawEntry { - if (priority !== 9) clone.priority = priority; - applyNativeOpenAiContextOverride(clone, contextCap); - // GPT-5.6 natives keep their exact upstream ladders (e.g. luna has max but no ultra). - // Older natives (gpt-5.5) get mock max + ultra - // (wire-clamped to xhigh). Ultra is always advertised regardless of v2 toggle. - if (!isGpt56NativeSlug(String(clone.slug ?? ""))) ensureUltraReasoningLevel(clone); - return ensureStrictCatalogFields(normalizeServiceTiers(clone)); -} - -export function isExactComboCatalogModel( - model: CatalogModel | undefined, - exactComboSlugs: ReadonlySet, -): boolean { - return model?.provider === COMBO_NAMESPACE && exactComboSlugs.has(catalogModelSlug(model)); -} - -function isExactComboCatalogEntry( - entry: RawEntry, - exactComboSlugs: ReadonlySet, -): boolean { - return entry.owned_by === COMBO_NAMESPACE - && typeof entry.slug === "string" - && exactComboSlugs.has(entry.slug); -} - -/** - * Friendly Codex-picker label for a routed `provider/model` slug. Command Code's two config - * ids differ by a single dash (`command-code` vs `commandcode`), so relabel them to the - * lowercase-dash style the opencode presets use: `commandcode-auth/x` and `commandcode-api/x`. - * The model-id portion also carries a redundant `-` prefix (`deepseek-deepseek-v4-flash`) - * that is dropped for display. Google Antigravity is relabeled to the compact `agy/` prefix for - * the same reason: `google-antigravity/` alone consumes most of the picker row. That prefix comes - * from the row's own `providerAlias`, decided once per gather flight; `null` means a cross-provider - * collision suppressed it and the canonical slug stands. This is the raw-slug path only -- a - * configured `modelAliases` entry is labeled by the effective-alias path in - * catalog/provider-fetch.ts (#2960) and keeps the canonical provider name. All other providers - * keep the raw slug exactly as before. - */ -function routedDisplayName(slug: string, model?: CatalogModel, config?: Pick): string { - const slash = slug.indexOf("/"); - if (slash <= 0) return slug; - const provider = slug.slice(0, slash); - let modelId = slug.slice(slash + 1); - if (provider === "google-antigravity") { - if (model?.providerAlias === null) return slug; - const alias = (typeof model?.providerAlias === "string" && model.providerAlias.trim().length > 0) - ? model.providerAlias.trim() - : effectiveProviderAlias(provider, undefined, config); - return alias ? `${alias}/${modelId}` : slug; - } - if (provider === "command-code" || provider === "commandcode") { - const m = modelId.match(/^([a-z0-9]+)-([a-z0-9]+(?:-[a-z0-9]+)+)$/i); - if (m && modelId.startsWith(`${m[1]}-${m[1]}-`)) modelId = modelId.slice(m[1]!.length + 1); - return `${provider === "command-code" ? "commandcode-auth" : "commandcode-api"}/${modelId}`; - } - return slug; -} - -function preservePinnedNativeCustomReasoning(model?: CatalogModel): boolean { - return model !== undefined - && model.catalogKind === CODEX_CUSTOM_MODEL_CATALOG_KIND - && hasNativeOpenAiCapabilityMetadata(model.id) - && Array.isArray(model.reasoningEfforts); -} - -/** - * Cria uma entrada nativa ou roteada a partir do snapshot upstream, de um clone - * do template ou de campos mínimos. Aplica os metadados e limites pertinentes - * sem alterar o template nem herdar sua marca de nome ou histórico de prioridade. - */ -export function deriveEntry( - template: RawEntry | null, - slug: string, - desc: string, - priority: number, - model?: CatalogModel, - exactComboSlugs: ReadonlySet = new Set(), - contextCap?: NativeContextLimitsInput, -): RawEntry { - const preserveExact = isExactComboCatalogModel(model, exactComboSlugs); - // Go exposes model-specific upstream enums; synthetic tiers mislead subagent overrides. - const preserveExactReasoning = preserveExact || model?.provider === "opencode-go"; - const codexForwardNativeCapabilityAlias = model?.codexForwardNativeCapabilityAlias === true - ? upstreamNativeEntry(model.id) - : null; - const isRouted = model !== undefined; - if (!isRouted && !slug.includes("/")) { - // Supported native slug covered by the upstream snapshot: use the REAL entry (exact - // reasoning ladder — e.g. luna has no ultra — default effort, identity, model_messages) - // instead of cloning an older template. - const upstream = upstreamNativeEntry(slug); - if (upstream) return finishUpstreamNativeEntry(upstream, priority, contextCap); - } - if (template || codexForwardNativeCapabilityAlias) { - const e = JSON.parse(JSON.stringify(codexForwardNativeCapabilityAlias ?? template)) as RawEntry; - delete e.opencodex_native_display_name; - // A cached template may carry display-order history; each new row owns its natural rank. - delete e[SPAWN_PRIORITY_FIELD]; - e.slug = slug; - e.display_name = routedDisplayName(slug, model); - e.description = desc; - e.priority = priority; - e.visibility = "list"; - if ("upgrade" in e) e.upgrade = null; - delete e.availability_nux; // don't replay another model's "now available" NUX - // Routed (namespaced) models inherit the gpt template — correct its OpenAI/GPT identity - // and advertise the reasoning ladder Codex accepts. - if (isRouted) { - // A routed model is NOT the native template: never inherit its context - // window when /models omits context metadata (#992). Known metadata - // restores exact values below; an enabled Context cap fills the gap; - // otherwise the strict-fields fallback supplies the 128k triple. - if (!codexForwardNativeCapabilityAlias) { - delete e.context_window; - delete e.max_context_window; - delete e.auto_compact_token_limit; - } - // Native id for identity text + metadata lookups — the slug may be an encoded - // alias (`provider/vendor-model`); the model object carries the native id. - const modelName = model?.id ?? slug.slice(slug.indexOf("/") + 1); - if (typeof e.base_instructions === "string") { - // Proxy-neutral: keep the GPT-5/OpenAI disclaimer but never advertise the opencodex proxy - // (leaking that into base_instructions is a non-first-party signature → ToS risk). - e.base_instructions = identifyRoutedModel(e.base_instructions, modelName); - } - applyReasoningLevels( - e, - model?.reasoningEfforts, - model?.defaultReasoningEffort, - preserveExactReasoning - || codexForwardNativeCapabilityAlias !== null - || preservePinnedNativeCustomReasoning(model), - ); - // This exact provider/model pair is the ChatGPT/Codex forward surface. Keep the pinned - // native tool/search/responses-lite contract while preserving the routed slug and wire id. - if (!codexForwardNativeCapabilityAlias) { - normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true, model?.codexToolMode); - } else if (model?.codexToolMode !== undefined) { - applyRoutedCodexToolMode(e, model.codexToolMode); - } - if (model) applyCatalogMetadata(e, model.provider, model.id, model.contextCap); - applyCatalogModelMetadata(e, model); - if (model?.catalogKind) e.opencodex_catalog_kind = model.catalogKind; - // Additive only. `visibility` is untouched: an inactive row must still be OFFERED, which is - // the whole point of #1711 — operator disable is what removes rows, and it stays a separate - // path from this one. - if (model?.quotaInactiveReason) e[CATALOG_INACTIVE_REASON_FIELD] = model.quotaInactiveReason; - } else { - applyNativeOpenAiContextOverride(e, contextCap); - if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(e); - else ensureUltraReasoningLevel(e); - // Older natives do not support Responses Lite. A newer template must not enable - // reasoning.context or WebSockets on those models. - if (!isGpt56NativeSlug(slug)) { - delete e.use_responses_lite; - delete e.supports_websockets; - } - } - return ensureStrictCatalogFields(normalizeServiceTiers(e), { - preserveExactInputModalities: preserveExact, - isRouted, - }); - } - // Fallback when no template is available (best-effort; strict parser may need more). - // Routed fallbacks default to code-mode tool exposure (or shell mode when codexToolMode === "shell"); - // otherwise the nested catalog expands into `exec.description` and can exceed Cursor's 120 KB serialized tool limit (#1830). - // Cursor still omits hosted web-search metadata because runTurn bypasses that separate sidecar. - const isCursorFallback = isRouted && model?.provider === "cursor"; - const entry: RawEntry = { - slug, display_name: routedDisplayName(slug, model), description: desc, - shell_type: "unified_exec", visibility: "list", supported_in_api: true, - priority, base_instructions: "You are a helpful coding assistant.", - ...(isRouted - ? isCursorFallback - ? { supports_search_tool: true } - : { web_search_tool_type: "text_and_image", supports_search_tool: true } - : {}), - }; - if (isRouted) { - applyRoutedCodexToolMode(entry, model?.codexToolMode); - applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExactReasoning || preservePinnedNativeCustomReasoning(model)); - } - else { - applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]); - if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(entry); - } - if (model && isRouted) applyCatalogMetadata(entry, model.provider, model.id, model.contextCap); - applyCatalogModelMetadata(entry, model); - if (model?.catalogKind) entry.opencodex_catalog_kind = model.catalogKind; - // Same additive stamp as the templated path above. A routed row that reaches the no-template - // fallback is still a served row, so omitting it here would make the field depend on whether a - // template happened to be cached — which is exactly what the regression test caught. - if (model?.quotaInactiveReason) entry[CATALOG_INACTIVE_REASON_FIELD] = model.quotaInactiveReason; - if (!isRouted) applyNativeOpenAiContextOverride(entry, contextCap); - return ensureStrictCatalogFields(normalizeServiceTiers(entry), { - preserveExactInputModalities: preserveExact, - isRouted, - }); -} - -export interface ObservedCatalogEntryBuildInput { - readonly template: RawEntry | null; - readonly gptSlugs: readonly string[]; - readonly goModels: readonly CatalogModel[]; - readonly featured?: readonly string[]; - /** Optional full picker ordering (config.modelPickerOrder); orders non-featured rows. */ - readonly modelPickerOrder?: readonly string[]; - readonly wsEnabled: boolean; - readonly multiAgentMode: MultiAgentMode; - readonly exactComboSlugs: ReadonlySet; - readonly accountSelectors: readonly string[]; - readonly suppressedBareNativeSlugs: ReadonlySet; - readonly disabledNativeAccountSlugs: ReadonlySet; - readonly multiAgentV2Enabled: boolean; - readonly keepNativeChatGptOnV1?: boolean; - readonly openaiContextCap?: NativeContextLimitsInput; - /** Additional native ids to clone under account selectors, without creating bare rows. */ - readonly accountNativeSlugs?: readonly string[]; - /** Per-selector account ids; unknown observations must not be copied to unrelated accounts. */ - readonly accountNativeSlugsBySelector?: ReadonlyMap; - /** Codex-only manual selector metadata; deliberately independent of live permission. */ - readonly reserve?: ReserveCatalogProjection; -} - -/** Build entries with the process-observed Codex feature state. */ -export function buildCatalogEntries( - template: RawEntry | null, - gptSlugs: string[], - goModels: CatalogModel[], - featured?: string[], - wsEnabled = false, - multiAgentMode: MultiAgentMode = "default", - exactComboSlugs: ReadonlySet = new Set(), - accountSelectors: readonly string[] = [], - suppressedBareNativeSlugs: ReadonlySet = new Set(), - disabledNativeAccountSlugs: ReadonlySet = new Set(), - contextCap?: NativeContextLimitsInput, - accountNativeSlugs?: readonly string[], - accountNativeSlugsBySelector?: ReadonlyMap, - keepNativeChatGptOnV1 = false, - modelPickerOrder: readonly string[] = [], -): RawEntry[] { - const entries = buildCatalogEntriesFromObservedState({ - template, - gptSlugs, - goModels, - featured, - modelPickerOrder, - wsEnabled, - multiAgentMode, - exactComboSlugs, - accountSelectors, - suppressedBareNativeSlugs, - disabledNativeAccountSlugs, - multiAgentV2Enabled: isMultiAgentV2Enabled(), - keepNativeChatGptOnV1, - openaiContextCap: contextCap, - accountNativeSlugs, - accountNativeSlugsBySelector, - }); - applyFullModelPickerOrder(entries, modelPickerOrder); - return entries; -} - -/** Build entries solely from caller-observed inputs, with no feature-state filesystem read. */ -export function buildCatalogEntriesFromObservedState({ - template, - gptSlugs, - goModels, - featured, - modelPickerOrder, - wsEnabled, - multiAgentMode, - exactComboSlugs, - accountSelectors, - suppressedBareNativeSlugs, - disabledNativeAccountSlugs, - multiAgentV2Enabled, - keepNativeChatGptOnV1, - openaiContextCap, - accountNativeSlugs, - accountNativeSlugsBySelector, - reserve, -}: ObservedCatalogEntryBuildInput): RawEntry[] { - // Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible - // models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog - // ARRAY order is discarded — so "featuring" a model = giving it the LOWEST priority (0..N-1) so - // it sorts to the front. This works for native gpt slugs AND routed slugs alike. - const rank = new Map((featured ?? []).map((slug, i) => [slug, i] as const)); - const priorityStride = Math.max(accountSelectors.length, 1); - // Optional full picker order (#1649). Independent of the 5-slot spawn_agent cap: it only - // rewrites the Codex-visible display `priority` of listed non-featured routed rows so a >5 - // catalog stays put across rebuilds. Featured rows keep their existing 0..N-1 band; when - // modelPickerOrder is unset the helper is a no-op and every priority below is byte-identical to - // before. The spawn_agent candidate window is derived separately from SPAWN_PRIORITY_FIELD, so - // this display reorder does not change OpenCodex's guidance candidate calculation. - const pickerOrder = normalizeModelPickerOrder(modelPickerOrder); - const pickerOrderRank = new Map(pickerOrder.map((slug, i) => [slug, i] as const)); - const pickerOrderActive = pickerOrder.length > 0; - // The display band reuses the existing high priority tier (>= PICKER_ORDER_PRIORITY_BASE, the - // same 1_000+ neighborhood account rows occupy), keeping listed rows visually after the featured - // band. OpenCodex guidance membership does not depend on this — see SPAWN_PRIORITY_FIELD. - /** - * Priority for a non-featured routed row that is explicitly LISTED in modelPickerOrder. Listed - * slugs sort in declared order within the high picker-order display tier - * (>= PICKER_ORDER_PRIORITY_BASE). This sets the Codex-visible `priority` only; the caller records - * the row's natural priority in SPAWN_PRIORITY_FIELD for OpenCodex's unchanged guidance window. - * Returns undefined when the feature is off or the row is not listed, so those rows - * keep their original assignment (default 5 / account 1_000+) untouched. - * - * Scope: only the generic routed `/` rows call this (see the goModels loop - * below). Native passthrough rows and account-qualified native rows keep their own priority - * logic and are intentionally not reordered in this legacy builder pass. The final merge can - * apply complete ordering when the configured list includes a bare id. - */ - const pickerOrderPriority = (slug: string, altSlug?: string): number | undefined => { - if (!pickerOrderActive) return undefined; - const hit = pickerOrderRank.get(slug) ?? (altSlug !== undefined ? pickerOrderRank.get(altSlug) : undefined); - if (hit === undefined) return undefined; - return PICKER_ORDER_PRIORITY_BASE + hit * priorityStride; - }; - const out: RawEntry[] = []; - const nativeEntries: RawEntry[] = []; - const collisionSkipped = resolveSlugAliasCollisions([...goModels]); - const emittedNativeAliases = new Set(); - const emittedNativeAliasSlugs = new Set(); - const nativeAliasesBySlug = new Map(); - for (const model of goModels) { - if (model.provider !== COMBO_NAMESPACE - || model.nativeAlias !== true - || typeof model.alias !== "string" - || model.alias.includes("/")) continue; - if (nativeAliasesBySlug.has(model.alias)) { - collisionSkipped.add(model); - if (!slugAliasCollisionWarnings.has(model.alias)) { - slugAliasCollisionWarnings.add(model.alias); - console.warn( - `[opencodex] native combo alias collision on "${model.alias}": keeping the first configured combo and omitting later duplicates from the catalog.`, - ); - } - continue; - } - nativeAliasesBySlug.set(model.alias, model); - } - const comboPublicSlugs = new Set(goModels - .filter(model => model.provider === COMBO_NAMESPACE) - .map(catalogModelSlug)); - for (const slug of gptSlugs) { - const native = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9, undefined, new Set(), openaiContextCap); - if (rank.has(slug)) native.priority = rank.get(slug)!; - nativeEntries.push(native); - const nativeAlias = nativeAliasesBySlug.get(slug); - if (!nativeAlias || collisionSkipped.has(nativeAlias)) { - if (!suppressedBareNativeSlugs.has(slug)) out.push(native); - continue; - } - const routed = deriveEntry( - template, - slug, - `Routed via opencodex → ${nativeAlias.provider} (${nativeAlias.owned_by ?? nativeAlias.provider}).`, - 5, - nativeAlias, - exactComboSlugs, - ); - routed.opencodex_catalog_kind = CODEX_NATIVE_ALIAS_CATALOG_KIND; - const rankHit = rank.get(slug) ?? rank.get(`${nativeAlias.provider}/${nativeAlias.id}`); - if (rankHit !== undefined) routed.priority = rankHit * priorityStride; - else if (accountSelectors.length > 0) routed.priority = 1_000 + (typeof routed.priority === "number" ? routed.priority : 5); - out.push(routed); - emittedNativeAliases.add(nativeAlias); - emittedNativeAliasSlugs.add(slug); - } - const nativeEntriesBySlug = new Map(nativeEntries.map(entry => [String(entry.slug), entry] as const)); - for (const [selectorIndex, selector] of accountSelectors.entries()) { - const selectorNativeSlugs = accountNativeSlugsBySelector?.get(selector) - ?? accountNativeSlugs - ?? gptSlugs; - const accountNativeEntries = selectorNativeSlugs.filter(slug => slug !== NATIVE_RESERVE_MODEL).map(slug => ( - nativeEntriesBySlug.get(slug) - ?? deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9, undefined, new Set(), openaiContextCap) - )); - if (reserve?.mainSelectors.includes(selector)) accountNativeEntries.push(reserve.source); - for (const [nativeIndex, native] of accountNativeEntries.entries()) { - const nativeSlug = String(native.slug); - if (disabledNativeAccountSlugs.has(nativeSlug)) continue; - const e = JSON.parse(JSON.stringify(native)) as RawEntry; - const catalogSlug = `${selector}/${nativeSlug}`; - if (nativeSlug === NATIVE_RESERVE_MODEL && disabledNativeAccountSlugs.has(catalogSlug)) continue; - e.slug = catalogSlug; - e.display_name = accountBoundNativeDisplayName(selector, native); - // Codex ignores this OpenCodex extension; preserve the native comp_hash unchanged. - e.opencodex_catalog_kind = CODEX_ACCOUNT_BOUND_CATALOG_KIND; - const exactRank = rank.get(catalogSlug); - // A bare featured id belongs to the compatibility combo once shadowed. Exact - // account-qualified picks still rank normally, but the account clone must not - // inherit the bare alias rank and consume another top spawn_agent slot. - const inheritedRank = emittedNativeAliasSlugs.has(nativeSlug) ? undefined : rank.get(nativeSlug); - const featuredRank = exactRank ?? inheritedRank; - e.priority = featuredRank !== undefined - ? featuredRank * priorityStride + selectorIndex - : ((featured?.length ?? 0) + nativeIndex) * accountSelectors.length + selectorIndex; - e.visibility = "list"; - out.push(e); - } - } - for (const m of goModels) { - if (collisionSkipped.has(m) || emittedNativeAliases.has(m)) continue; - const slug = catalogModelSlug(m); - if (m.provider !== COMBO_NAMESPACE && comboPublicSlugs.has(slug)) { - warnComboMasqueradeCollisionOnce(slug); - continue; - } - // Provider rows use the one-slash slug codec; combo aliases intentionally override that - // public slug and may be bare. - const e = deriveEntry( - template, - slug, - `Routed via opencodex → ${m.provider} (${m.owned_by ?? m.provider}).`, - 5, - m, - exactComboSlugs, - ); - if (m.provider === COMBO_NAMESPACE && m.nativeAlias === true && !slug.includes("/")) { - e.opencodex_catalog_kind = CODEX_NATIVE_ALIAS_CATALOG_KIND; - } - // Featured picks may be stored raw (legacy) or encoded — honor both. - const rankHit = rank.get(slug) ?? rank.get(`${m.provider}/${m.id}`); - // Natural priority: what the row would get WITHOUT modelPickerOrder. This is the value the - // spawn_agent candidate window is derived from (see effectiveSubagentRoster), so it must never - // move when modelPickerOrder reorders the picker. - if (rankHit !== undefined) e.priority = rankHit * priorityStride; - else if (accountSelectors.length > 0) { - // Keep the generated account rows together in Codex's priority-sorted flat picker. - e.priority = 1_000 + (typeof e.priority === "number" ? e.priority : 5); - } - // The legacy routed-only builder pass keeps featured ranks and records natural priority - // before changing non-featured display priority. The final complete-order pass may move - // featured display rows too; OpenCodex guidance continues to use their natural ranks. - if (rankHit === undefined) { - const pickerPriority = pickerOrderPriority(slug, `${m.provider}/${m.id}`); - if (pickerPriority !== undefined) { - e[SPAWN_PRIORITY_FIELD] = typeof e.priority === "number" ? e.priority : 5; - e.priority = pickerPriority; - } - } - out.push(e); - } - // Central capability override (phase 120.4): the advertised flag must match the implemented WS - // endpoint. Overrides both the routed strip (normalizeRoutedCatalogEntry) and any native template - // leak (deriveEntry clones the template as-is for native slugs). - for (const entry of out) { - if (wsEnabled) entry.supports_websockets = true; - else { - delete entry.supports_websockets; - // Snapshot-backed native entries carry prefer_websockets: never advertise a preference - // for an endpoint ocx has disabled. - delete entry.prefer_websockets; - } - } - return applyMultiAgentMode(out, multiAgentMode, multiAgentV2Enabled, { - keepNativeChatGptOnV1, - preserveDefaultMultiAgentVersion: isReserveCatalogProjection, - }); -} - -export function resetCatalogRuntimeStateForTests(): void { - resetBundledCatalogCacheForTests(); - lastDropWarnSignature.clear(); - openAiApiCollisionWarnings.clear(); - comboCatalogWarningSignatures.clear(); - slugAliasCollisionWarnings.clear(); - comboMasqueradeCollisionWarnings.clear(); - comboUnrestorableShadowWarnings.clear(); - accountSelectorShadowCollisionWarnings.clear(); - clearLastComboCatalogOmissions(); - clearModelCache(undefined, "eviction"); - clearGatherRoutedModelsInflight(); -} - -export function orderForSubagents(goModels: CatalogModel[], featured?: string[]): CatalogModel[] { - if (!featured || featured.length === 0) return goModels; - const rank = new Map(featured.map((id, i) => [id, i])); - // Featured picks may be stored raw (legacy) or encoded — match both forms. - const rankOf = (m: CatalogModel) => - (m.alias ? rank.get(m.alias) : undefined) - ?? rank.get(`${m.provider}/${m.id}`) - ?? rank.get(routedSlug(m.provider, m.id)) - ?? Number.MAX_SAFE_INTEGER; - return [...goModels].sort((a, b) => { - return rankOf(a) - rankOf(b); - }); -} - -/** Routed discovery projection; native groups and alias ownership belong to the caller. */ -export function orderForModelPicker( - models: readonly CatalogModel[], - order: readonly string[] = [], - featured: readonly string[] = [], -): CatalogModel[] { - const pickerOrder = normalizeModelPickerOrder(order); - if (pickerOrder.length === 0) return [...models]; - const pickerRank = modelPickerRank(pickerOrder); - const featuredRank = modelPickerRank(featured); - const complete = pickerOrder.some(slug => !slug.includes("/")); - const rank = (model: CatalogModel): number => { - const slug = catalogModelSlug(model); - const featuredIndex = featuredRank(slug) ?? featuredRank(`${model.provider}/${model.id}`); - const natural = featuredIndex ?? 5; - const index = pickerRank(slug) ?? pickerRank(`${model.provider}/${model.id}`); - if (complete) return index ?? pickerOrder.length + natural; - // Preserve the legacy featured/alias bands, including unlisted rows before listed rows. - if (featuredIndex !== undefined || model.nativeAlias === true) return natural; - return index === undefined ? natural : PICKER_ORDER_PRIORITY_BASE + index; - }; - return [...models].sort((a, b) => rank(a) - rank(b)); -} - -/** - * True when an existing catalog row was authored by OpenCodex routing (#855). - * Every generated routed row — current full-slug form, the June–July 2026 - * provider-name form, and legacy combo aliases — carries the stable - * description prefix `Routed via opencodex → `; foreign rows from Cursor or - * user tooling do not. `owned_by` cannot serve as the signal (upstream - * ownership), and `comp_hash` defaults to "opencodex" for every normalized - * row. - */ -function isOcxAuthoredRoutedEntry(entry: RawEntry): boolean { - if (isNativeAliasCatalogEntry(entry)) return true; - const desc = typeof entry.description === "string" ? entry.description : ""; - const slug = typeof entry.slug === "string" ? entry.slug : ""; - return slug.includes("/") && desc.startsWith("Routed via opencodex → "); -} - -function recoverableNativeSlug(entry: RawEntry): string | null { - const slug = typeof entry.slug === "string" ? entry.slug : ""; - return SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug) - && !isNativeAliasCatalogEntry(entry) - && entry.owned_by !== COMBO_NAMESPACE - ? slug - : null; -} - -/** Undo our display overlay before native metadata normalization and template reuse. */ -function restoreNativeDisplayName(entry: RawEntry): RawEntry { - const saved = entry.opencodex_native_display_name; - delete entry.opencodex_native_display_name; - if (saved && typeof saved === "object" && !Array.isArray(saved)) { - const label = saved as Record; - if (recoverableNativeSlug(entry) === label.slug - && typeof label.original === "string" && entry.display_name === label.applied) { - entry.display_name = label.original; - } - } - return entry; -} - -/** Append missing supported native rows from trusted catalog sources only. */ -export function mergeCatalogModelsWithNativeRecovery( - primaryCatalogModels: readonly RawEntry[], - nativeRecoverySources: readonly (readonly RawEntry[])[], -): RawEntry[] { - const merged = [...primaryCatalogModels]; - const recoveredNativeSlugs = new Set(primaryCatalogModels.flatMap(entry => { - const slug = recoverableNativeSlug(entry); - return slug === null ? [] : [slug]; - })); - for (const source of nativeRecoverySources) { - for (const entry of source) { - const slug = recoverableNativeSlug(entry); - if (slug === null || recoveredNativeSlugs.has(slug)) continue; - merged.push(structuredClone(entry) as RawEntry); - recoveredNativeSlugs.add(slug); - } - } - return merged; -} - -export interface ObservedCatalogMergePolicy { - /** Required observed/fixed set; the core merge never consults ambient catalog state. */ - readonly nativeBackfillSlugs: readonly string[]; - /** Whether unsupported OpenAI-family bare rows survive the merge. */ - readonly unsupportedNativeEntries: "preserve" | "drop"; - /** Whether merge-policy collision/preservation warnings belong to this caller's flow. */ - readonly warningPolicy: "emit" | "suppress"; -} - -/** Content policy shared by every writer of the canonical Codex model catalog. */ -export const CANONICAL_NATIVE_CATALOG_CONTENT_POLICY: Readonly< - Pick -> = Object.freeze({ - nativeBackfillSlugs: Object.freeze([...NATIVE_OPENAI_MODELS]), - unsupportedNativeEntries: "drop", -}); - -function normalizeModelPickerOrder(order: unknown): string[] { - return Array.isArray(order) - ? order.filter((id): id is string => typeof id === "string" && id.trim().length > 0) - : []; -} - -/** Preserve exact-id precedence while accepting the existing raw/encoded slug spellings. */ -function modelPickerRank(order: readonly string[]): (slug: string) => number | undefined { - const exact = new Map(order.map((slug, index) => [slug, index])); - const equivalent = new Map(order.map((slug, index) => [slugEquivalenceKey(slug), index])); - return slug => exact.get(slug) ?? equivalent.get(slugEquivalenceKey(slug)); -} - -/** Complete display ordering retains natural ranks for OpenCodex's separate guidance projection. */ -export function applyFullModelPickerOrder(entries: RawEntry[], order: readonly string[]): void { - const pickerOrder = normalizeModelPickerOrder(order); - if (!pickerOrder.some(slug => !slug.includes("/"))) return; - const rankOf = modelPickerRank(pickerOrder); - for (const entry of entries) { - const natural = entry[SPAWN_PRIORITY_FIELD] ?? entry.priority ?? 9; - entry[SPAWN_PRIORITY_FIELD] = natural; - entry.priority = rankOf(String(entry.slug)) ?? pickerOrder.length + Number(natural); - } -} - -export interface ObservedCatalogMergeInput { - readonly catalogModels: readonly RawEntry[]; - readonly baselineCatalogModels: readonly RawEntry[]; - readonly routedEntries: readonly RawEntry[]; - readonly baseline: ReadonlyMap; - readonly featured: readonly string[]; - readonly modelPickerOrder?: readonly string[]; - readonly accountSelectors?: readonly string[]; - readonly wsEnabled: boolean; - readonly template: RawEntry | null; - readonly disabledModels: ReadonlySet; - readonly selectedModelsByProvider: ReadonlyMap>; - readonly gatheredProviderNames: ReadonlySet; - readonly pendingProviderNames?: ReadonlySet; - readonly degradedProviderNames: ReadonlySet; - readonly legacyCustomModelSlugs: ReadonlySet; - readonly multiAgentMode: MultiAgentMode; - readonly multiAgentV2Enabled: boolean; - readonly keepNativeChatGptOnV1?: boolean; - readonly exactComboSlugs: ReadonlySet; - readonly hasPhysicalComboProvider: boolean; - readonly includeNativeOpenAi: boolean; - readonly accountBoundEntries: readonly RawEntry[]; - readonly suppressedBareNativeSlugs?: ReadonlySet; - readonly policy: ObservedCatalogMergePolicy; - readonly openaiContextCap?: NativeContextLimitsInput; - /** Exact display-only labels for bare native OpenAI models. */ - readonly nativeDisplayNames?: Readonly>; -} - -/** - * Deterministically merge one fully observed catalog state. - * - * Every non-catalog input is explicit so evidence-bound convergence cannot - * accidentally fall back to process-ambient catalog discovery or merge-policy warnings. - */ -export function mergeCatalogEntriesFromObservedState({ - catalogModels, - baselineCatalogModels, - routedEntries, - baseline, - featured, - modelPickerOrder = [], - accountSelectors = [], - wsEnabled, - template, - disabledModels, - selectedModelsByProvider, - gatheredProviderNames, - pendingProviderNames = new Set(), - degradedProviderNames, - legacyCustomModelSlugs, - multiAgentMode, - multiAgentV2Enabled, - keepNativeChatGptOnV1, - exactComboSlugs, - hasPhysicalComboProvider, - includeNativeOpenAi, - accountBoundEntries, - suppressedBareNativeSlugs = new Set(), - policy, - openaiContextCap, - nativeDisplayNames, -}: ObservedCatalogMergeInput): RawEntry[] { - // Raw catalog rows contain nested arrays/objects that normalization mutates. Detach every row at - // the observed-core boundary so callers can safely retain evidence objects or repeat the merge. - const detachedCatalogModels = catalogModels - .map(entry => restoreNativeDisplayName(structuredClone(entry) as RawEntry)); - const detachedBaselineCatalogModels = baselineCatalogModels - .map(entry => restoreNativeDisplayName(structuredClone(entry) as RawEntry)); - const detachedRoutedEntries = routedEntries.map(entry => structuredClone(entry) as RawEntry); - // Track this invocation's generated custom rows, not ownership markers read from disk. - // Their builder already finalized exact native ladders and ordinary routed mock tiers. - const freshCustomEntries = new Set(detachedRoutedEntries.filter(entry => - entry.opencodex_catalog_kind === CODEX_CUSTOM_MODEL_CATALOG_KIND)); - const detachedAccountBoundEntries = accountBoundEntries - .map(entry => structuredClone(entry) as RawEntry); - const disabledModelKeys = new Set([...disabledModels].map(slugEquivalenceKey)); - const legacyCustomModelKeys = new Set( - [...legacyCustomModelSlugs].map(slugEquivalenceKey), - ); - const selectedModelKeysByProvider = new Map([...selectedModelsByProvider].map(([provider, models]) => ( - [provider, new Set([...models].map(model => slugEquivalenceKey(routedSlug(provider, model))))] as const - ))); - const freshAccountKeys = new Set(detachedAccountBoundEntries.flatMap(entry => ( - typeof entry.slug === "string" ? [slugEquivalenceKey(entry.slug)] : [] - ))); - const wouldSurviveUnreplaced = (entry: RawEntry): boolean => { - if (entry.owned_by === COMBO_NAMESPACE - || trustedAccountBoundNativeCatalogSlug(entry) !== undefined - || entry.opencodex_catalog_kind === CODEX_CUSTOM_MODEL_CATALOG_KIND - || isOcxAuthoredRoutedEntry(entry) - || typeof entry.slug !== "string") return false; - const slug = entry.slug; - if (!slug.includes("/")) { - if (!includeNativeOpenAi || policy.nativeBackfillSlugs.includes(slug)) return false; - return policy.unsupportedNativeEntries === "preserve" || !isUnsupportedOpenAiNativeSlug(slug); - } - if (isRoutedModelCompatibilityExcluded(slug)) return false; - if (!hasPhysicalComboProvider && slug.startsWith(`${COMBO_NAMESPACE}/`)) return false; - const key = slugEquivalenceKey(slug); - if (freshAccountKeys.has(key)) return false; - if (disabledModelKeys.has(key)) return false; - const slash = slug.indexOf("/"); - const provider = slug.slice(0, slash); - if (pendingProviderNames.has(provider)) return false; - const selected = selectedModelKeysByProvider.get(provider); - if (selected !== undefined && !selected.has(key)) return false; - return !gatheredProviderNames.has(provider) || degradedProviderNames.has(provider); - }; - const validRoutedEntries = detachedRoutedEntries.filter(entry => { - return !isExactComboCatalogEntry(entry, exactComboSlugs) - || (Array.isArray(entry.input_modalities) && entry.input_modalities.length > 0); - }); - const restorableCatalogKeys = new Set(detachedBaselineCatalogModels.flatMap(entry => ( - wouldSurviveUnreplaced(entry) && typeof entry.slug === "string" - ? [slugEquivalenceKey(entry.slug)] - : [] - ))); - const unrestorableCatalogKeys = new Set(detachedCatalogModels.flatMap(entry => { - if (!wouldSurviveUnreplaced(entry) || typeof entry.slug !== "string") return []; - const key = slugEquivalenceKey(entry.slug); - return restorableCatalogKeys.has(key) ? [] : [key]; - })); - const admittedRoutedEntries = validRoutedEntries.filter(entry => { - if (!isExactComboCatalogEntry(entry, exactComboSlugs)) return true; - const slug = entry.slug as string; - const key = slugEquivalenceKey(slug); - if (!unrestorableCatalogKeys.has(key)) return true; - if (policy.warningPolicy === "emit") warnComboUnrestorableShadowOnce(slug); - return false; - }); - // A fresh non-custom row authoritatively resolves a historically ambiguous slug as a normal - // provider model. Persist that classification so the durable deletion evidence cannot remove - // the legitimate row during a later degraded refresh. - for (const entry of admittedRoutedEntries) { - const slug = typeof entry.slug === "string" ? entry.slug : ""; - if (!slug - || entry.opencodex_catalog_kind !== undefined - || entry.owned_by === COMBO_NAMESPACE - || !isOcxAuthoredRoutedEntry(entry) - || !legacyCustomModelKeys.has(slugEquivalenceKey(slug))) continue; - entry.opencodex_catalog_kind = CODEX_PROVIDER_MODEL_CATALOG_KIND; - } - const freshExactComboEntries = new Set(admittedRoutedEntries.filter(entry => ( - isExactComboCatalogEntry(entry, exactComboSlugs) - && typeof entry.description === "string" - && entry.description.startsWith(`Routed via opencodex → ${COMBO_NAMESPACE} (`) - ))); - const rank = new Map(featured.map((slug, i) => [slug, i] as const)); - const freshEquivalentKeys = new Set(admittedRoutedEntries.flatMap(entry => ( - typeof entry.slug === "string" ? [slugEquivalenceKey(entry.slug)] : [] - ))); - const freshEquivalent = (slug: string): boolean => ( - freshEquivalentKeys.has(slugEquivalenceKey(slug)) - ); - const freshBareComboAliases = new Set(admittedRoutedEntries.flatMap(entry => ( - typeof entry.slug === "string" - && !entry.slug.includes("/") - && entry.owned_by === COMBO_NAMESPACE - ? [entry.slug] - : [] - ))); - const staleComboKeys = new Set(detachedCatalogModels.flatMap(entry => ( - typeof entry.slug === "string" - && entry.owned_by === COMBO_NAMESPACE - && !freshEquivalent(entry.slug) - ? [slugEquivalenceKey(entry.slug)] - : [] - ))); - const currentNonComboKeys = new Set(detachedCatalogModels.flatMap(entry => ( - entry.owned_by !== COMBO_NAMESPACE && typeof entry.slug === "string" - ? [slugEquivalenceKey(entry.slug)] - : [] - ))); - const restoredComboShadows = detachedBaselineCatalogModels.filter(entry => { - const slug = typeof entry.slug === "string" ? entry.slug : ""; - if (!slug || entry.owned_by === COMBO_NAMESPACE) return false; - const key = slugEquivalenceKey(slug); - return staleComboKeys.has(key) && !currentNonComboKeys.has(key); - }); - const catalogModelsForMerge = [...detachedCatalogModels, ...restoredComboShadows]; - const nativePriority = (slug: string, fallback: unknown): number => { - const base = baseline.get(slug) - ?? (typeof fallback === "number" ? fallback : 9); - if (rank.has(slug)) return rank.get(slug)!; - return featured.length > 0 ? Math.max(base, featured.length + 100) : base; - }; - const nativeSourceEntries = includeNativeOpenAi - ? catalogModelsForMerge - .filter(m => typeof m.slug === "string" - && !(m.slug as string).includes("/") - && m.owned_by !== COMBO_NAMESPACE - && (policy.unsupportedNativeEntries === "preserve" - || policy.nativeBackfillSlugs.includes(m.slug as string) - || !isUnsupportedOpenAiNativeSlug(m.slug as string))) - .map(m => { - const slug = m.slug as string; - // Fallback-quality entries (ocx synthesis / codex-rs model_info fallback: display_name - // stamped with the bare slug) are upgraded to the pinned upstream snapshot entry so a - // previously synthesized ladder (e.g. luna advertising ultra) self-heals on sync. A - // genuine catalog entry (real display name) is preserved untouched. - if (shouldUpgradeToUpstreamEntry(m)) { - const upstream = upstreamNativeEntry(slug)!; - const finished = finishUpstreamNativeEntry(upstream, 9, openaiContextCap); - finished.priority = nativePriority(slug, upstream.priority); - return finished; - } - const preserved = normalizeServiceTiers({ ...m, priority: nativePriority(slug, m[SPAWN_PRIORITY_FIELD] ?? m.priority) }); - // Recompute spawn rank from current featured models, not a prior picker override. - delete preserved[SPAWN_PRIORITY_FIELD]; - // Older natives kept from disk still need the mock top tiers (max + ultra always - // for subagent max spawns; wire-clamped to the model's real top rung). - if (!isGpt56NativeSlug(slug) && slug !== NATIVE_RESERVE_MODEL) ensureUltraReasoningLevel(preserved); - return preserved; - }) - : []; - const native = nativeSourceEntries.filter(entry => - typeof entry.slug !== "string" - || (!freshBareComboAliases.has(entry.slug) && !suppressedBareNativeSlugs.has(entry.slug)) - ); - - // Backfill any native OpenAI slug that the on-disk catalog is missing (e.g. gpt-5.5), so a - // routed provider exposing the same id can never delete the native OpenAI/Codex base row. - // Skip when no enabled canonical openai provider exists (#636) — bare gpt-* would 404. - const nativeSlugs = new Set(native.flatMap(m => typeof m.slug === "string" ? [m.slug] : [])); - if (includeNativeOpenAi) { - for (const slug of policy.nativeBackfillSlugs) { - if (nativeSlugs.has(slug) || freshBareComboAliases.has(slug) || suppressedBareNativeSlugs.has(slug)) continue; - nativeSlugs.add(slug); - const entry = deriveEntry( - template ? JSON.parse(JSON.stringify(template)) : null, - slug, - "OpenAI native model (Codex OAuth passthrough).", - nativePriority(slug, upstreamNativeEntry(slug)?.priority), - undefined, - new Set(), - openaiContextCap, - ); - entry.priority = nativePriority(slug, upstreamNativeEntry(slug)?.priority); - native.push(entry); - } - } - - const nativeSourceBySlug = new Map([...nativeSourceEntries, ...native].flatMap(entry => - typeof entry.slug === "string" ? [[entry.slug, entry] as const] : [] - )); - const alignedAccountBoundEntries = detachedAccountBoundEntries.map(entry => { - // The explicit Reserve source is already chosen (actual row or documented Luna adaptation). - // A generic native merge must not replace its provenance or capability ladder. - if (isReserveCatalogProjection(entry)) return entry; - const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry); - const source = nativeSlug === undefined ? undefined : nativeSourceBySlug.get(nativeSlug); - if (!source) return entry; - const aligned = JSON.parse(JSON.stringify(source)) as RawEntry; - aligned.slug = entry.slug; - aligned.display_name = entry.display_name; - aligned.priority = entry.priority; - aligned.visibility = "list"; - aligned.opencodex_catalog_kind = CODEX_ACCOUNT_BOUND_CATALOG_KIND; - return aligned; - }); - - const freshSlugs = new Set( - admittedRoutedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []), - ); - const existingRoutedEntries = catalogModelsForMerge.filter(m => - typeof m.slug === "string" - && (m.slug.includes("/") || isNativeAliasCatalogEntry(m)) - && trustedAccountBoundNativeCatalogSlug(m) === undefined - ); - const preservedRoutedEntries = existingRoutedEntries.filter(entry => { - const slug = entry.slug as string; - if (freshEquivalent(slug)) return false; - if (isNativeAliasCatalogEntry(entry)) return exactComboSlugs.has(slug); - // Current custom rows are always regenerated from config, even while provider discovery is - // degraded. A marked row absent from the fresh projection is therefore an intentional delete. - if (entry.opencodex_catalog_kind === CODEX_CUSTOM_MODEL_CATALOG_KIND) return false; - // Before custom rows had a marker, a config deletion could otherwise be mistaken for a - // provider outage. Only explicit save-boundary evidence may classify an unmarked OpenCodex - // row; foreign and future-marked rows fail closed and remain preserved. - if (entry.opencodex_catalog_kind === undefined - && entry.owned_by !== COMBO_NAMESPACE - && isOcxAuthoredRoutedEntry(entry) - && legacyCustomModelKeys.has(slugEquivalenceKey(slug))) return false; - const provider = slug.slice(0, slug.indexOf("/")); - if (gatheredProviderNames.has(provider)) { - // A provider-local degraded observation preserves only that namespace. Authoritative empty - // catalogs and successful removals still delete stale rows even when another provider fails. - return degradedProviderNames.has(provider); - } - // Deleted/disabled providers cannot retain OpenCodex-authored ghosts. Foreign catalog rows - // remain outside provider ownership and survive unless a fresh row replaces their exact slug. - return !isOcxAuthoredRoutedEntry(entry); - }); - // Retained rows bypass the builder. Recompute managed spawn ranks from current config - // before either display-order mode; a saved display override is not current roster authority. - const pickerOrder = normalizeModelPickerOrder(modelPickerOrder); - const fullPickerOrder = pickerOrder.some(slug => !slug.includes("/")); - const rankOf = modelPickerRank(pickerOrder); - const featuredRankOf = modelPickerRank(featured); - const priorityStride = Math.max(accountSelectors.length, 1); - for (const entry of preservedRoutedEntries) { - const natural = entry[SPAWN_PRIORITY_FIELD]; - if (typeof natural === "number") { - entry.priority = natural; - delete entry[SPAWN_PRIORITY_FIELD]; - } - const slug = String(entry.slug); - if (!isOcxAuthoredRoutedEntry(entry) || isNativeAliasCatalogEntry(entry)) continue; - const featuredRank = featuredRankOf(slug); - entry.priority = featuredRank !== undefined - ? featuredRank * priorityStride - : (accountSelectors.length > 0 ? 1_000 : 0) + 5; - if (featuredRank !== undefined || fullPickerOrder) continue; - const pickerIndex = rankOf(slug); - if (pickerIndex !== undefined) { - entry[SPAWN_PRIORITY_FIELD] = entry.priority; - entry.priority = PICKER_ORDER_PRIORITY_BASE + pickerIndex * priorityStride; - } - } - let finalRoutedEntries = [...admittedRoutedEntries, ...preservedRoutedEntries]; - finalRoutedEntries = finalRoutedEntries.filter(entry => { - const slug = typeof entry.slug === "string" ? entry.slug : ""; - if (!slug.includes("/")) return true; - if (disabledModelKeys.has(slugEquivalenceKey(slug))) return false; - // Provider allowlists own provider rows, not a current combo's public alias. Exempt only an - // identity from this gather's generated combo projection: provider discovery may supply a - // spoofed `owned_by`, and persisted combo-shaped rows are not fresh authority. - if (freshExactComboEntries.has(entry)) return true; - const slash = slug.indexOf("/"); - const provider = slug.slice(0, slash); - if (pendingProviderNames.has(provider)) return false; - const selected = selectedModelKeysByProvider.get(provider); - return selected === undefined || selected.has(slugEquivalenceKey(slug)); - }); - if (!hasPhysicalComboProvider) { - finalRoutedEntries = finalRoutedEntries.filter(entry => { - const slug = typeof entry.slug === "string" ? entry.slug : ""; - const comboOwned = slug.startsWith(`${COMBO_NAMESPACE}/`) || entry.owned_by === COMBO_NAMESPACE; - const retainedNativeAlias = isNativeAliasCatalogEntry(entry) && exactComboSlugs.has(slug); - return !comboOwned || freshSlugs.has(slug) || retainedNativeAlias; - }); - } - finalRoutedEntries = finalRoutedEntries.filter(entry => { - const slug = typeof entry.slug === "string" ? entry.slug : ""; - const retainedNativeAlias = isNativeAliasCatalogEntry(entry) && exactComboSlugs.has(slug); - return retainedNativeAlias - || !isExactComboCatalogEntry(entry, exactComboSlugs) - || (Array.isArray(entry.input_modalities) && entry.input_modalities.length > 0); - }); - // Reapply final catalog policy to rows preserved from disk. Those rows bypass - // gatherRoutedModels, so filtering only the freshly gathered list can resurrect an excluded id. - finalRoutedEntries = finalRoutedEntries.filter(entry => - typeof entry.slug !== "string" || !isRoutedModelCompatibilityExcluded(entry.slug) - ); - const accountBoundSlugs = new Set(alignedAccountBoundEntries.flatMap(entry => - typeof entry.slug === "string" ? [entry.slug] : [] - )); - finalRoutedEntries = finalRoutedEntries.filter(entry => { - if (typeof entry.slug !== "string" || !accountBoundSlugs.has(entry.slug)) return true; - if (freshSlugs.has(entry.slug) && policy.warningPolicy === "emit") { - warnAccountSelectorShadowedProviderOnce(entry.slug); - } - return false; - }); - const finalRoutedEntrySet = new Set(finalRoutedEntries); - const degradedPreservedCount = preservedRoutedEntries.filter(entry => { - if (!finalRoutedEntrySet.has(entry)) return false; - const slug = entry.slug as string; - const provider = slug.slice(0, slug.indexOf("/")); - return gatheredProviderNames.has(provider) && degradedProviderNames.has(provider); - }).length; - if (degradedPreservedCount > 0 && policy.warningPolicy === "emit") { - console.warn(`[opencodex] catalog sync: provider discovery degraded; preserving ${degradedPreservedCount} existing routed entr${degradedPreservedCount === 1 ? "y" : "ies"} on disk.`); - } - - const managedEntries = [...finalRoutedEntries, ...alignedAccountBoundEntries]; - const observedNativeSlugs = new Set(alignedAccountBoundEntries.flatMap(entry => { - const slug = trustedAccountBoundNativeCatalogSlug(entry); - return slug === undefined ? [] : [slug]; - })); - for (const slug of policy.nativeBackfillSlugs) observedNativeSlugs.add(slug); - const mergedEntries = [...native, ...managedEntries].map(m => { - const reserveProjection = isReserveCatalogProjection(m); - const normalized = reserveProjection ? m : normalizeServiceTiers(m); - if (!reserveProjection && !isNativeAliasCatalogEntry(normalized)) applyNativeOpenAiContextOverride(normalized, openaiContextCap); - const exactCombo = isExactComboCatalogEntry(m, exactComboSlugs); - const e = reserveProjection ? normalized : ensureStrictCatalogFields(normalized, { - preserveExactInputModalities: exactCombo, - isRouted: finalRoutedEntrySet.has(m), - }); - // Mock-max universality (260709): preserved routed entries from disk may predate - // the max rung — ensure it here so subagent max spawns validate on every - // reasoning-capable entry. max only: 5.6 exact ladders (luna: no ultra) stay intact. - if (!freshCustomEntries.has(m) && !exactCombo && !reserveProjection && !String(e.slug ?? "").startsWith("opencode-go/")) { - const levels = Array.isArray(e.supported_reasoning_levels) - ? e.supported_reasoning_levels as Array<{ effort?: string }> - : []; - if (levels.length > 0 && !levels.some(level => level.effort === "max")) { - levels.push(CODEX_REASONING_LEVELS.find(level => level.effort === "max") - ?? { effort: "max", description: "Maximum reasoning depth for the hardest problems" }); - e.supported_reasoning_levels = levels; - } - } - if (wsEnabled) e.supports_websockets = true; - else { - delete e.supports_websockets; - // Match buildCatalogEntries: never advertise a websocket preference while WS is off. - delete e.prefer_websockets; - } - return e; - }); - // Native enable/disable runs as the LAST pass so the upstream-upgrade branch above can never - // clobber a hide flag back to list. Bare ids disable every account clone; qualified ids disable - // only their generated account row. - const versionedEntries = applyMultiAgentMode( - applyNativeVisibility(mergedEntries, disabledModels, alignedAccountBoundEntries.length > 0, observedNativeSlugs), - multiAgentMode, - multiAgentV2Enabled, - { keepNativeChatGptOnV1, preserveDefaultMultiAgentVersion: isReserveCatalogProjection }, - ); - applyFullModelPickerOrder(versionedEntries, modelPickerOrder); - for (const entry of versionedEntries) { - // Templates and account clones must not inherit the native row's overlay marker. - delete entry.opencodex_native_display_name; - const slug = recoverableNativeSlug(entry); - if (slug !== null) { - const label = nativeDisplayNames && Object.hasOwn(nativeDisplayNames, slug) - ? nativeDisplayNames[slug]?.trim() : undefined; - if (label && label !== entry.display_name) { - entry.opencodex_native_display_name = { slug, original: entry.display_name, applied: label }; - entry.display_name = label; - } - } - const kind = entry.opencodex_catalog_kind; - if (trustedAccountBoundNativeCatalogSlug(entry) === undefined - && kind !== CODEX_CUSTOM_MODEL_CATALOG_KIND - && kind !== CODEX_PROVIDER_MODEL_CATALOG_KIND) continue; - // Canonicalize extension-field order after every normalizer. This keeps an unchanged catalog - // byte-idempotent whether an owned row was freshly built or retained from the prior pass. - delete entry.opencodex_catalog_kind; - entry.opencodex_catalog_kind = kind; - } - return versionedEntries; -} - -/** Merge retained-sync rows using the process-observed Codex feature state. */ -export function mergeCatalogEntriesForSync( - catalogModels: RawEntry[], - routedEntries: RawEntry[], - baseline: Map, - featured: string[], - wsEnabled: boolean, - _goIds: Set = new Set(), - template: RawEntry | null = null, - disabledModels: ReadonlySet = new Set(), - gatheredProviderNames?: Set, - multiAgentMode: MultiAgentMode = "default", - exactComboSlugs: ReadonlySet = new Set(), - hasPhysicalComboProvider = false, - includeNativeOpenAi = true, - accountBoundEntries: readonly RawEntry[] = [], - legacyCustomModelSlugs: ReadonlySet = new Set(), - suppressedBareNativeSlugs: ReadonlySet = new Set( - routedEntries.flatMap(entry => ( - isNativeAliasCatalogEntry(entry) && typeof entry.slug === "string" ? [entry.slug] : [] - )), - ), - openaiContextCap?: NativeContextLimitsInput, - keepNativeChatGptOnV1 = false, -): RawEntry[] { - // Retained for source compatibility with the original helper contract. Raw provider ids must - // not suppress same-named native rows; actual admitted combo entries own that decision now. - void _goIds; - const effectiveGatheredProviderNames = gatheredProviderNames ?? new Set( - routedEntries.flatMap(entry => { - // A slashed combo alias is not evidence that its public prefix is an authoritative provider - // namespace. Treating it as one would let the combo replace an unrestorable foreign row. - if (isExactComboCatalogEntry(entry, exactComboSlugs)) return []; - const slug = typeof entry.slug === "string" ? entry.slug : ""; - const slash = slug.indexOf("/"); - return slash > 0 ? [slug.slice(0, slash)] : []; - }), - ); - return mergeCatalogEntriesFromObservedState({ - catalogModels, - baselineCatalogModels: [], - routedEntries, - baseline, - featured, - wsEnabled, - template, - disabledModels, - selectedModelsByProvider: new Map(), - gatheredProviderNames: effectiveGatheredProviderNames, - degradedProviderNames: new Set(), - legacyCustomModelSlugs, - multiAgentMode, - multiAgentV2Enabled: isMultiAgentV2Enabled(), - keepNativeChatGptOnV1, - exactComboSlugs, - hasPhysicalComboProvider, - includeNativeOpenAi, - accountBoundEntries, - suppressedBareNativeSlugs, - openaiContextCap, - policy: { - ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, - warningPolicy: "emit", - }, - }); -} - -interface RetainedCatalogSyncRead { - readonly catalogPath: string; - readonly catalog: RawCatalog; - readonly onDiskCatalog: RawCatalog | null; - readonly modelsCache: RawCatalog | null; - readonly evidence: string; - /** - * Process-local epochs, baselined AFTER our own gather rather than with the - * filesystem bytes above. See `retainedCatalogProcessEvidence`. - */ - readonly processEvidence: string; -} - -interface RetainedCatalogSyncResult { - added: number; - path: string; - catalogWritten: boolean; - comboOmissions: ComboCatalogOmission[]; - /** Validated catalog commit (including identical bytes), or a refused refresh. */ - refreshOutcome?: "committed" | "refused"; - /** `desired_disabled` observed under K after the provider await; nothing was written. */ - skippedReason?: "desired_disabled"; -} - -/** - * Catalog/cache commit overrides. - * - * An explicit `ocx sync` is also the refresh path for side profiles that consume - * the OpenCodex catalog without injection (for example a custom `model_provider` - * that routes to the proxy). In that mode the Codex integration toggle only - * governs config/history injection; the catalog and models cache may still be - * refreshed, so `allowWhenDesiredDisabled` lets the commit path ignore the OFF - * gate that otherwise protects a fully native home. - */ -export interface CodexCatalogSyncOptions { - allowWhenDesiredDisabled?: boolean; -} - -interface RetainedCatalogSyncWrite { - readonly config: OcxConfig; - readonly goModels: CatalogModel[]; - readonly providerModelOutcomes: readonly CatalogGatherProviderModelOutcome[]; - readonly comboOmissions: ComboCatalogOmission[]; - readonly read: RetainedCatalogSyncRead; - readonly permit: CatalogWritePermit; - readonly owningCodexHome: string; - readonly modelEntitlements: CodexModelEntitlementSnapshot; -} - -function optionalFileBytes(path: string): string | null { - try { - return readFileSync(path).toString("base64"); - } catch (error) { - if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return null; - throw error; - } -} - -function loadCatalogForRetainedSync(path: string): RawCatalog | null { - const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null; - if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog; - const active = readCatalog(path); - // A valid configured custom file remains the content authority even when it has no bare native - // template. The null-template builder is deliberate; a stale backup must not replace active - // custom root metadata merely because the current file contains only routed rows. - if (active && (!isDefaultCatalogPath(path) || findNativeTemplate(active))) return active; - return readCatalog(catalogBackupPathFor(path)) - ?? (isDefaultCatalogPath(path) ? readCatalog(legacyCatalogBackupPath()) : null) - ?? readCatalog(activeCodexModelsCachePath()) - ?? active; -} - -function retainedCatalogSyncEvidence( - config: OcxConfig, - catalogPath: string, - catalog: RawCatalog, -): string { - return JSON.stringify({ - config, - catalogPath, - catalog, - catalogBytes: optionalFileBytes(catalogPath), - hashedBackupBytes: optionalFileBytes(catalogBackupPathFor(catalogPath)), - legacyBackupBytes: isDefaultCatalogPath(catalogPath) - ? optionalFileBytes(legacyCatalogBackupPath()) : null, - modelsCacheBytes: optionalFileBytes(activeCodexModelsCachePath()), - // The persisted runtime selection is a pre-await filesystem input, not a - // process epoch: another PROCESS can move runtime authority by rewriting this - // file, and that move is invisible to our in-process memo. Recorded PRESENT or - // ABSENT, because its absence is what makes the resolver fall back. - runtimeStateBytes: optionalFileBytes(codexRuntimeStatePath()), - }); -} - -/** - * The bundled-template half of the same evidence, observed separately. - * - * The runtime process memo is deliberately NOT here, and that exclusion took three - * attempts to get honest. Gathering resolves the Codex runtime lazily and under its - * own cache key, so this path cannot pre-settle that memo: baselining it before the - * await always detected our own side effect and refused every write, and baselining - * it after the await captured a runtime that ANOTHER process had moved as though it - * were ours — a catalog prepared from R1 committing after authority reached R2. - * - * Runtime authority is covered where it is actually durable instead: the persisted - * `codex-runtime.json` bytes sit in the pre-await filesystem evidence, PRESENT or - * ABSENT, so a cross-process runtime move is caught. What is left uncovered, and is - * written down rather than papered over, is a same-process in-memory runtime swap - * that never touches that file — WP11 owns the lock that makes that case decidable. - */ -function retainedCatalogProcessEvidence(): string { - return JSON.stringify({ - bundledCatalogCache: bundledCatalogCacheState(), - }); -} - -/** - * Capture every local catalog input the retained sync path consults before its - * provider await. The exact evidence is compared after K acquisition; a newer - * catalog/backup/cache or target selection makes this attempt a no-write. - */ -function readRetainedCatalogSync(config: OcxConfig): RetainedCatalogSyncRead | null { - const catalogPath = readCodexCatalogPath(); - const catalog = loadCatalogForRetainedSync(catalogPath); - if (!catalog) return null; - - // The bundled catalog is a reliable native template on the default path, but it is not the - // merge source. Preservation must inspect the file that this sync is about to overwrite; - // otherwise an empty/partial provider gather cannot see routed or user-native rows on disk. - const onDiskCatalog = readCatalog(catalogPath); - const modelsCache = readCatalog(activeCodexModelsCachePath()); - const evidence = retainedCatalogSyncEvidence(config, catalogPath, catalog); - // `processEvidence` is filled in after the provider await, not here. - return { catalogPath, catalog, onDiskCatalog, modelsCache, evidence, processEvidence: "" }; -} - -function revalidateRetainedCatalogSync( - config: OcxConfig, - prepared: RetainedCatalogSyncRead, -): RetainedCatalogSyncRead | null { - const catalogPath = readCodexCatalogPath(); - if (catalogPath !== prepared.catalogPath) return null; - const evidence = retainedCatalogSyncEvidence(config, catalogPath, prepared.catalog); - if (evidence !== prepared.evidence) return null; - if (retainedCatalogProcessEvidence() !== prepared.processEvidence) return null; - return { - catalogPath, - catalog: JSON.parse(JSON.stringify(prepared.catalog)) as RawCatalog, - onDiskCatalog: readCatalog(catalogPath), - modelsCache: readCatalog(activeCodexModelsCachePath()), - evidence, - processEvidence: prepared.processEvidence, - }; -} - -/** - * Exact bytes currently on disk at `path`, or null when unreadable/absent. - * - * Deliberately a Buffer rather than a decoded string: `readFileSync(path, "utf8")` - * substitutes U+FFFD for every invalid byte, so a file holding a raw 0x80 decodes - * equal to prepared content holding a legitimately encoded U+FFFD. Comparing the - * decoded strings would then classify a malformed catalog as identical, skip the - * atomic repair write, and leave the corruption on disk while reporting - * `catalogWritten: false`. - */ -function currentCatalogFileContent(path: string): Buffer | null { - try { - return readFileSync(path); - } catch { - return null; - } -} - -function pristineCatalogBytes(read: RetainedCatalogSyncRead): string | null { - if (read.onDiskCatalog && !catalogHasRoutedEntries(read.onDiskCatalog)) { - try { - return readFileSync(read.catalogPath, "utf8"); - } catch { - return null; - } - } - return catalogHasRoutedEntries(read.catalog) - ? null - : `${JSON.stringify(read.catalog, null, 2)}\n`; -} - -function catalogModelsForMergeWithNativeRecovery( - catalogPath: string, - catalog: RawCatalog, - onDiskCatalog: RawCatalog | null, -): RawEntry[] { - const primaryCatalogModels = onDiskCatalog?.models ?? catalog.models ?? []; - // Native-alias compatibility can omit disabled native rows from the effective catalog because - // Desktop's remote allowlist ignores `visibility: "hide"`. Keep current/pristine native recovery - // sources beside the on-disk rows so re-enabling a model restores its real metadata. Routed and - // user-authored rows still come only from the on-disk catalog. - return mergeCatalogModelsWithNativeRecovery(primaryCatalogModels, [ - catalog.models ?? [], - readCatalogBackup(catalogPath)?.models ?? [], - ]); -} - -const AUTO_REVIEW_ROOT_MARKER = "opencodex_auto_review_root"; - -interface RootAutoReviewStamp { - slug: string; - original: string | null; - applied: string; -} - -function rootAutoReviewStamp(entry: RawEntry): RootAutoReviewStamp | undefined { - const value = entry[AUTO_REVIEW_ROOT_MARKER]; - if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; - const stamp = value as Record; - if (stamp.slug !== entry.slug || typeof stamp.slug !== "string" - || typeof stamp.applied !== "string" - || (stamp.original !== null && typeof stamp.original !== "string")) return undefined; - return stamp as unknown as RootAutoReviewStamp; -} - - -/** True when the value is a valid Codex catalog auto-review selector. */ -export function isValidAutoReviewModel(value: unknown): value is string { - return isValidAutoReviewTarget(value); -} - -export type AutoReviewModelOverrideResult = "absent" | "applied" | "invalid" | "unresolved"; - -/** True when a catalog row was synthesized by opencodex instead of coming from upstream. */ -function isRoutedCatalogEntry(entry: RawEntry): boolean { - const slug = typeof entry.slug === "string" ? entry.slug : ""; - return slug.includes("/") - || (typeof entry.description === "string" && entry.description.startsWith("Routed via opencodex → ")); -} - -/** Restore an owned native value, retaining provenance to avoid legacy reclassification. */ -function clearAutoReviewOverrideValue(entry: RawEntry): void { - const stamp = rootAutoReviewStamp(entry); - if (stamp) { - if (entry.auto_review_model_override === stamp.applied) entry.auto_review_model_override = stamp.original; - } else { - entry.auto_review_model_override = null; - delete entry[AUTO_REVIEW_ROOT_MARKER]; - } -} - -/** - * Legacy whole-catalog root stamp: releases before AUTO_REVIEW_ROOT_MARKER wrote root stamps that - * are textually identical to an upstream value, so the only way to recognize one is the uniform - * signature the no-provider path relies on — a single value that a routed row also carries. - * Returns the stamped values when the observed rows match that shape. - */ -function legacyRootStampValues(observedModels: readonly RawEntry[]): ReadonlySet | undefined { - if (observedModels.some(entry => entry?.[AUTO_REVIEW_ROOT_MARKER] !== undefined)) return undefined; - const configuredValues = new Set(observedModels.flatMap(entry => { - const value = entry?.auto_review_model_override; - return typeof value === "string" && value.trim() ? [value] : []; - })); - const globalStamp = configuredValues.size === 1 - && observedModels.some(entry => { - const value = entry.auto_review_model_override; - return isRoutedCatalogEntry(entry) - && typeof value === "string" - && value.trim().length > 0 - && configuredValues.has(value); - }) - && observedModels.every(entry => { - const value = entry?.auto_review_model_override; - return value === null - || value === undefined - || (typeof value === "string" && configuredValues.has(value)); - }); - return globalStamp ? configuredValues : undefined; -} - -/** - * Sweep legacy root stamps off the rows a root removal owns, before provider plans land. - * - * Root removal reaches marker-tagged native rows on its own, but a catalog written before the - * marker only carries the legacy signature — and provider stamping rewrites that signature before - * the root pass could read it, so the sweep has to run first. - */ -function clearLegacyRootStamps(models: readonly RawEntry[], sourceModels: readonly RawEntry[] = []): void { - const legacyStamp = legacyRootStampValues([...models, ...sourceModels]); - if (legacyStamp === undefined) return; - for (const entry of models) { - if (!entry || typeof entry !== "object") continue; - const current = entry.auto_review_model_override; - if (entry[AUTO_REVIEW_ROOT_MARKER] === undefined - && typeof current === "string" && legacyStamp.has(current)) clearAutoReviewOverrideValue(entry); - } -} - -/** - * Clear the root selector from every row this path owns: routed rows, rows stamped by a release - * that writes the provenance marker, and the legacy whole-catalog stamp that predates it. - */ -function clearAutoReviewModelOverride( - models: readonly RawEntry[], - sourceModels: readonly RawEntry[] = [], -): void { - const legacyStamp = legacyRootStampValues([...models, ...sourceModels]); - for (const entry of models) { - if (!entry || typeof entry !== "object") continue; - const current = entry.auto_review_model_override; - if (isRoutedCatalogEntry(entry) - || (entry[AUTO_REVIEW_ROOT_MARKER] === true || rootAutoReviewStamp(entry) !== undefined) - || (legacyStamp !== undefined && typeof current === "string" && legacyStamp.has(current))) { - clearAutoReviewOverrideValue(entry); - } - } -} - -/** Warn once about a malformed or unresolvable root auto-review selector. */ -function warnAutoReviewModelDiagnostic( - reason: "invalid" | "unresolved", - configured: string, -): void { - const safeConfigured = JSON.stringify(redactSecretString(configured)); - const detail = reason === "unresolved" - ? "the selector was not found in the final catalog" - : "the selector format is invalid"; - console.warn( - `[opencodex] auto_review_model ${detail} (${safeConfigured}); preserving normal upstream auto-review behavior.`, - ); -} - -/** Warn once about a malformed or unresolvable provider-scoped auto-review selector. */ -function warnProviderAutoReviewModelDiagnostic( - reason: "invalid" | "unresolved", - provider: string, - configured: string, -): void { - const safeProvider = JSON.stringify(redactSecretString(provider)); - const safeConfigured = JSON.stringify(redactSecretString(configured)); - const detail = reason === "unresolved" - ? "the selector was not found in the final catalog" - : "the selector format is invalid"; - console.warn( - `[opencodex] auto_review_model for provider ${safeProvider} ${detail} (${safeConfigured}); using the next valid provider/root selector or upstream behavior.`, - ); -} - -/** - * Note once when a bare selector resolves to a row outside the provider it was configured on. - * - * That is how a native model is named as a reviewer, so it stays usable, but a mistyped target must - * not be silent: the operator sees which catalog row actually supplies the reviewer. - */ -function warnProviderAutoReviewForeignTarget(provider: string, configured: string, target: string): void { - const safeProvider = JSON.stringify(redactSecretString(provider)); - const safeConfigured = JSON.stringify(redactSecretString(configured)); - const safeTarget = JSON.stringify(redactSecretString(target)); - console.warn( - `[opencodex] auto_review_model for provider ${safeProvider} (${safeConfigured}) resolved to ${safeTarget}, which is not a row of that provider; that catalog row supplies the reviewer.`, - ); -} - -/** Preserve native upstream overrides and the root-derived provenance marker from source rows. */ -function preserveNativeAutoReviewModelOverrides( - models: readonly RawEntry[], - sourceModels: readonly RawEntry[], -): void { - const existing = new Map(); - for (const entry of sourceModels) { - const slug = typeof entry.slug === "string" ? entry.slug : undefined; - const value = entry.auto_review_model_override; - if (!slug || isRoutedCatalogEntry(entry)) continue; - if (typeof value === "string" || value === null) { - existing.set(slug, { value, root: rootAutoReviewStamp(entry) ?? (entry[AUTO_REVIEW_ROOT_MARKER] === true ? true : undefined) }); - } - } - for (const entry of models) { - const slug = typeof entry.slug === "string" ? entry.slug : undefined; - if (!slug || isRoutedCatalogEntry(entry) || !existing.has(slug)) continue; - const saved = existing.get(slug)!; - entry.auto_review_model_override = saved.value; - if (saved.root) entry[AUTO_REVIEW_ROOT_MARKER] = structuredClone(saved.root); - else delete entry[AUTO_REVIEW_ROOT_MARKER]; - } -} - -/** Stamp a root-derived override and mark native rows so later root removal is durable. */ -function stampRootAutoReviewOverride(entry: RawEntry, target: string): void { - if (!isRoutedCatalogEntry(entry)) { - const previous = rootAutoReviewStamp(entry); - const current = entry.auto_review_model_override; - entry[AUTO_REVIEW_ROOT_MARKER] = { - slug: typeof entry.slug === "string" ? entry.slug : "", - original: previous && current === previous.applied - ? previous.original : typeof current === "string" ? current : null, - applied: target, - } satisfies RootAutoReviewStamp; - } else { - delete entry[AUTO_REVIEW_ROOT_MARKER]; - } - entry.auto_review_model_override = target; -} - -/** Stamp a provider-derived override; provider stamps never fall under root removal. */ -function stampProviderAutoReviewOverride(entry: RawEntry, target: string): void { - entry.auto_review_model_override = target; - delete entry[AUTO_REVIEW_ROOT_MARKER]; -} - -/** - * Apply the root Codex auto-review selector to every catalog row, or clear it when the value is - * absent, blank, malformed, or does not resolve against the assembled catalog. - */ -export function applyAutoReviewModelOverride( - models: RawEntry[] | undefined, - autoReviewModel: string | null | undefined, - sourceModels: readonly RawEntry[] = [], -): AutoReviewModelOverrideResult { - if (!models || !Array.isArray(models)) return "absent"; - if (autoReviewModel === null || autoReviewModel === undefined) { - clearAutoReviewModelOverride(models, sourceModels); - return "absent"; - } - const trimmed = autoReviewModel.trim(); - if (!trimmed) { - clearAutoReviewModelOverride(models, sourceModels); - return "absent"; - } - if (!isValidAutoReviewModel(trimmed)) { - clearAutoReviewModelOverride(models, sourceModels); - warnAutoReviewModelDiagnostic("invalid", trimmed); - return "invalid"; - } - if (!configuredCatalogEntry(models, trimmed)) { - clearAutoReviewModelOverride(models, sourceModels); - warnAutoReviewModelDiagnostic("unresolved", trimmed); - return "unresolved"; - } - for (const entry of models) { - if (entry && typeof entry === "object") { - stampRootAutoReviewOverride(entry, trimmed); - } - } - return "applied"; -} - -/** Validated provider-scoped target with both the configured spelling and catalog slug. */ -interface ValidProviderReviewTarget { - configured: string; - target: string; -} - -/** One provider's resolved provider-wide and per-model auto-review targets. */ -interface ProviderReviewPlan { - wide?: ValidProviderReviewTarget; - perModel: Map; -} - -/** Public provider namespace of a routed catalog row, when it has one. */ -function catalogEntryProviderName(entry: RawEntry): string | undefined { - const slug = typeof entry.slug === "string" ? entry.slug : ""; - const slash = slug.indexOf("/"); - return slash > 0 && isRoutedCatalogEntry(entry) ? slug.slice(0, slash) : undefined; -} - -/** Encoded model-id segment of a routed catalog row, when it has one. */ -function catalogEntryModelSegment(entry: RawEntry): string | undefined { - const slug = typeof entry.slug === "string" ? entry.slug : ""; - const slash = slug.indexOf("/"); - return slash > 0 ? slug.slice(slash + 1) : undefined; -} - -/** Case-preserving encoded key used to match per-model override maps. */ -function providerModelKey(modelId: string): string { - return canonicalAutoReviewModelKey(modelId); -} - -/** - * True when another routed row of this provider already carries `alias` as its own model id. - * - * The alias API validates against whatever ids discovery has reported so far, so on a cold start an - * alias can be persisted that later turns out to name a different row. A key using it is then not - * an alternate spelling of the aliased model — it is that row's id — and must not be propagated. - */ -function aliasNamesAnotherRoutedRow(models: readonly RawEntry[], provider: string, alias: string): boolean { - const encoded = encodeRoutedModelId(alias); - return models.some(entry => isRoutedCatalogEntry(entry) - && catalogEntryProviderName(entry) === provider - && catalogEntryModelSegment(entry) === encoded); -} - -/** Resolve one configured target against the assembled catalog; bare values name a model of the same provider. */ -function resolveProviderReviewTarget( - models: readonly RawEntry[], - provider: string, - configuredRaw: unknown, -): { kind: "valid"; value: ValidProviderReviewTarget; foreign?: boolean } | { kind: "invalid"; configured: string } | { kind: "unresolved"; configured: string } | { kind: "absent" } { - if (typeof configuredRaw !== "string") return { kind: "absent" }; - const configured = configuredRaw.trim(); - if (!configured) return { kind: "absent" }; - if (!isValidAutoReviewModel(configured)) return { kind: "invalid", configured }; - const prefix = `${provider}/`; - let match: RawEntry | undefined; - const sameProviderCandidate = (rawModelId: string): RawEntry | undefined => models.find(entry => { - if (!isRoutedCatalogEntry(entry) || typeof entry.slug !== "string" || !entry.slug.startsWith(prefix)) return false; - const segment = catalogEntryModelSegment(entry); - return segment !== undefined && segment === encodeRoutedModelId(rawModelId); - }); - // A bare selector names a model of this provider. A full selector that resolves in the - // assembled catalog already names the exact row, including a same-provider encoded slug. - if (!configured.includes("/")) { - match = sameProviderCandidate(configured); - } - match ??= configuredCatalogEntry(models, configured); - if (!match && configured.startsWith(prefix)) { - match = sameProviderCandidate(configured.slice(prefix.length)); - } - if (!match) { - // A raw model id may itself contain "/" (for example zenmux moonshotai/kimi-k3). - // After the full-selector lookup misses, try that spelling as a same-provider id. - match = sameProviderCandidate(configured); - } - if (!match) return { kind: "unresolved", configured }; - const target = typeof match.slug === "string" ? match.slug : configured; - // A qualified selector may name another provider's row on purpose; only a bare value that lands - // outside this provider is worth reporting. - const foreign = !configured.includes("/") && catalogEntryProviderName(match) !== provider; - return { kind: "valid", value: { configured, target }, ...(foreign ? { foreign: true } : {}) }; -} - -/** Build resolved per-provider plans and emit one diagnostic per bad selector. */ -function buildProviderReviewPlans( - models: readonly RawEntry[], - config: Pick, -): { plans: Map; failure?: "invalid" | "unresolved" } { - const plans = new Map(); - let failure: "invalid" | "unresolved" | undefined; - const warned = new Set(); - const recordFailure = (kind: "invalid" | "unresolved", provider: string, configured: string): void => { - const signature = `${provider}\u0000${configured}`; - if (warned.has(signature)) return; - warned.add(signature); - warnProviderAutoReviewModelDiagnostic(kind, provider, configured); - failure ??= kind; - }; - const recordForeignTarget = (provider: string, configured: string, target: string): void => { - const signature = `${provider}\u0000foreign\u0000${configured}`; - if (warned.has(signature)) return; - warned.add(signature); - warnProviderAutoReviewForeignTarget(provider, configured, target); - }; - for (const [name, provider] of Object.entries(config.providers ?? {})) { - if (provider.autoReviewModel === undefined && provider.autoReviewModelOverrides === undefined) continue; - const plan: ProviderReviewPlan = { perModel: new Map() }; - if (provider.autoReviewModel !== undefined) { - const resolved = resolveProviderReviewTarget(models, name, provider.autoReviewModel); - if (resolved.kind === "valid") { - plan.wide = resolved.value; - if (resolved.foreign) recordForeignTarget(name, resolved.value.configured, resolved.value.target); - } - else if (resolved.kind !== "absent") recordFailure(resolved.kind, name, resolved.configured); - } - if (provider.autoReviewModelOverrides !== undefined) { - for (const [modelId, rawTarget] of Object.entries(provider.autoReviewModelOverrides)) { - const resolved = resolveProviderReviewTarget(models, name, rawTarget); - if (resolved.kind === "valid") { - plan.perModel.set(providerModelKey(modelId), resolved.value); - if (resolved.foreign) recordForeignTarget(name, resolved.value.configured, resolved.value.target); - } else if (resolved.kind !== "absent") { - recordFailure(resolved.kind, name, resolved.configured); - } - } - } - // `modelAliases` publishes a second public name for a model id, and a routed row's slug always - // carries the upstream id — so accept an override key written in either spelling. - for (const [modelId, alias] of Object.entries(provider.modelAliases ?? {})) { - if (typeof alias !== "string" || !alias.trim()) continue; - if (aliasNamesAnotherRoutedRow(models, name, alias)) continue; - const idKey = providerModelKey(modelId); - const aliasKey = providerModelKey(alias); - if (idKey === aliasKey) continue; - const fromId = plan.perModel.get(idKey); - const fromAlias = plan.perModel.get(aliasKey); - if (fromId !== undefined && fromAlias === undefined) plan.perModel.set(aliasKey, fromId); - else if (fromAlias !== undefined && fromId === undefined) plan.perModel.set(idKey, fromAlias); - } - if (plan.wide !== undefined || plan.perModel.size > 0) plans.set(name, plan); - } - return { plans, failure }; -} - -/** Apply or clear the root selector only on rows without a provider stamp. */ -function applyRootSelectorToRemaining( - models: readonly RawEntry[], - rootValue: string | null | undefined, - providerStamped: ReadonlySet, -): AutoReviewModelOverrideResult { - const clearRemaining = (): void => { - for (const entry of models) { - if (!entry || providerStamped.has(entry)) continue; - // Native rows written by releases before the root marker cannot be told apart from upstream - // values once provider stamps diverge. clearLegacyRootStamps sweeps the ones the legacy - // uniform signature still recognizes before provider plans land, because provider stamping - // destroys that signature; a catalog that no longer matches it needs a one-off manual sync. - if (isRoutedCatalogEntry(entry) || entry[AUTO_REVIEW_ROOT_MARKER] === true || rootAutoReviewStamp(entry)) clearAutoReviewOverrideValue(entry); - } - }; - if (rootValue === null || rootValue === undefined) { - clearRemaining(); - return "absent"; - } - const trimmed = rootValue.trim(); - if (!trimmed) { - clearRemaining(); - return "absent"; - } - if (!isValidAutoReviewModel(trimmed)) { - clearRemaining(); - warnAutoReviewModelDiagnostic("invalid", trimmed); - return "invalid"; - } - if (!configuredCatalogEntry(models, trimmed)) { - clearRemaining(); - warnAutoReviewModelDiagnostic("unresolved", trimmed); - return "unresolved"; - } - for (const entry of models) { - if (!entry || providerStamped.has(entry)) continue; - stampRootAutoReviewOverride(entry, trimmed); - } - return "applied"; -} - -/** Provider-aware variant: provider rows win and the root selector is the fallback. */ -export function applyConfiguredAutoReviewModelOverride( - models: RawEntry[] | undefined, - rootAutoReviewModel: string | null | undefined, - config: Pick, - sourceModels: readonly RawEntry[] = [], -): AutoReviewModelOverrideResult { - if (!models || !Array.isArray(models)) return "absent"; - // Runs unconditionally because the sweep only fires on the uniform legacy signature. A resolved - // root selector restamps every row it touches below, so the call is behavior-preserving there; - // with the root absent, invalid, or unresolved those clears are final — which is the point, and - // also the limit: the legacy heuristic cannot tell a root stamp from an identical upstream value. - clearLegacyRootStamps(models, sourceModels); - const { plans, failure } = buildProviderReviewPlans(models, config); - const providerStamped = new Set(); - for (const entry of models) { - if (!entry || typeof entry !== "object") continue; - const provider = catalogEntryProviderName(entry); - if (!provider) continue; - const plan = plans.get(provider); - if (!plan) continue; - const modelSegment = catalogEntryModelSegment(entry); - const perModel = modelSegment === undefined ? undefined : plan.perModel.get(providerModelKey(modelSegment)); - const selected = perModel ?? plan.wide; - if (!selected) continue; - stampProviderAutoReviewOverride(entry, selected.target); - providerStamped.add(entry); - } - const rootResult = applyRootSelectorToRemaining(models, rootAutoReviewModel, providerStamped); - const providerApplied = [...providerStamped].some(entry => typeof entry.auto_review_model_override === "string"); - if (providerApplied) { - if (rootResult === "invalid" || rootResult === "unresolved") return rootResult; - return failure ?? "applied"; - } - return failure ?? rootResult; -} - -/** True when any provider row configures a provider-scoped auto-review selector. */ -function configHasProviderAutoReview(config: Pick): boolean { - return Object.values(config.providers ?? {}).some(provider => - provider.autoReviewModel !== undefined || provider.autoReviewModelOverrides !== undefined); -} - -/** Apply the root Codex auto-review selector after the final catalog merge. */ -export function finalizeAutoReviewModelOverride( - models: RawEntry[] | undefined, - sourceModels: readonly RawEntry[] = [], - config?: Pick, -): AutoReviewModelOverrideResult { - if (models && sourceModels.length > 0) preserveNativeAutoReviewModelOverrides(models, sourceModels); - if (config && configHasProviderAutoReview(config)) { - return applyConfiguredAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), config, sourceModels); - } - return applyAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), sourceModels); -} -/** - * Why an account-gated native model stopped being offered, but only when the answer is one the - * operator can act on. - * - * Suppression is an omission: the row is never built, so there is no catalog entry for a reason - * to ride on and no downstream consumer that could explain it later. #4212's reporter watched - * their models disappear and reasonably concluded the proxy was broken, because every surface - * that changed said nothing about the account that caused it. - * - * Returns `undefined` for the ordinary case — an account that is simply not entitled to a gated - * model. That is the default state for most installations, it is not news, and warning about it - * on every sync would bury the one case that matters. A credential the operator must repair is - * the case that matters, so that is the only one this speaks up about. - * - * Accounts are named with the durable `p`-prefixed log label, the same identifier the dashboard - * shows, never the raw pool id or the email. - */ -export function gatedNativeReauthSuppressionReason(args: { - snapshot: CodexModelEntitlementSnapshot; - slug: string; - eligibleAccountIds?: ReadonlySet; - needsReauth: (accountId: string) => boolean; - label: (accountId: string) => string; -}): string | undefined { - const observed = [...args.snapshot.modelsByAccount.keys()] - .filter(accountId => !args.eligibleAccountIds || args.eligibleAccountIds.has(accountId)) - // Only accounts that could actually have served THIS model. An account upstream positively - // denied is not why the model is missing, and blaming it would send the operator to repair a - // credential that was never going to help. `unknown` has to stay in: an account whose roster - // could not be confirmed reports `unknown` rather than `granted`, and a credential stuck on - // a failed refresh is exactly that account. - .filter(accountId => ( - codexModelEntitlementStateForAccount(args.snapshot, accountId, args.slug) !== "denied" - )); - const stuck = observed.filter(accountId => args.needsReauth(accountId)); - if (stuck.length === 0) return undefined; - const names = stuck.map(accountId => args.label(accountId)).sort().join(", "); - return stuck.length === observed.length - ? `every Codex account that could serve it needs reauthentication (${names})` - : `${stuck.length} of ${observed.length} Codex accounts that could serve it need reauthentication (${names})`; -} - -/** Durable, operator-facing label for a pool account id; never the raw id or the email. */ -function gatedNativeAccountLabel(config: OcxConfig, accountId: string): string { - // Direct mode narrows eligibility to the native main credential, so this is the account most - // likely to be named here. `codexAuthContextLogLabel` calls it "main" everywhere else; hashing - // it into a `p`-prefixed digest would name the one account the operator cannot look up. - if (accountId === MAIN_CODEX_ACCOUNT_ID) return "main"; - const account = (config.codexAccounts ?? []).find(candidate => candidate.id === accountId); - return account ? codexAccountLogLabel(account) : fallbackCodexAccountLogLabel(accountId); -} - -const warnedGatedNativeSuppression = new Set(); - -/** Test seam: the warn-once memory is process-global, so a case needs to be able to clear it. */ -export function resetGatedNativeSuppressionWarningsForTests(): void { - warnedGatedNativeSuppression.clear(); -} - -function warnGatedNativeSuppressedOnce(slug: string, reason: string): void { - const signature = `${slug}\u0000${reason}`; - if (warnedGatedNativeSuppression.has(signature)) return; - warnedGatedNativeSuppression.add(signature); - console.warn( - `[opencodex] catalog sync: ${slug} is not being offered because ${reason}. ` - + "Sign in again to restore it.", - ); -} - -/** - * Mescla o catálogo retido com os modelos visíveis e as configurações atuais, - * incluindo os nomes nativos. Tenta preservar o backup original e usa a permissão - * de escrita para publicar o resultado apenas se os bytes mudarem, retornando - * a contagem de entradas roteadas e por conta, o caminho e o estado da gravação. - */ -function writeRetainedCatalogSync({ - config, - goModels, - providerModelOutcomes, - comboOmissions, - read, - permit, - owningCodexHome, - modelEntitlements, -}: RetainedCatalogSyncWrite): RetainedCatalogSyncResult { - const { catalogPath, catalog, onDiskCatalog } = read; - const catalogModelsForMerge = catalogModelsForMergeWithNativeRecovery( - catalogPath, - catalog, - onDiskCatalog, - ); - // Strict selector for template inheritance; the validity gate above keeps the broad one. - const template = findSupportedNativeTemplate(catalog); - - try { - // Once-only: preserve the PRISTINE pre-opencodex catalog as the native-priority baseline - // (later syncs would otherwise overwrite it with featured-modified priorities). - const pristine = pristineCatalogBytes(read); - if (pristine !== null) { - publishHashedCodexCatalogBackup(permit, owningCodexHome, { - path: catalogBackupPathFor(catalogPath), - content: pristine, - }); - if (isDefaultCatalogPath(catalogPath)) { - publishLegacyCodexCatalogBackup(permit, owningCodexHome, { - path: legacyCatalogBackupPath(), - content: pristine, - }); - } - } - } catch { /* backup best-effort */ } - - // Hide disabled models from Codex, then feature the chosen subagent models (native OR routed) - // by giving them the lowest priority — see buildCatalogEntries for why priority, not array order. - const enabledGo = filterCatalogVisibleModels(goModels, config); - const featured = config.subagentModels ?? []; - const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities - const modelPickerOrder = config.modelPickerOrder ?? []; - const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; - const exactComboSlugs = exactComboCatalogSlugs(config); - const bareEligibleAccountIds = providerCodexAccountMode( - OPENAI_CODEX_PROVIDER_ID, - config.providers[OPENAI_CODEX_PROVIDER_ID], - ) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined; - const availableBareGatedNativeSlugs = availableAccountGatedNativeModels( - modelEntitlements, - bareEligibleAccountIds, - ); - const availableAccountGatedNativeSlugs = availableAccountGatedNativeModels(modelEntitlements); - const availableBareNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => ( - !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug) - )); - const availableAccountNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => ( - !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableAccountGatedNativeSlugs.has(slug) - )); - const unavailableGatedNativeSlugs = new Set([...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(slug => ( - !availableBareGatedNativeSlugs.has(slug) - ))); - // #4212: this set is the whole record of a model vanishing, and it is a set of strings that - // nothing downstream ever asks a question of. Explain it here, while the entitlement snapshot - // that produced it is still in scope, because after this point the model is simply absent and - // no later surface can tell "never entitled" apart from "the account broke this morning". - for (const slug of unavailableGatedNativeSlugs) { - const reason = gatedNativeReauthSuppressionReason({ - snapshot: modelEntitlements, - slug, - eligibleAccountIds: bareEligibleAccountIds, - needsReauth: isAccountNeedsReauth, - label: accountId => gatedNativeAccountLabel(config, accountId), - }); - if (reason) warnGatedNativeSuppressedOnce(slug, reason); - } - const suppressedBareNativeSlugs = new Set([ - ...desktopAllowlistSuppressedNativeSlugs(config), - ...unavailableGatedNativeSlugs, - ]); - const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE); - const includeNativeOpenAi = shouldIncludeNativeOpenAi(config); - const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config); - // Both user levers. Passing only the cap here is what let a per-model window the dashboard - // had accepted get written back at full width in the on-disk catalog. - const openaiContextCap = nativeContextLimits(config); - const accountSelectors = includeAccountBoundNativeOpenAi - ? visibleCodexAccountSelectors(config) - : []; - const observedAccountNativeEntries = [ - ...(read.modelsCache?.models ?? []), - ...(onDiskCatalog?.models ?? []).filter(entry => - trustedAccountBoundNativeCatalogSlug(entry) !== undefined), - ]; - const accountTargets = new Map(codexAccountNamespaceEntries(config)); - const reserveMainSelectors = accountSelectors.filter(selector => - isMainCodexAccountTarget(accountTargets.get(selector) ?? "")); - // The active file can own a bare source even when the bundled catalog is the build base. - // A previously clamped qualified projection must not shorten a retained genuine ladder. - const reserveObservations = [ - ...(onDiskCatalog?.models ?? []), - ...(read.modelsCache?.models ?? []), - ...(catalog.models ?? []), - ]; - const retainedReserve = onDiskCatalog?.[RESERVE_SOURCE_CATALOG_FIELD]; - const retainedReserveSource = retainedReserve && typeof retainedReserve === "object" && !Array.isArray(retainedReserve) - ? observedReserveCatalogSource([retainedReserve as RawEntry], []) - : null; - const observedReserveSource = observedReserveCatalogSource( - // Cache invalidation carries historical bare observations alongside emitted models. - // Only unmarked observations are fresh enough to supersede the retained source. - reserveObservations.filter(entry => entry.slug === NATIVE_RESERVE_MODEL - && entry.opencodex_account_observed_native === undefined), reserveMainSelectors, - ) ?? retainedReserveSource ?? observedReserveCatalogSource(reserveObservations, reserveMainSelectors); - // This root is read only by OCX. Upstream ModelsResponse ignores unknown root fields. - // Retain before final runtime clamping: an omitted row must not turn into Luna next sync. - if (observedReserveSource) catalog[RESERVE_SOURCE_CATALOG_FIELD] = structuredClone(observedReserveSource); - else delete catalog[RESERVE_SOURCE_CATALOG_FIELD]; - const lunaSource = upstreamNativeEntry(RESERVE_LUNA_METADATA_SOURCE); - const reserve = createReserveCatalogProjection( - config, - reserveMainSelectors, - observedReserveSource, - lunaSource ? finishUpstreamNativeEntry(lunaSource, 9, openaiContextCap) : null, - ); - const accountNativeSlugsBySelector = accountSelectors.length > 0 - ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)].map(([selector, slugs]) => { - const target = accountTargets.get(selector); - const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target; - return [selector, slugs.filter(slug => ( - !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) - || (accountId !== undefined - && codexModelEntitlementStateForAccount(modelEntitlements, accountId, slug) === "granted") - ))] as const; - })) - : new Map(); - const accountNativeSlugs = accountSelectors.length > 0 - ? [...new Set([...accountNativeSlugsBySelector.values()].flatMap(slugs => [...slugs]))] - : []; - // Unknown account-native ids have no safe bare/global identity. They are only projected through - // the selector map above; the no-selector catalog remains the static native/API-key surface. - const observedNativeSlugs: string[] = []; - const wsEnabled = websocketsEnabled(config); - const multiAgentV2Enabled = isMultiAgentV2Enabled(); - const goEntries = buildCatalogEntriesFromObservedState({ - template: template ? JSON.parse(JSON.stringify(template)) : null, - gptSlugs: [], - goModels: orderedGoModels, - featured, - modelPickerOrder, - wsEnabled, - multiAgentMode, - exactComboSlugs, - accountSelectors, - suppressedBareNativeSlugs, - disabledNativeAccountSlugs: new Set(), - multiAgentV2Enabled, - openaiContextCap, - }); - // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append - // routed providers as namespaced slugs. Cursor and other adopted providers can expose model ids - // like `gpt-5.5`; those must not delete the native OpenAI/Codex base row. - const baselineCatalog = readCatalogBackup(catalogPath); - const baseline = readNativeBaseline(catalogPath); - const gatheredProviderNames = new Set( - Object.entries(config.providers ?? {}) - .filter(([, prov]) => prov.disabled !== true) - .map(([name]) => name), - ); - const degradedProviderNames = new Set( - providerModelOutcomes - .filter(outcome => outcome.state === "degraded") - .map(outcome => outcome.provider), - ); - const selectedModelsByProvider = new Map>( - Object.entries(config.providers ?? {}).flatMap(([name, provider]) => ( - provider.disabled !== true - && Array.isArray(provider.selectedModels) - && provider.selectedModels.length > 0 - ? [[name, new Set(provider.selectedModels)] as const] - : [] - )), - ); - // Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to - // native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a - // native template can never leak supports_websockets while the flag is off. - // #636: when the user only configured non-OpenAI providers (e.g. kimi), do not advertise - // bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no - // providers are configured yet (fresh install / catalog bootstrap tests). - const accountBoundEntries = includeAccountBoundNativeOpenAi && accountSelectors.length > 0 - ? buildCatalogEntriesFromObservedState({ - template: template ? JSON.parse(JSON.stringify(template)) : null, - gptSlugs: availableAccountNativeSlugs, - goModels: [], - featured, - wsEnabled, - multiAgentMode, - exactComboSlugs, - accountSelectors, - suppressedBareNativeSlugs, - disabledNativeAccountSlugs: new Set([...disabledNativeSlugs(config)].filter(slug => suppressedBareNativeSlugs.has(slug))), - multiAgentV2Enabled, - keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true, - openaiContextCap, - accountNativeSlugs, - accountNativeSlugsBySelector, - reserve, - }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) - : []; - catalog.models = mergeCatalogEntriesFromObservedState({ - modelPickerOrder, - accountSelectors, - catalogModels: catalogModelsForMerge, - baselineCatalogModels: baselineCatalog?.models ?? [], - routedEntries: goEntries, - baseline, - featured, - wsEnabled, - template, - disabledModels: new Set(config.disabledModels ?? []), - selectedModelsByProvider, - gatheredProviderNames, - pendingProviderNames: pendingModelSelectionProviders(config), - degradedProviderNames, - legacyCustomModelSlugs: legacyCustomModelCatalogSlugs(config), - multiAgentMode, - multiAgentV2Enabled, - keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true, - exactComboSlugs, - hasPhysicalComboProvider, - includeNativeOpenAi, - accountBoundEntries, - suppressedBareNativeSlugs, - openaiContextCap, - nativeDisplayNames: config.providers[OPENAI_CODEX_PROVIDER_ID]?.modelDisplayNames, - policy: { - ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, - nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs], - warningPolicy: "emit", - }, - }); - clampCatalogModelsToCodexSupport(catalog.models); - finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge, config); - - const added = goEntries.length + accountBoundEntries.length; - const content = `${JSON.stringify(catalog, null, 2)}\n`; - // A byte-identical rewrite is not a catalog change, but every mtime-keyed reader - // has to treat it as one. The app-server staleness classifier (#857) is the one - // that matters: it compares this file's mtime against each running Codex's start - // time, so an ordinary `ocx start` — or any dashboard action that re-syncs an - // unchanged model set — marked every already-running Codex as holding an outdated - // in-memory catalog. Since #1407 that verdict silences opencodex's own model - // guidance entirely (no preferred model, no roster) for the rest of that Codex's - // lifetime, so a configured injectionModel stops reaching the session even though - // nothing about the catalog changed. Skipping the no-op write keeps both the mtime - // and `catalogWritten` honest; `added` still reports the routed rows the catalog - // carries, because they are on disk either way. - const onDiskBytes = currentCatalogFileContent(catalogPath); - if (onDiskBytes !== null && onDiskBytes.equals(Buffer.from(content, "utf8"))) { - return { added, path: catalogPath, catalogWritten: false, comboOmissions }; - } - - replaceActiveCodexCatalog(permit, owningCodexHome, { - path: catalogPath, - content, - }); - return { - added, - path: catalogPath, - catalogWritten: true, - comboOmissions, - }; -} - -function visibleAccountReplacementNatives( - models: readonly RawEntry[], - disabledModels: ReadonlySet | null, -): Map { - const replacements = new Map(); - for (const entry of models) { - const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry); - if (nativeSlug === undefined || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug)) continue; - const exactSlug = typeof entry.slug === "string" ? entry.slug : ""; - const visible = entry.visibility === "list" - || (disabledModels !== null - && (disabledModels.has(nativeSlug) || disabledModels.has(exactSlug))); - replacements.set(nativeSlug, (replacements.get(nativeSlug) ?? true) && visible); - } - return replacements; -} - -function restoreAccountHiddenBareNatives( - entries: readonly RawEntry[], - replacementVisibility: ReadonlyMap, - disabledModels: ReadonlySet | null, -): RawEntry[] { - return entries.map(entry => { - const slug = typeof entry.slug === "string" ? entry.slug : ""; - if ( - entry.visibility !== "hide" - || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug) - || replacementVisibility.get(slug) !== true - || disabledModels === null - || disabledModels.has(slug) - ) { - return entry; - } - return { ...entry, visibility: "list" }; - }); -} - -function currentDisabledModelsForRestore(): Set | null { - try { - const diagnostics = readConfigDiagnostics(); - if (diagnostics.source === "fallback" || diagnostics.error !== null) return null; - return new Set(diagnostics.config.disabledModels ?? []); - } catch { - // An unreadable config cannot safely authorize a visibility change during restore. - return null; - } -} - -export async function syncCatalogModels( - config: OcxConfig, - options?: CodexCatalogSyncOptions, -): Promise { - if (pendingModelSelectionProviders(config).size) { - const { resolvePendingInitialModelSelection } = await import("../../providers/initial-model-selection-runtime"); - await resolvePendingInitialModelSelection(config); - } - const owningCodexHome = getCodexHome(); - const preflightRead = readRetainedCatalogSync(config); - if (preflightRead === null) { - return { - added: 0, - path: readCodexCatalogPath(), - catalogWritten: false, - comboOmissions: [], - refreshOutcome: "refused", - }; - } - - const comboOmissions: ComboCatalogOmission[] = []; - const providerModelOutcomes: CatalogGatherProviderModelOutcome[] = []; - // Settle the bundled template, then baseline, and only then await. Reading it - // here makes the memo ours before anyone else can move it, so a bundled swap - // during the await is an outside change rather than our own side effect. - // - // The persisted runtime selection is covered by the filesystem evidence above - // rather than by a process epoch; see `retainedCatalogProcessEvidence` for why - // the in-memory runtime memo cannot be baselined honestly from this path. - loadBundledCodexCatalog(); - const prepared: RetainedCatalogSyncRead = { - ...preflightRead, - evidence: retainedCatalogSyncEvidence(config, preflightRead.catalogPath, preflightRead.catalog), - processEvidence: retainedCatalogProcessEvidence(), - }; - const [goModels, modelEntitlements] = await Promise.all([ - gatherRoutedModels(config, { - comboOmissions, - providerModelOutcomes, - }), - resolveCodexModelEntitlements(config), - ]); - const committed = withCatalogWriteSerialization(owningCodexHome, permit => { - // Desired state can flip OFF during the provider await above. The catalog - // evidence revalidation below cannot see that — intent lives in our config, - // not in the catalog files — so the policy is re-read here, under K, right - // before the only write. A lost race becomes the discriminated skip instead - // of a routed catalog/cache surviving a completed disable. An explicit - // catalog-only sync opts out of that gate: the user asked for a refresh even - // when injection is OFF, and the toggle only protects config/history writes. - if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) { - return { - added: 0, - path: prepared.catalogPath, - catalogWritten: false, - comboOmissions, - skippedReason: "desired_disabled" as const, - }; - } - const current = revalidateRetainedCatalogSync(config, prepared); - if (current === null) return null; - if (!isCodexModelEntitlementSnapshotCurrent(modelEntitlements)) return null; - return writeRetainedCatalogSync({ - config, - goModels, - providerModelOutcomes, - comboOmissions, - read: current, - permit, - owningCodexHome, - modelEntitlements, - }); - }); - if (committed.kind === "completed" && committed.value !== null) { - return { - ...committed.value, - refreshOutcome: committed.value.skippedReason ? "refused" : "committed", - }; - } - return { - added: 0, - path: prepared.catalogPath, - catalogWritten: false, - comboOmissions, - refreshOutcome: "refused", - }; -} - -export function restoreCodexCatalogWithPermit( - permit: CatalogWritePermit, - owningCodexHome: string, - /** - * The catalog this injection actually wrote, when it is known (#1798). - * - * Re-resolving from the CURRENT config is wrong after a Codex app rewrite that dropped - * `model_catalog_json`: that sends restore to the default catalog while the routed file we - * really wrote is left untouched. The recorded path is the file whose routing is ours. - */ - injectedCatalogPath?: string | null, -): { removed: number; kept: number; path: string } { - const catalogPath = injectedCatalogPath ?? readCodexCatalogPath(); - const catalog = readCatalog(catalogPath); - if (!catalog || !Array.isArray(catalog.models)) return { removed: 0, kept: 0, path: catalogPath }; - const disabledModels = currentDisabledModelsForRestore(); - const replacementVisibility = visibleAccountReplacementNatives(catalog.models, disabledModels); - const backup = readCatalogBackup(catalogPath); - if (backup && Array.isArray(backup.models)) { - const removed = (catalog.models ?? []).filter(m => typeof m.slug === "string" - && (m.slug.includes("/") || RETIRED_NATIVE_OPENAI_MODELS.has(m.slug))).length; - const backupSlugs = new Set(backup.models.flatMap(m => typeof m.slug === "string" ? [m.slug] : [])); - const userNativeAdditions = restoreAccountHiddenBareNatives( - (catalog.models ?? []).filter(m => - typeof m.slug === "string" && !m.slug.includes("/") && !backupSlugs.has(m.slug) - && !RETIRED_NATIVE_OPENAI_MODELS.has(m.slug) - ), - replacementVisibility, - disabledModels, - ); - const restored = { - ...backup, - // A pristine backup predates retirement; it must not revive withdrawn native rows. - models: [...backup.models.filter(m => typeof m.slug !== "string" - || !RETIRED_NATIVE_OPENAI_MODELS.has(trustedAccountBoundNativeCatalogSlug(m) ?? m.slug)), ...userNativeAdditions], - }; - replaceActiveCodexCatalog(permit, owningCodexHome, { - path: catalogPath, - content: `${JSON.stringify(restored, null, 2)}\n`, - }); - return { removed, kept: restored.models.length, path: catalogPath }; - } - const before = catalog.models.length; - const native = restoreAccountHiddenBareNatives( - catalog.models.filter(m => !(typeof m.slug === "string" - && (m.slug.includes("/") || RETIRED_NATIVE_OPENAI_MODELS.has(m.slug)))), - replacementVisibility, - disabledModels, - ); - const removed = before - native.length; - if (removed > 0) { - catalog.models = native; - replaceActiveCodexCatalog(permit, owningCodexHome, { - path: catalogPath, - content: `${JSON.stringify(catalog, null, 2)}\n`, - }); - } - return { removed, kept: native.length, path: catalogPath }; -} - -export function restoreCodexCatalog(): { removed: number; kept: number; path: string } { - const owningCodexHome = getCodexHome(); - const outcome = withCatalogWriteSerialization( - owningCodexHome, - permit => restoreCodexCatalogWithPermit(permit, owningCodexHome), - ); - return outcome.kind === "completed" - ? outcome.value - : { removed: 0, kept: 0, path: readCodexCatalogPath() }; -} - -/** Force Codex's models_cache stale from the on-disk catalog. Returns whether a cache write occurred. */ -export function invalidateCodexModelsCacheWithPermit( - permit: CatalogWritePermit, - owningCodexHome: string, - options?: CodexCatalogSyncOptions, -): boolean { - try { - // This permit is a REACQUISITION: refreshCodexModelCatalog's commit released - // K before this rewrite runs, so the commit-path desired-state check cannot - // cover it. A disable landing in that gap must not be overwritten by a - // routed cache write — re-read intent under this permit, same as the commit. - // The catalog-only sync override applies here too so an explicit refresh - // keeps the cache consistent with the catalog it just wrote. - if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) return false; - const catalogPath = readCodexCatalogPathForHome(owningCodexHome); - if (!existsSync(catalogPath)) return false; - const catalog = JSON.parse(readFileSync(catalogPath, "utf8")); - const models = catalog.models ?? catalog; - const cachePath = join(owningCodexHome, "models_cache.json"); - const currentCache = readCatalog(cachePath); - const existingSlugs = new Set(models.flatMap((entry: RawEntry) => - typeof entry.slug === "string" ? [entry.slug] : [])); - const currentConfig = loadConfig(); - const mainSelectors = visibleCodexAccountSelectors(currentConfig).filter(selector => { - const target = new Map(codexAccountNamespaceEntries(currentConfig)).get(selector); - return isMainCodexAccountTarget(target ?? ""); - }); - const observedAccountModels = observedAccountBoundNativeEntries(currentCache?.models ?? []) - .filter(entry => { - const slug = typeof entry.slug === "string" ? entry.slug : ""; - return !existingSlugs.has(slug); - }) - .map(entry => ({ - ...entry, - // Keep the observation in Codex's cache without advertising a new bare picker row. The - // next OpenCodex catalog sync consumes this marker and creates only selector-qualified - // rows for the currently configured public account selectors. - visibility: "hide", - opencodex_account_observed_native: true, - opencodex_account_observed_selectors: mainSelectors, - })); - const wrapper = { - fetched_at: "2000-01-01T00:00:00Z", - client_version: "0.0.0", - models: [...models, ...observedAccountModels], - }; - replaceCodexModelsCache(permit, owningCodexHome, { - path: cachePath, - content: `${JSON.stringify(wrapper, null, 2)}\n`, - }); - return true; - } catch { - return false; - } -} - -export function invalidateCodexModelsCache(options?: CodexCatalogSyncOptions): boolean { - const owningCodexHome = getCodexHome(); - const outcome = withCatalogWriteSerialization( - owningCodexHome, - permit => invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, options), - ); - return outcome.kind === "completed" && outcome.value; -} +export { + MAX_SPAWN_AGENT_MODEL_OVERRIDES, + PICKER_ORDER_PRIORITY_BASE, + SPAWN_PRIORITY_FIELD, + CATALOG_INACTIVE_REASON_FIELD, + isEligibleV2SubagentEntry, + configuredCatalogEntry, + effectiveSubagentRoster, +} from "./subagent-roster"; +export type { + SpawnAgentSurface, + SubagentRosterExclusionReason, + EffectiveSubagentModel, + SubagentRosterExclusion, + EffectiveSubagentRoster, +} from "./subagent-roster"; +export { finishUpstreamNativeEntry, isExactComboCatalogModel, deriveEntry } from "./derive-entry"; +export { + buildCatalogEntries, + buildCatalogEntriesFromObservedState, + resetCatalogRuntimeStateForTests, + orderForSubagents, + orderForModelPicker, + mergeCatalogModelsWithNativeRecovery, + applyFullModelPickerOrder, + mergeCatalogEntriesFromObservedState, + mergeCatalogEntriesForSync, + CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, +} from "./build-entries"; +export type { + ObservedCatalogEntryBuildInput, + ObservedCatalogMergeInput, + ObservedCatalogMergePolicy, +} from "./build-entries"; +export { + isValidAutoReviewModel, + applyAutoReviewModelOverride, + applyConfiguredAutoReviewModelOverride, + finalizeAutoReviewModelOverride, +} from "./auto-review"; +export type { AutoReviewModelOverrideResult } from "./auto-review"; +export { + gatedNativeReauthSuppressionReason, + resetGatedNativeSuppressionWarningsForTests, +} from "./gated-native-warn"; +export { + syncCatalogModels, + invalidateCodexModelsCache, + invalidateCodexModelsCacheWithPermit, +} from "./retained-sync"; +export type { CodexCatalogSyncOptions } from "./retained-sync"; +export { restoreCodexCatalog, restoreCodexCatalogWithPermit } from "./restore"; diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 9cbddf45fe..ea6424e0eb 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -1,11 +1,9 @@ -import { contextCompatibleBaseLine } from "./context-compat"; -import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { atomicWriteFile, loadConfig, observeConfigGeneration, readConfigAdmissionSnapshot, - subagentDefaultSyncEffective, websocketsEnabled, withConfigMutationLockSync, } from "../config"; @@ -30,7 +28,6 @@ import { } from "./inject-coordination"; import { readIntegrationRecord } from "./integration-record"; import { classifyNativeRoutedResidue } from "./native-residue"; -import { inspectNativeCodexOwnership } from "../integrations/native/ownership-preflight"; import { resolveCodexCoordinatorDatabasePath, resolveEffectiveUserIdentity, @@ -40,14 +37,10 @@ import { markJournalInjectedState, journaledInjectedOpenaiBaseUrl, journaledInjectedRealtimeWsBaseUrl, - journaledInjectedCatalogPath, removeJournal, - restoreJournalState, writeJournal, } from "./journal"; -import { withCatalogWriteSerialization } from "./catalog-write-serialization"; -import { restoreCodexCatalogWithPermit } from "./catalog/sync"; -import { preflightCodexHistoryInjection, syncCodexHistoryProvider, type CodexHistoryFailureReason } from "./history-provider"; +import { preflightCodexHistoryInjection } from "./history-provider"; import { describeHistoryJobFailure, deriveCodexHistoryOperation, @@ -56,36 +49,49 @@ import { type CodexHistoryJobOutcome, } from "./history-job"; import { - OCX_SECTION_MARKER, REALTIME_WS_BASE_URL_KEY, hasInjectedCodexRouting, hasInjectedOpenaiBaseUrl, - isRootOpenaiBaseUrlLine, - isRootRealtimeWsBaseUrlLine, - providerTableStart, - providerTableString, rootTomlString, stripJournaledOpenaiBaseUrl, - tomlStringPattern, } from "./injected-marker"; import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, - DEFAULT_CATALOG_PATH, getCodexHome, - parseTomlString, - readRootTomlString, - resolveCodexConfigPath, resolveCodexStateDbPath, tomlString, } from "./paths"; -import { resolveEffectiveProjectModelProvider } from "./project-config-warnings"; -import { - transformManagedSubagentDefaults, - type ManagedSubagentDefaults, -} from "./subagent-defaults"; +import { transformManagedSubagentDefaults } from "./subagent-defaults"; import type { OcxConfig } from "../types"; -import { effectiveLoopbackListenerPort, isLoopbackHostname, shouldInjectApiAuthHeader } from "./loopback-target"; +import { + configuredManagedSubagentDefaults, + standaloneCodexRoutingTarget, + usesProviderTable, + validateCodexRoutingTarget, + type CodexRoutingTarget, +} from "./inject/routing-target"; +import { + applyEol, + buildProfileFileForTarget, + buildProviderTableBlockForTarget, + chooseCatalogPathForInjection, + dominantEol, + ensureFastModeFeature, + externalCodexModelProvider, + normalizeServiceTier, + removeProfileSection, + setRootModelCatalogPath, + setRootModelProvider, + setRootOpenaiBaseUrlForTarget, + setRootRealtimeWsBaseUrl, + stripExistingModelProvider, + stripInjectedOpenaiBaseUrl, + stripOpencodexCatalogPath, + stripRootContextWindowOverrides, +} from "./inject/config-toml"; +import { hasOcxProviderTable, removeOcxSection } from "./inject/remove"; + export { effectiveLoopbackListenerPort, isLoopbackHostname, shouldInjectApiAuthHeader } from "./loopback-target"; @@ -93,37 +99,6 @@ export { effectiveLoopbackListenerPort, isLoopbackHostname, shouldInjectApiAuthH // without importing this module back. Re-exported for existing external callers. export { hasInjectedCodexRouting, hasInjectedOpenaiBaseUrl }; -export function externalCodexModelProvider(content: string): string | null { - const provider = resolveEffectiveProjectModelProvider(content).provider; - return provider && provider !== "openai" && provider !== "opencodex" - ? provider - : null; -} - -export function currentExternalCodexModelProvider(): string | null { - if (!existsSync(CODEX_CONFIG_PATH)) return null; - return externalCodexModelProvider(readFileSync(CODEX_CONFIG_PATH, "utf8")); -} - -/** - * Detect the file's dominant line ending. Every transform in this module is LF-pure - * (split("\n") + hard "\n" joins), so CRLF configs (Windows-edited config.toml) are - * normalized to LF at the pipeline boundary and converted back on write — otherwise a - * single inject would leave a mixed-EOL file. - */ -export function dominantEol(content: string): "\r\n" | "\n" { - const crlf = (content.match(/\r\n/g) ?? []).length; - if (crlf === 0) return "\n"; - const bareLf = (content.match(/\n/g) ?? []).length - crlf; - return crlf >= bareLf ? "\r\n" : "\n"; -} - -/** Normalize all line endings to `eol` (CRLF first collapsed to LF, then expanded). */ -export function applyEol(content: string, eol: "\r\n" | "\n"): string { - const lf = content.replace(/\r\n/g, "\n"); - return eol === "\n" ? lf : lf.replace(/\n/g, "\r\n"); -} - /** * Design B (2026-07-06): loopback installs no longer re-tag the provider. Instead of * `model_provider = "opencodex"` + a `[model_providers.opencodex]` table, we set the official @@ -170,727 +145,6 @@ function runClientWriteGuard(guard: InjectCodexOptions["beforeClientWrite"]): vo } } -export interface CodexRoutingTarget { - baseUrl: string; - requiresAdmissionToken: boolean; - tokenEnv: "OPENCODEX_API_AUTH_TOKEN"; - /** - * Opt-in authless Codex Desktop mode (#1107): inject the dedicated provider table with - * `requires_openai_auth = false` so Desktop skips the ChatGPT login gate. Only ever true for - * loopback targets that need no admission token; non-loopback admission is a separate layer - * and is never weakened by this flag. - */ - desktopAuthless?: boolean; - /** Select the dedicated provider identity so Codex owns compaction locally. */ - clientCompaction?: boolean; -} - -function validateCodexRoutingTarget(target: CodexRoutingTarget): CodexRoutingTarget { - let parsed: URL; - try { - parsed = new URL(target.baseUrl); - } catch { - throw new TypeError("Codex routing target must be an absolute HTTP(S) /v1 URL"); - } - if ( - (parsed.protocol !== "http:" && parsed.protocol !== "https:") - || parsed.username - || parsed.password - || parsed.pathname !== "/v1" - || parsed.search - || parsed.hash - || target.tokenEnv !== "OPENCODEX_API_AUTH_TOKEN" - ) { - throw new TypeError("Codex routing target must be a canonical HTTP(S) /v1 URL without credentials, query, or fragment"); - } - return { ...target, baseUrl: `${parsed.origin}/v1` }; -} - -/** Provider-table form is used when auth, admission, or compaction policy needs a dedicated provider. */ -function usesProviderTable(target: CodexRoutingTarget): boolean { - return target.requiresAdmissionToken - || target.desktopAuthless === true - || target.clientCompaction === true; -} - -export function standaloneCodexRoutingTarget( - port: number, - config?: Pick< - OcxConfig, - "hostname" | "unauthenticatedLoopbackListener" | "codexDesktopAuthless" | "codexClientCompaction" - >, -): CodexRoutingTarget { - // An enabled listener with no `port` is the companion form: it answers on `port` itself, - // bound to 127.0.0.1 (#4236). Resolving it through the shared helper is what makes the - // one-port hub work without every writer repeating `?? port`. - const loopback = config?.unauthenticatedLoopbackListener; - const effectivePort = effectiveLoopbackListenerPort(config, port) ?? port; - const hostname = loopback?.enabled ? undefined : config?.hostname; - const requiresAdmissionToken = loopback?.enabled ? false : shouldInjectApiAuthHeader(config); - return { - baseUrl: `http://${providerBaseHost(hostname)}:${effectivePort}/v1`, - requiresAdmissionToken, - tokenEnv: "OPENCODEX_API_AUTH_TOKEN", - ...(config?.codexDesktopAuthless === true && !requiresAdmissionToken - ? { desktopAuthless: true } - : {}), - ...(config?.codexClientCompaction === true && !requiresAdmissionToken - ? { clientCompaction: true } - : {}), - }; -} - -function routingTargetOrigin(target: CodexRoutingTarget): string { - return target.baseUrl.slice(0, -3); -} - -function configuredManagedSubagentDefaults( - config: - | Pick< - OcxConfig, - "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults" - > - | undefined, -): ManagedSubagentDefaults | null { - if (!subagentDefaultSyncEffective(config ?? {})) return null; - return { - model: config!.injectionModel!.trim(), - ...(config!.injectionEffort?.trim() - ? { reasoningEffort: config!.injectionEffort.trim() } - : {}), - }; -} - -/** - * The `[model_providers.opencodex]` TABLE only. A table is position-independent in TOML, so it is - * safe to append at EOF. The bare root key `model_provider = "opencodex"` is NOT included here — - * it must live at the document root (before any table header) and is set separately by - * setRootModelProvider(). Appending the bare key at EOF was the original bug: it nested under - * whatever `[table]` happened to be open last (e.g. `[plugins."chrome@openai-bundled"]`), so Codex - * never saw a global model_provider and silently fell back to the `openai` (ChatGPT) provider. - */ -export function providerBaseHost(hostname: string | undefined): string { - const trimmed = (hostname ?? "127.0.0.1").trim(); - const lower = trimmed.toLowerCase(); - // Match what the server actually binds. Writing "localhost" while binding IPv4-only - // 127.0.0.1 breaks on Windows, where localhost commonly resolves to ::1 first. - if (lower === "::1" || lower === "[::1]") return "[::1]"; - if ( - isLoopbackHostname(trimmed) || - trimmed === "0.0.0.0" || - trimmed === "::" || - trimmed === "[::]" - ) - return "127.0.0.1"; - if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed; - return trimmed.includes(":") ? `[${trimmed}]` : trimmed; -} - -export function buildProviderTableBlock( - port: number, - supportsWebsockets?: boolean, - includeApiAuthHeader?: boolean, - hostname?: string, -): string; -export function buildProviderTableBlock( - target: CodexRoutingTarget, - supportsWebsockets?: boolean, -): string; -export function buildProviderTableBlock( - portOrTarget: number | CodexRoutingTarget, - supportsWebsockets = false, - includeApiAuthHeader = false, - hostname?: string, -): string { - const target = typeof portOrTarget === "number" - ? validateCodexRoutingTarget({ - baseUrl: `http://${providerBaseHost(hostname)}:${portOrTarget}/v1`, - requiresAdmissionToken: includeApiAuthHeader, - tokenEnv: "OPENCODEX_API_AUTH_TOKEN", - }) - : validateCodexRoutingTarget(portOrTarget); - return buildProviderTableBlockForTarget(target, supportsWebsockets); -} - -function buildProviderTableBlockForTarget( - target: CodexRoutingTarget, - supportsWebsockets = false, -): string { - const lines = [ - "", - OCX_SECTION_MARKER, - "[model_providers.opencodex]", - 'name = "OpenCodex Proxy"', - `base_url = ${tomlString(target.baseUrl)}`, - 'wire_api = "responses"', - // false only in the authless Desktop opt-in (#1107); true keeps the App/TUI account gate. - `requires_openai_auth = ${target.desktopAuthless === true ? "false" : "true"}`, - ]; - if (target.requiresAdmissionToken) { - // codex-cli 0.146+ contract (#2073): env_key sends Authorization: Bearer $VAR and - // hard-errors on a missing/empty variable instead of silently omitting auth. It - // coexists with requires_openai_auth (env_key wins wire auth; the flag keeps the - // login/account UX), and the server substitutes stored main auth for our admission - // bearer (#1686), so the modern form is strictly better than the legacy - // env_http_headers table this line used to emit. - lines.push(`env_key = ${tomlString(target.tokenEnv)}`); - } - if (supportsWebsockets) lines.push("supports_websockets = true"); - return lines.join("\n") + "\n"; -} - -export function buildOpenaiBaseUrlLine( - port: number, - hostname?: string, -): string; -export function buildOpenaiBaseUrlLine(target: CodexRoutingTarget): string; -export function buildOpenaiBaseUrlLine( - portOrTarget: number | CodexRoutingTarget, - hostname?: string, -): string { - return typeof portOrTarget === "number" - ? `openai_base_url = "http://${providerBaseHost(hostname)}:${portOrTarget}/v1"` - : buildOpenaiBaseUrlLineForTarget(validateCodexRoutingTarget(portOrTarget)); -} - -function buildOpenaiBaseUrlLineForTarget(target: CodexRoutingTarget): string { - return `openai_base_url = ${tomlString(target.baseUrl)}`; -} - -/** - * Realtime sideband override (codex-rs `experimental_realtime_ws_base_url`), written with the - * SAME value as `openai_base_url`. Desktop voice creates its WebRTC call through the proxy - * (`POST /v1/live`, answered under the Pool account the proxy selects) but, since openai/codex - * 438c9e98d (#35830), joins the sideband at `wss://api.openai.com/v1/live/{callId}` with the - * app's own login unless this key redirects it. Two accounts, one call: the join 404s. Pointing - * the key at the proxy sends the join through `GET /v1/live/{callId}` (src/server/live.ts), - * where the same Pool account is reused. codex-rs turns `http` into `ws` and appends - * `/live/{callId}` itself; the value must stay the canonical `/v1` root. - */ -export function buildRealtimeWsBaseUrlLine(target: CodexRoutingTarget): string { - return `${REALTIME_WS_BASE_URL_KEY} = ${tomlString(target.baseUrl)}`; -} - -/** - * Design B root-key injection: place `OCX_SECTION_MARKER` + `openai_base_url` at the document - * ROOT (before the first table header). Idempotent: an existing marker-owned line is rewritten - * in place. A user's OWN root `openai_base_url` (no marker above it) is respected — we keep it - * and inject nothing, reporting `keptUserBaseUrl` so the caller can surface it. - */ -export function setRootOpenaiBaseUrl( - content: string, - port: number, - hostname?: string, -): { content: string; keptUserBaseUrl: boolean }; -export function setRootOpenaiBaseUrl( - content: string, - target: CodexRoutingTarget, -): { content: string; keptUserBaseUrl: boolean }; -export function setRootOpenaiBaseUrl( - content: string, - portOrTarget: number | CodexRoutingTarget, - hostname?: string, -): { content: string; keptUserBaseUrl: boolean } { - if (typeof portOrTarget !== "number") { - return setRootOpenaiBaseUrlForTarget(content, validateCodexRoutingTarget(portOrTarget)); - } - const lines = content.split("\n"); - const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); - const rootEnd = firstTable === -1 ? lines.length : firstTable; - const key = contextCompatibleBaseLine(content, buildOpenaiBaseUrlLine(portOrTarget, hostname)); - - for (let i = 0; i < rootEnd; i++) { - if (!isRootOpenaiBaseUrlLine(lines[i])) continue; - const markerOwned = i > 0 && lines[i - 1].includes(OCX_SECTION_MARKER); - if (!markerOwned) return { content, keptUserBaseUrl: true }; - lines[i] = key; - return { content: lines.join("\n"), keptUserBaseUrl: false }; - } - - if (firstTable === -1) { - return { - content: - content.replace(/\n+$/, "") + - "\n" + - OCX_SECTION_MARKER + - "\n" + - key + - "\n", - keptUserBaseUrl: false, - }; - } - let insertAt = firstTable; - while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--; - lines.splice(insertAt, 0, OCX_SECTION_MARKER, key); - return { content: lines.join("\n"), keptUserBaseUrl: false }; -} - -function setRootOpenaiBaseUrlForTarget( - content: string, - target: CodexRoutingTarget, -): { content: string; keptUserBaseUrl: boolean } { - const lines = content.split("\n"); - const firstTable = lines.findIndex((line) => /^\s*\[/.test(line)); - const rootEnd = firstTable === -1 ? lines.length : firstTable; - const key = contextCompatibleBaseLine(content, buildOpenaiBaseUrlLineForTarget(target)); - for (let index = 0; index < rootEnd; index += 1) { - if (!isRootOpenaiBaseUrlLine(lines[index])) continue; - const markerOwned = index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER); - if (!markerOwned) return { content, keptUserBaseUrl: true }; - lines[index] = key; - return { content: lines.join("\n"), keptUserBaseUrl: false }; - } - if (firstTable === -1) { - return { - content: `${content.replace(/\n+$/, "")}\n${OCX_SECTION_MARKER}\n${key}\n`, - keptUserBaseUrl: false, - }; - } - let insertAt = firstTable; - while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt -= 1; - lines.splice(insertAt, 0, OCX_SECTION_MARKER, key); - return { content: lines.join("\n"), keptUserBaseUrl: false }; -} - -/** - * Companion to `setRootOpenaiBaseUrlForTarget` for the realtime sideband override. Same - * ownership rule, applied per key: the line is ours only when the marker sits directly - * above it; a user's own line (no marker above it) is kept and nothing is injected. The - * key gets its OWN marker line rather than sharing the routing override's, so a user line - * that happens to sit right under our `openai_base_url` is never mistaken for ours. - * Placement: directly after the marker-owned `openai_base_url` pair. Only ever called on - * the Design B (loopback) path right after the routing override was written — the legacy - * provider-table form needs the admission-token header, which the sideband cannot carry. - */ -export function setRootRealtimeWsBaseUrl( - content: string, - target: CodexRoutingTarget, -): { content: string; keptUserRealtimeWsBaseUrl: boolean } { - const lines = content.split("\n"); - const firstTable = lines.findIndex((line) => /^\s*\[/.test(line)); - const rootEnd = firstTable === -1 ? lines.length : firstTable; - const key = buildRealtimeWsBaseUrlLine(validateCodexRoutingTarget(target)); - for (let index = 0; index < rootEnd; index += 1) { - if (!isRootRealtimeWsBaseUrlLine(lines[index])) continue; - const markerOwned = index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER); - if (!markerOwned) return { content, keptUserRealtimeWsBaseUrl: true }; - lines[index] = key; - return { content: lines.join("\n"), keptUserRealtimeWsBaseUrl: false }; - } - for (let index = 0; index < rootEnd; index += 1) { - if (!isRootOpenaiBaseUrlLine(lines[index])) continue; - if (!(index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER))) continue; - lines.splice(index + 1, 0, OCX_SECTION_MARKER, key); - return { content: lines.join("\n"), keptUserRealtimeWsBaseUrl: false }; - } - // No marker-owned routing override to attach to: the override has no owner, so inject nothing. - return { content, keptUserRealtimeWsBaseUrl: false }; -} - -/** - * Remove the marker-owned root `openai_base_url` (marker line + the key line right after it). - * A user's own root override (no marker) survives; an orphaned marker with no key line after - * it is dropped too so repeated strip/inject cycles cannot accumulate marker comments. - * A marker-owned `experimental_realtime_ws_base_url` pair is removed by the same rule. - */ -export function stripInjectedOpenaiBaseUrl(content: string): string { - const lines = content.split("\n"); - const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); - const rootEnd = firstTable === -1 ? lines.length : firstTable; - const drop = new Set(); - for (let i = 0; i < rootEnd; i++) { - if (!lines[i].includes(OCX_SECTION_MARKER)) continue; - if (i + 1 < rootEnd && (isRootOpenaiBaseUrlLine(lines[i + 1]) || isRootRealtimeWsBaseUrlLine(lines[i + 1]))) { - drop.add(i); - drop.add(i + 1); - } else if (i + 1 >= rootEnd || lines[i + 1].trim() === "") { - drop.add(i); // orphaned marker at root - } - } - if (drop.size === 0) return content; - return lines.filter((_, i) => !drop.has(i)).join("\n"); -} - -export type CodexRoutingKind = - "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown"; - -type RoutingEndpointKind = "local" | "remote" | "unknown"; - -function ipv4Octets(hostname: string): number[] | null { - const dotted = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname); - if (dotted) { - const octets = dotted.slice(1).map(Number); - return octets.some((octet) => octet > 255) ? null : octets; - } - const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(hostname); - if (!mapped) return null; - const high = Number.parseInt(mapped[1], 16); - const low = Number.parseInt(mapped[2], 16); - return [high >>> 8, high & 0xff, low >>> 8, low & 0xff]; -} - -function classifyRoutingEndpoint(value: string): RoutingEndpointKind { - try { - const url = new URL(value); - if (url.protocol !== "http:" && url.protocol !== "https:") return "unknown"; - const hostname = url.hostname - .toLowerCase() - .replace(/^\[|\]$/g, "") - .replace(/\.$/, ""); - if (!hostname) return "unknown"; - if (hostname === "localhost" || hostname.endsWith(".localhost")) - return "local"; - if (hostname === "::" || hostname === "::1" || hostname === "0.0.0.0") - return "local"; - const octets = ipv4Octets(hostname); - if (octets) { - if (octets.every((octet) => octet === 0)) return "local"; - if (octets[0] === 127) return "local"; - return "remote"; - } - if (/^::ffff:/i.test(hostname)) return "unknown"; - return "remote"; - } catch { - return "unknown"; - } -} - -/** Classify actual routing dependency separately from opencodex ownership. */ -export function classifyCodexRouting(content: string): CodexRoutingKind { - const rootBaseUrl = rootTomlString(content, "openai_base_url"); - if (rootBaseUrl) { - const endpoint = classifyRoutingEndpoint(rootBaseUrl); - if (endpoint === "unknown") return "unknown"; - if (hasInjectedOpenaiBaseUrl(content)) return "opencodex-local"; - return endpoint === "local" ? "custom-local" : "custom-remote"; - } - const rootProvider = rootTomlString(content, "model_provider"); - if (rootProvider) { - const providerTableExists = - providerTableStart(content.split("\n"), rootProvider) !== -1; - const providerBaseUrl = providerTableString( - content, - rootProvider, - "base_url", - ); - if (providerBaseUrl) { - const endpoint = classifyRoutingEndpoint(providerBaseUrl); - if (endpoint === "unknown") return "unknown"; - if (rootProvider === "opencodex") return "opencodex-local"; - return endpoint === "local" ? "custom-local" : "custom-remote"; - } - if ( - rootProvider === "opencodex" || - providerTableExists || - rootProvider !== "openai" - ) - return "unknown"; - } - return "native"; -} - -/** Read-only probe used by status, doctor, and the dashboard. */ -export function isCodexRoutingInjected(): boolean { - const path = CODEX_CONFIG_PATH; - if (!existsSync(path)) return false; - try { - return hasInjectedCodexRouting(readFileSync(path, "utf8")); - } catch { - return false; - } -} - -export function getCodexRoutingKind(): CodexRoutingKind { - const path = CODEX_CONFIG_PATH; - if (!existsSync(path)) return "native"; - try { - return classifyCodexRouting(readFileSync(path, "utf8")); - } catch { - return "unknown"; - } -} - -/** - * Strip every existing `model_provider` line that we must not duplicate: any line set to - * "opencodex" (wherever it sits — including a previously mis-nested one under a table), plus any - * ROOT-level model_provider (before the first table) of any value, since we override the global. - * A `model_provider` legitimately inside a user table/profile with a non-opencodex value is left - * untouched. - */ -function stripExistingModelProvider(content: string): string { - const lines = content.split("\n"); - const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); - const out: string[] = []; - lines.forEach((line, i) => { - if (/^\s*model_provider\s*=/.test(line)) { - const isOurs = /^\s*model_provider\s*=\s*"opencodex"\s*$/.test(line); - const isRoot = firstTable === -1 || i < firstTable; - if (isOurs || isRoot) return; // drop it - } - out.push(line); - }); - return out.join("\n"); -} - -/** - * Drop ROOT-level `model_context_window` overrides (keys before the first table header). Codex - * treats this root key as a global override that wins over the per-model catalog values, so a stale - * `model_context_window = 1000000` makes every model (e.g. gpt-5.5) report a 1M window. User-owned - * compaction limits do not alter the advertised context window and must survive reinjection. - */ -export function stripRootContextWindowOverrides(content: string): string { - const lines = content.split("\n"); - const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); - return lines - .filter((line, i) => { - const isRoot = firstTable === -1 || i < firstTable; - return !isRoot || !/^\s*model_context_window\s*=/.test(line); - }) - .join("\n"); -} - -function stripRootRoutedModel(content: string): string { - const lines = content.split("\n"); - const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); - return lines - .filter((line, i) => { - const isRoot = firstTable === -1 || i < firstTable; - if (!isRoot) return true; - const m = line.match(/^\s*model\s*=\s*("(?:\\.|[^"\\])*"|'[^']*')\s*$/); - if (!m) return true; - const model = parseTomlString(m[1]); - return !model?.includes("/"); - }) - .join("\n"); -} - -/** - * Insert `model_provider = "opencodex"` at the document ROOT — immediately before the first table - * header (TOML root keys must precede all tables). If there are no tables, append it to the root body. - */ -function setRootModelProvider(content: string): string { - const lines = content.split("\n"); - const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); - const key = 'model_provider = "opencodex"'; - if (firstTable === -1) { - return content.replace(/\n+$/, "") + "\n" + key + "\n"; - } - let insertAt = firstTable; - while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--; - lines.splice(insertAt, 0, key); - return lines.join("\n"); -} - -function readRootModelCatalogPath(content: string): string | null { - const lines = content.split("\n"); - const firstTable = lines.findIndex((line) => /^\s*\[/.test(line)); - const rootEnd = firstTable === -1 ? lines.length : firstTable; - const modelCatalogAssignment = tomlStringPattern("model_catalog_json"); - let ownedCatalogPath: string | null = null; - for (let index = 0; index < rootEnd; index += 1) { - const match = modelCatalogAssignment.exec(lines[index]); - if (!match) continue; - const catalogPath = parseTomlString(match[1]); - if (!isOpencodexCatalogPath(catalogPath)) return catalogPath; - ownedCatalogPath ??= catalogPath; - } - return ownedCatalogPath; -} - -function setRootModelCatalogPath(content: string, catalogPath: string): string { - const lines = content.split("\n"); - const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); - const key = `model_catalog_json = ${tomlString(catalogPath)}`; - const modelCatalogAssignment = tomlStringPattern("model_catalog_json"); - const rootEnd = firstTable === -1 ? lines.length : firstTable; - const ownedAssignments: number[] = []; - let hasUserAssignment = false; - for (let i = 0; i < rootEnd; i++) { - const m = modelCatalogAssignment.exec(lines[i]); - if (!m) continue; - const existing = parseTomlString(m[1]); - if (isOpencodexCatalogPath(existing)) { - ownedAssignments.push(i); - } else { - hasUserAssignment = true; - } - } - if (hasUserAssignment) { - const owned = new Set(ownedAssignments); - return lines.filter((_, index) => !owned.has(index)).join("\n"); - } - if (ownedAssignments.length > 0) { - lines[ownedAssignments[0]] = key; - const duplicates = new Set(ownedAssignments.slice(1)); - return lines.filter((_, index) => !duplicates.has(index)).join("\n"); - } - if (firstTable === -1) { - return content.replace(/\n+$/, "") + "\n" + key + "\n"; - } - let insertAt = firstTable; - while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--; - lines.splice(insertAt, 0, key); - return lines.join("\n"); -} - -function removeProfileSection(content: string): string { - const lines = content.split("\n"); - const filtered: string[] = []; - let inProfile = false; - for (const line of lines) { - if (line.trim() === "[profiles.opencodex]") { - inProfile = true; - continue; - } - if (inProfile) { - if (/^\s*\[/.test(line) && line.trim() !== "[profiles.opencodex]") { - inProfile = false; - filtered.push(line); - } - continue; - } - filtered.push(line); - } - return ( - filtered - .join("\n") - .replace(/\n{3,}/g, "\n\n") - .trimEnd() + "\n" - ); -} - -function normalizeServiceTier(content: string): string { - return content.replace( - /^(\s*service_tier\s*=\s*)["']priority["']\s*$/gm, - '$1"fast"', - ); -} - -function ensureFastModeFeature(content: string, fastMode?: boolean): string { - // Tri-state fast mode (see OcxConfig.fastMode): true forces `fast_mode = true`, - // false forces `fast_mode = false`, and undefined leaves the user's config - // untouched (no [features] table is added and an existing fast_mode line is - // preserved as-is). Table and key matching accept the valid TOML spellings - // `[features] # comment`, `["features"]` / `['features']`, and quoted keys. - const lines = content.split("\n"); - const featuresHeader = /^\s*\[(["']?)\s*features\s*\1\]\s*(?:#.*)?$/; - const fastModeKey = /^\s*(?:"fast_mode"|'fast_mode'|fast_mode)\s*=/; - const featuresStart = lines.findIndex(line => featuresHeader.test(line)); - if (featuresStart === -1) { - if (fastMode === undefined) return content; - return content.trimEnd() + "\n\n[features]\nfast_mode = " + (fastMode ? "true" : "false") + "\n"; - } - - const nextTable = lines.findIndex( - (line, index) => index > featuresStart && /^\s*\[/.test(line), - ); - const featuresEnd = nextTable === -1 ? lines.length : nextTable; - for (let i = featuresStart + 1; i < featuresEnd; i++) { - if (fastModeKey.test(lines[i])) { - if (fastMode === undefined) return lines.join("\n"); - lines[i] = lines[i].replace(/^(\s*)(?:"fast_mode"|'fast_mode'|fast_mode)\s*=.*$/, `$1fast_mode = ${fastMode ? "true" : "false"}`); - return lines.join("\n"); - } - } - - if (fastMode === undefined) return lines.join("\n"); - let insertAt = featuresEnd; - while (insertAt > featuresStart + 1 && lines[insertAt - 1].trim() === "") insertAt--; - lines.splice(insertAt, 0, `fast_mode = ${fastMode ? "true" : "false"}`); - return lines.join("\n"); -} - -function isOpencodexCatalogPath(path: string): boolean { - return path.replace(/\\/g, "/").split("/").pop() === "opencodex-catalog.json"; -} - -function stripOpencodexCatalogPath(content: string): string { - const modelCatalogAssignment = tomlStringPattern("model_catalog_json"); - const lines = content.split("\n"); - const firstTable = lines.findIndex((line) => /^\s*\[/.test(line)); - const rootEnd = firstTable === -1 ? lines.length : firstTable; - return lines - .filter((line, index) => { - if (index >= rootEnd) return true; - const m = modelCatalogAssignment.exec(line); - return !m || !isOpencodexCatalogPath(parseTomlString(m[1])); - }) - .join("\n"); -} - -export function buildProfileFile(port: number, catalogPath?: string | null, supportsWebsockets?: boolean, includeApiAuthHeader?: boolean, hostname?: string, fastMode?: boolean): string; -export function buildProfileFile(target: CodexRoutingTarget, catalogPath?: string | null, supportsWebsockets?: boolean, fastMode?: boolean): string; -export function buildProfileFile( - portOrTarget: number | CodexRoutingTarget, - catalogPath?: string | null, - supportsWebsockets = false, - includeApiAuthHeaderOrFastMode?: boolean, - hostname?: string, - fastMode?: boolean, -): string { - const target = typeof portOrTarget === "number" - ? validateCodexRoutingTarget({ - baseUrl: `http://${providerBaseHost(hostname)}:${portOrTarget}/v1`, - requiresAdmissionToken: includeApiAuthHeaderOrFastMode === true, - tokenEnv: "OPENCODEX_API_AUTH_TOKEN", - }) - : validateCodexRoutingTarget(portOrTarget); - return buildProfileFileForTarget( - target, - catalogPath, - supportsWebsockets, - typeof portOrTarget === "number" ? fastMode : includeApiAuthHeaderOrFastMode, - ); -} - -function buildProfileFileForTarget( - target: CodexRoutingTarget, - catalogPath?: string | null, - supportsWebsockets = false, - fastMode?: boolean, -): string { - const origin = routingTargetOrigin(target); - const host = new URL(origin).host; - // Design B (loopback): the reference/fallback file documents the root override form. - // Non-loopback keeps the legacy provider-table shape (built-in provider cannot carry - // the x-opencodex-api-key env header); explicit Desktop policies share that shape. - if (!usesProviderTable(target)) { - const lines = [ - "# OpenCodex proxy fallback config (Design B)", - `# Root override that points Codex's built-in openai provider at the proxy on ${host}.`, - "# Merge these root keys into ~/.codex/config.toml manually if auto-injection was removed.", - buildOpenaiBaseUrlLineForTarget(target), - ]; - if (catalogPath) lines.push(`model_catalog_json = ${tomlString(catalogPath)}`); - if (fastMode !== undefined) lines.push("", "[features]", `fast_mode = ${fastMode ? "true" : "false"}`, ""); - return lines.join("\n"); - } - const lines = [ - "# OpenCodex proxy profile — use with: codex --profile opencodex", - `# Routes all model requests through the opencodex proxy at ${host}`, - 'model_provider = "opencodex"', - ]; - if (catalogPath) lines.push(`model_catalog_json = ${tomlString(catalogPath)}`); - if (fastMode !== undefined) lines.push("", "[features]", `fast_mode = ${fastMode ? "true" : "false"}`); - lines.push(buildProviderTableBlockForTarget(target, supportsWebsockets).trimEnd(), ""); - return lines.join("\n"); -} - -export function chooseCatalogPathForInjection( - content: string, - requested?: string | null, -): string | null { - if (requested !== undefined) return requested; - - const existing = readRootModelCatalogPath(content); - if (existing) { - const resolved = resolveCodexConfigPath(existing); - if (!isOpencodexCatalogPath(resolved) || existsSync(resolved)) - return existing; - } - - return existsSync(DEFAULT_CATALOG_PATH) ? DEFAULT_CATALOG_PATH : null; -} export interface CodexInjectResult { success: boolean; @@ -916,19 +170,10 @@ export interface CodexInjectResult { const HISTORY_RELABEL_STANDS_DOWN = "history_paginated_requires_native_writer"; class CodexHistoryPreflightRefusal extends Error {} -class CodexRestoreRefusal extends Error { - constructor(readonly config: CodexRestoreConfigResult) { - super(config.message); - } -} let historyArtifactStageForTests: ((stage: string) => void) | undefined; export function setHistoryArtifactStageForTests(hook: typeof historyArtifactStageForTests): void { historyArtifactStageForTests = hook; } -let beforeRestoreConfigForTests: ((kind: string) => void) | undefined; -export function setBeforeRestoreConfigForTests(hook: typeof beforeRestoreConfigForTests): void { - beforeRestoreConfigForTests = hook; -} let beforeHistoryArtifactCommitForTests: ((kind: string) => void) | undefined; export function setBeforeHistoryArtifactCommitForTests(hook: typeof beforeHistoryArtifactCommitForTests): void { beforeHistoryArtifactCommitForTests = hook; @@ -1661,656 +906,6 @@ async function injectCodexConfigImpl( }; } -/** - * Sub-table headers like `[model_providers.opencodex.env_http_headers]` appear when a Codex app - * config rewrite re-serializes the provider's inline `env_http_headers` table. They define the - * same `model_providers.opencodex` provider, so cleanup must remove them too — otherwise the - * provider survives with no `name` and Codex rejects the whole config - * ("provider name must not be empty"). The dot terminator keeps a user's - * `[model_providers.opencodex_backup]`-style tables out of scope. - */ -function isOcxProviderHeaderLine(trimmedLine: string): boolean { - // Root form matched by regex, not equality: TOML v1.0 allows a trailing comment - // (`[model_providers.opencodex] # comment`), and an exact compare would miss that form. - // The sub-table prefix check already tolerates trailing comments by construction. - return ( - /^\[model_providers\.opencodex\]\s*(?:#.*)?$/.test(trimmedLine) || - trimmedLine.startsWith("[model_providers.opencodex.") - ); -} - -function hasOcxProviderTable(content: string): boolean { - return content - .split("\n") - .some((line) => isOcxProviderHeaderLine(line.trim())); -} - -function removeOcxSection(content: string): string { - const lines = content.split("\n"); - const filtered: string[] = []; - let inOcxSection = false; - for (const line of lines) { - if ( - line.includes(OCX_SECTION_MARKER) || - isOcxProviderHeaderLine(line.trim()) - ) { - inOcxSection = true; - continue; - } - if (inOcxSection) { - // End the injected section at the next table header that ISN'T our own. Exact match on the - // provider name (plus our own sub-tables) so a user's - // "[model_providers.opencodex_backup]" (or similar) is preserved, not swallowed. - if (/^\s*\[/.test(line) && !isOcxProviderHeaderLine(line.trim())) { - inOcxSection = false; - filtered.push(line); - } - continue; - } - filtered.push(line); - } - return ( - filtered - .join("\n") - .replace(/\n{3,}/g, "\n\n") - .trimEnd() + "\n" - ); -} - -interface StripOpencodexConfigResult { - content: string; - managedDefaultsError: string | null; -} - -/** - * Detailed form used by the on-disk restore path. A damaged ownership marker is - * ambiguous: keep the associated value, but return the transform error so the - * caller cannot report a complete restore. - */ -function stripOpencodexConfigResult( - content: string, - journaledBaseUrl: string | null = null, - journaledRealtimeWsBaseUrl: string | null = null, -): StripOpencodexConfigResult { - let out = content; - const hadRootOcxProvider = - readRootTomlString(out, "model_provider") === "opencodex"; - // #1798: marker adjacency is FORMATTING evidence, and a Codex app rewrite keeps values - // while dropping comments. Fall back to VALUE evidence -- the exact URL we recorded - // writing -- so an app-rewritten config is still recognized as ours. - const hadInjectedBaseUrl = hasInjectedOpenaiBaseUrl(out) - || (journaledBaseUrl !== null && rootTomlString(out, "openai_base_url") === journaledBaseUrl); - out = stripInjectedOpenaiBaseUrl(out); // before removeOcxSection — it keys on the marker line too - out = stripJournaledOpenaiBaseUrl(out, journaledBaseUrl, journaledRealtimeWsBaseUrl); - if (hasOcxProviderTable(out)) { - out = removeOcxSection(out); - } - out = removeProfileSection(out); - // Regex (not exact-string) removal so compact `model_provider="opencodex"` is stripped too — - // must match the detection regex above, or a detected line could survive un-removed. - out = out - .split("\n") - .filter((l) => !/^\s*model_provider\s*=\s*"opencodex"\s*$/.test(l)) - .join("\n"); - // Routed root model ids (`model = "provider/slug"`) only make sense while the proxy serves - // them — strip on both the legacy re-tag form and the Design B injected-base-url form. - if (hadRootOcxProvider || hadInjectedBaseUrl) out = stripRootRoutedModel(out); - const managedDefaults = transformManagedSubagentDefaults(out, null); - if (managedDefaults.ok) out = managedDefaults.content; - out = stripOpencodexCatalogPath(out); - return { - content: out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n", - managedDefaultsError: !managedDefaults.ok ? managedDefaults.error : null, - }; -} - -/** Pure transform: strip the opencodex provider block + `model_provider = "opencodex"` lines. */ -export function stripOpencodexConfig(content: string): string { - return stripOpencodexConfigResult(content).content; -} - -function hasOpencodexRouting(content: string): boolean { - return ( - hasOcxProviderTable(content) || - /^\s*model_provider\s*=\s*"opencodex"/m.test(content) || - hasInjectedOpenaiBaseUrl(content) - ); -} - -export function removeCodexConfig( - options: { preserveProfile?: boolean } = {}, -): { success: boolean; message: string } { - const historyError = preflightCodexHistoryInjection(false, false); - if (historyError) return { success: false, message: `Codex configuration preserved: ${historyError}. Native writer coordination is required.` }; - if (!existsSync(CODEX_CONFIG_PATH)) { - if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) - unlinkSync(CODEX_PROFILE_PATH); - return { - success: true, - message: `Codex config not found; no native restore was needed${options.preserveProfile ? "." : ", and the opencodex profile was removed if present."}`, - }; - } - const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8"); - // Same EOL boundary as inject: strip in LF space, write back in the file's own ending. - // The unchanged fast path compares in LF space so an untouched file is never rewritten. - const eol = dominantEol(rawContent); - const content = applyEol(rawContent, "\n"); - // Read the recorded injection once: the strip below consumes it, and so does the - // ownership verdict, which must agree with what was actually removed. - const journaledBaseUrl = journaledInjectedOpenaiBaseUrl(); - const journaledRealtimeWsBaseUrl = journaledInjectedRealtimeWsBaseUrl(); - const had = hasOpencodexRouting(content) - || (journaledBaseUrl !== null && rootTomlString(content, "openai_base_url") === journaledBaseUrl) - || (journaledRealtimeWsBaseUrl !== null - && rootTomlString(content, REALTIME_WS_BASE_URL_KEY) === journaledRealtimeWsBaseUrl); - const stripped = stripOpencodexConfigResult(content, journaledBaseUrl, journaledRealtimeWsBaseUrl); - if (had || stripped.content !== content) { - atomicWriteFile(CODEX_CONFIG_PATH, applyEol(stripped.content, eol)); - } - if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) - unlinkSync(CODEX_PROFILE_PATH); - const removedMessage = had - ? `Removed opencodex routing from Codex config${options.preserveProfile ? "." : " + profile."}` - : "opencodex not present in Codex config."; - if (stripped.managedDefaultsError) { - const routingMessage = had - ? removedMessage - : "No opencodex routing was present in Codex config."; - return { - success: false, - message: - `${routingMessage} Native Codex sub-agent defaults could not be safely removed: ${stripped.managedDefaultsError}. ` + - "The ambiguous marker and adjacent value were preserved; inspect $CODEX_HOME/config.toml before using native Codex.", - }; - } - return { - success: true, - message: removedMessage, - }; -} - -export type CodexRestoreArtifactState = "ok" | "skipped" | "failed"; - -export interface CodexRestoreConfigResult { - state: CodexRestoreArtifactState; - changed: boolean; - action: "journal-restored" | "owned-fields-stripped" | "external-provider-preserved" | "failed"; - message: string; -} - -export interface CodexRestoreCatalogResult { - state: CodexRestoreArtifactState; - changed: boolean; - removed: number; - kept: number; - path: string | null; - message: string; -} - -export interface CodexRestoreHistoryResult { - state: CodexRestoreArtifactState; - changed: boolean; - reason?: CodexHistoryFailureReason; - rows: number; - files: number; - ejectedRows: number; - message: string; -} - -export interface CodexNativeRestoreResult { - success: boolean; - message: string; - externalProvider?: string; - artifacts: { - config: CodexRestoreConfigResult; - catalog: CodexRestoreCatalogResult; - history: CodexRestoreHistoryResult; - }; -} - -function failedHistoryRestore( - reason?: CodexHistoryFailureReason, - detail?: string, - progress: { rows?: number; files?: number } = {}, -): CodexRestoreHistoryResult { - const rows = progress.rows ?? 0; - const files = progress.files ?? 0; - const changed = rows > 0 || files > 0; - return { - state: "failed", - changed, - ...(reason ? { reason } : {}), - rows, - files, - ejectedRows: 0, - message: reason === "permission" - ? changed - ? "Codex resume history changed but did NOT converge because permission was denied while finalizing the backup manifest; the manifest was retained for review and safe retry." - : "Codex resume history could NOT be restored because permission was denied." - : reason === "busy" - ? changed - ? "Codex resume history changed but did NOT converge because backup-manifest finalization remained busy; the manifest was retained for review and safe retry." - : detail ?? "Codex resume history could NOT be restored — the Codex app appears to be holding the history database." - : reason === "integrity" - ? changed - ? "Codex resume history changed but did NOT converge because the backup or target changed; the manifest was retained for review and safe retry." - : "Codex resume history could NOT be restored because the backup or restore target failed integrity checks; unverified provider metadata was left unchanged." - : detail - ? `Codex resume history could NOT be restored: ${detail}` - : "Codex resume history could NOT be restored; the reason was not recorded. Run 'ocx doctor'.", - }; -} - -/** - * Restore failure wording for a Worker outcome. - * - * Only a genuine busy result blames the Codex app. An unsafe-path refusal, an - * unavailable coordinator database, a permission denial, or a dead/timed-out - * worker is a different problem; the old collapse made every one of those read - * as "the Codex app is holding the database" (issue #1191). `busy` and - * `permission` keep the restore-specific sentence built by - * `failedHistoryRestore`; every other reason reuses the single formatter so - * the two modules cannot drift apart. - */ -export function failedHistoryRestoreFromOutcome( - outcome: Extract, -): CodexRestoreHistoryResult { - if (outcome.kind === "blocked" && outcome.reason === "busy") return failedHistoryRestore("busy"); - if (outcome.kind === "failed" && outcome.historyFailureReason === "busy") { - return failedHistoryRestore( - "busy", - describeHistoryJobFailure(outcome, "restore"), - { rows: outcome.rows, files: outcome.files }, - ); - } - if (outcome.kind === "failed" && outcome.historyFailureReason === "permission") { - return failedHistoryRestore("permission", undefined, { rows: outcome.rows, files: outcome.files }); - } - if (outcome.kind === "failed" && outcome.historyFailureReason === "integrity") { - return failedHistoryRestore("integrity", undefined, { rows: outcome.rows, files: outcome.files }); - } - return failedHistoryRestore(undefined, describeHistoryJobFailure(outcome, "restore")); -} - -function externalProviderRestoreResult(activeProvider: string): CodexNativeRestoreResult { - const message = `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.`; - return { - success: true, - message, - externalProvider: activeProvider, - artifacts: { - config: { state: "skipped", changed: false, action: "external-provider-preserved", message }, - catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message }, - history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message }, - }, - }; -} - -/** A foreign service claim is an authority boundary, including explicit CLI restore. */ -function foreignOwnershipRestoreRefusal(message: string): CodexNativeRestoreResult { - return { - success: false, - message: `Codex native restore refused: ${message}`, - artifacts: { - config: { state: "skipped", changed: false, action: "failed", message }, - catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message }, - history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message }, - }, - }; -} - -function desiredEnabledRestoreSkip(): CodexNativeRestoreResult { - const message = "Codex integration was re-enabled; native restore was skipped."; - return skippedRestoreEnvelope(true, message); -} - -/** - * A schema-complete all-skipped envelope for outcomes decided before any - * restore machinery runs. Every `restore --json` path must stay shape-stable - * with `CodexNativeRestoreResult`; consumers never special-case early exits. - */ -export function skippedRestoreEnvelope(success: boolean, message: string): CodexNativeRestoreResult { - return { - success, - message, - artifacts: { - config: { state: "skipped", changed: false, action: "owned-fields-stripped", message }, - catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message }, - history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message }, - }, - }; -} - -/** Config was attempted and failed; downstream artifacts were never attempted. */ -function failedConfigRestoreEnvelope(config: CodexRestoreConfigResult): CodexNativeRestoreResult { - const result = skippedRestoreEnvelope(false, config.message); - result.artifacts.config = config; - return result; -} - -/** The config/profile half of a native restore, reported as one artifact. */ -function restoreCodexConfigInline(kind = "sync"): CodexRestoreConfigResult { - const preImages = captureCodexPreImages(); - const result = restoreCodexConfigInlineImpl(kind); - if (result.state === "failed") { - const compensated = restoreCodexPreImages(preImages); - if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored); - } - return result; -} - -function restoreCodexConfigInlineImpl(kind: string): CodexRestoreConfigResult { - try { - beforeRestoreConfigForTests?.(kind); - const historyError = preflightCodexHistoryInjection(false, false); - if (historyError) return { state: "failed", changed: false, action: "failed", message: `Codex configuration and journal preserved: ${historyError}.` }; - const journal = restoreJournalState(); - if (journal.unverified) { - return { - state: "failed", changed: false, action: "failed", - message: "Codex journal recovery was not verified; current configuration files and the journal were preserved.", - }; - } - const restored = journal.configRestored - ? { success: true, message: "Codex config restored from opencodex journal." } - : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged }); - if (restored.success) { - // A successful journal/fallback write can race native history migration too. - // Refuse here while preimage compensation and the remove transaction can roll back. - const finalHistoryError = preflightCodexHistoryInjection(false, false); - if (finalHistoryError) return { state: "failed", changed: false, action: "failed", message: `Codex configuration and journal preserved: ${finalHistoryError}.` }; - } - return restored.success - ? { - state: "ok", - changed: journal.configRestored || journal.profileRestored || journal.profileChanged || restored.message.startsWith("Removed"), - action: journal.configRestored ? "journal-restored" : "owned-fields-stripped", - message: restored.message, - } - : { state: "failed", changed: false, action: "failed", message: restored.message }; - } catch (error) { - return { state: "failed", changed: false, action: "failed", message: error instanceof Error ? error.message : String(error) }; - } -} - -/** The catalog half, always inside its own K acquisition. */ -/** - * The catalog half, always inside its own K acquisition. - * - * `journaledCatalogPath` must be captured by the CALLER, before the config half runs: a - * successful journal restore deletes the journal, and a config restore can remove - * `model_catalog_json`. Reading it here would be too late in both cases (#1798). - */ -function restoreCodexCatalogArtifact( - revalidateDesiredState: boolean, - journaledCatalogPath: string | null, -): CodexRestoreCatalogResult { - const owningCodexHome = getCodexHome(); - try { - const restored = withCatalogWriteSerialization(owningCodexHome, permit => - revalidateDesiredState && shouldSyncCodexOnStart(loadConfig()) - ? null - : restoreCodexCatalogWithPermit(permit, owningCodexHome, journaledCatalogPath)); - return restored.kind === "completed" && restored.value !== null - ? { state: "ok", changed: restored.value.removed > 0, ...restored.value, message: "Codex catalog restored." } - : restored.kind === "completed" - ? { - state: "skipped", changed: false, removed: 0, kept: 0, path: null, - message: "Codex integration was re-enabled; native catalog restoration was skipped.", - } - : { - state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH, - message: `Codex catalog could not be restored: ${restored.reason}.`, - }; - } catch (error) { - return { - state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH, - message: error instanceof Error ? error.message : String(error), - }; - } -} - -/** - * Restore native Codex, running history in a Worker under H. - * - * On a coordinated home the config/profile restore happens INSIDE the Codex - * write lock, publishing a `remove` transition — the same serialization inject - * uses. Without it, an older restore could overwrite a config a concurrent - * enable had just written under the lock, and then honestly report success - * while desired intent said ON. The desired-state re-read under the lock turns - * that lost race into the discriminated `desired_enabled` skip. - */ -export async function restoreNativeCodexAsync( - options: { revalidateDesiredState?: boolean } = {}, -): Promise { - try { - return await restoreNativeCodexAsyncImpl(options); - } catch (error) { - if (!(error instanceof CodexRestoreRefusal)) throw error; - return failedConfigRestoreEnvelope(error.config); - } -} - -async function restoreNativeCodexAsyncImpl( - options: { revalidateDesiredState?: boolean }, -): Promise { - const activeProvider = currentExternalCodexModelProvider(); - if (activeProvider) { - // External-provider courtesy: only the stale journal is removed. The - // history worker must not launch — it would turn a read-mostly courtesy - // result into a history mutation on a home we do not own. - removeJournal(); - return externalProviderRestoreResult(activeProvider); - } - - // `restore` normally honours a human request even when an unrelated - // service-manager probe is unavailable. A recorded FOREIGN home is not an - // unrelated probe: it is positive evidence another installation owns these - // native artifacts, so do not create profile/claim locks before refusing. - if (options.revalidateDesiredState) { - const ownership = inspectNativeCodexOwnership(); - if (ownership.ownership === "foreign") return foreignOwnershipRestoreRefusal(ownership.reason); - if (shouldSyncCodexOnStart(loadConfig())) return desiredEnabledRestoreSkip(); - } - - const historyError = preflightCodexHistoryInjection(false, false); - if (historyError) return skippedRestoreEnvelope(false, `Native restore refused: ${historyError}. Config, catalog, history and provenance were preserved.`); - - const eligibility = codexWriteCoordinationEligibility({ - coordinatorPath: () => - resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), getCodexHome()), - residue: () => classifyNativeRoutedResidue(), - integrationRecord: () => readIntegrationRecord(), - }); - - // Captured before the config half: a successful journal restore DELETES the journal, and - // restoring the config can drop `model_catalog_json`. Either one would hide the routed - // catalog we actually wrote (#1798). - const journaledCatalogPath = journaledInjectedCatalogPath(); - let config: CodexRestoreConfigResult; - let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined; - - if (eligibility.kind === "coordinated" || eligibility.kind === "adopt") { - // The restore has no candidate bytes to witness; freshness comes from the - // filesystem reads and the desired-state re-read performed under the lock. - const witness = { authoritySnapshotId: "codex-native-restore" }; - const coordinated = await withCodexWriteLock( - { - timeoutMs: DEFAULT_INJECT_LOCK_TIMEOUT_MS, - ...(eligibility.kind === "adopt" ? { adoption: { direction: "remove" as const } } : {}), - admitted: witness, - readAdmissionUnderLock: () => witness, - }, - (ctx) => { - if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) { - throw new CodexWriteLockSkipped("desired_enabled"); - } - const published = ctx.coordinator.beginTransition( - { - nativeGeneration: ctx.expectation.nativeBefore, - currentTxId: ctx.currentTxId, - }, - { - txId: ctx.expectation.txId, - direction: "remove", - authoritySnapshotId: ctx.admission.authoritySnapshotId, - nextRetryAt: new Date().toISOString(), - }, - ); - if (published.kind !== "updated") { - throw new CodexWriteConflictError( - `The Codex transition could not be published: ${published.kind}.`, - ); - } - const preImages = captureCodexPreImages(); - let restored: CodexRestoreConfigResult; - try { - restored = restoreCodexConfigInline(eligibility.kind); - // Throw inside N so the published remove transition rolls back too. - if (restored.state === "failed") throw new CodexRestoreRefusal(restored); - } catch (error) { - const compensated = restoreCodexPreImages(preImages); - if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored); - throw error; - } - return { - config: restored, - preImages, - receipt: { - nativeGeneration: ctx.expectation.nativeAfter, - currentTxId: ctx.expectation.txId, - }, - }; - }, - ); - if (coordinated.status === "skipped") return desiredEnabledRestoreSkip(); - if (coordinated.status !== "acquired") { - config = { - state: "failed", - changed: false, - action: "failed", - message: coordinated.status === "busy" - ? `Another process is writing Codex configuration right now (waited ${coordinated.waitedMs}ms). Retry shortly.` - : `Codex configuration was not restored: ${coordinated.message}`, - }; - } else { - recordCodexNativeTransactionProvenance( - coordinated.value.preImages, - coordinated.value.receipt.currentTxId, - ); - config = coordinated.value.config; - transitionReceipt = coordinated.value.receipt; - } - } else { - // Legacy-uncoordinated (or unresolvable) homes keep the unserialized path - // they have always had; restore is the escape hatch and must not strand - // them. The plain re-read still honors an intervening re-enable. - if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) { - return desiredEnabledRestoreSkip(); - } - config = restoreCodexConfigInline(eligibility.kind); - } - - if (config.state === "failed") return failedConfigRestoreEnvelope(config); - const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath); - const outcome = await runCodexHistoryJob({ - ...resolveCodexHistoryJobTarget(), - ...(options.revalidateDesiredState ? { expectedDesiredEnabled: false } : {}), - operation: deriveCodexHistoryOperation({ direction: "restore", resumeHistory: true, legacyMode: false }), - }); - if (transitionReceipt) { - resolveCodexHistoryTransition(transitionReceipt, outcome); - } - const history: CodexRestoreHistoryResult = outcome.kind === "converged" - ? { - state: "ok", changed: outcome.rows > 0 || outcome.files > 0, rows: outcome.rows, files: outcome.files, ejectedRows: 0, - message: outcome.rows > 0 - ? `Resume history metadata restored from opencodex backup (${outcome.rows} thread(s)); original providers preserved.` - : "No backed-up resume-history metadata was pending; untracked routed history was left unchanged.", - } - : outcome.kind === "skipped" - ? { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message: "Codex resume history was skipped." } - : outcome.kind === "blocked" && (outcome.reason === "desired_disabled" || outcome.reason === "desired_enabled") - ? { - state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, - message: outcome.reason === "desired_disabled" - ? "Codex integration was disabled; history restoration was skipped." - : "Codex integration was enabled; history restoration was skipped.", - } - : outcome.kind === "blocked" || outcome.kind === "failed" - ? failedHistoryRestoreFromOutcome(outcome) - : failedHistoryRestore(); - const base = catalog.removed > 0 - ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).` - : config.message; - const success = catalog.state !== "failed" - && history.state !== "failed"; - return { - success, - message: `${base}${history.state === "failed" ? ` ⚠️ ${history.message}` : ""}`, - artifacts: { config, catalog, history }, - }; -} - -export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateDesiredState?: boolean } = {}): CodexNativeRestoreResult { - const activeProvider = currentExternalCodexModelProvider(); - if (activeProvider) { - removeJournal(); - return externalProviderRestoreResult(activeProvider); - } - if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) { - return desiredEnabledRestoreSkip(); - } - const historyError = preflightCodexHistoryInjection(false, false); - if (historyError) return skippedRestoreEnvelope(false, `Native restore refused: ${historyError}. Config, catalog, history and provenance were preserved.`); - // Captured before the config half: a successful journal restore DELETES the journal, and - // restoring the config can drop `model_catalog_json`. Either one would hide the routed - // catalog we actually wrote (#1798). - const journaledCatalogPath = journaledInjectedCatalogPath(); - const config = restoreCodexConfigInline(); - if (config.state === "failed") return failedConfigRestoreEnvelope(config); - const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath); - // Design B (loopback) steady state: threads are already tagged openai, so prove the - // no-op with a readonly probe instead of write-opening a DB the Codex app may hold - // (Windows: WAL writer lock -> seconds of stalling + a false warning on every stop). - // Legacy (non-loopback) installs keep the unconditional write-open restore. - let skipWhenProvablyNoop = false; - try { - skipWhenProvablyNoop = !shouldInjectApiAuthHeader(loadConfig()); - } catch { - /* unreadable config: keep the conservative write-open restore */ - } - // `skipHistory` is how the async wrapper takes this work for itself: the - // native files come down here, and history runs in the Worker under H. - const rawHistory = options.skipHistory - ? { rows: 0, files: 0 } - : syncCodexHistoryProvider("openai", undefined, undefined, { - skipWhenProvablyNoop, - }); - const history: CodexRestoreHistoryResult = options.skipHistory - ? { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message: "History restoration runs asynchronously." } - : rawHistory.failed - ? failedHistoryRestore(rawHistory.failureReason, undefined, rawHistory) - : { - state: "ok", - changed: rawHistory.rows > 0 || rawHistory.files > 0 || (rawHistory.ejectedRows ?? 0) > 0, - rows: rawHistory.rows, - files: rawHistory.files, - ejectedRows: rawHistory.ejectedRows ?? 0, - message: rawHistory.rows > 0 - ? `Resume history metadata restored from opencodex backup (${rawHistory.rows} thread(s)); original providers preserved.` - : "No backed-up resume-history metadata was pending; untracked routed history was left unchanged.", - }; - const message = catalog.removed > 0 - ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).` - : config.message; - return { - success: catalog.state !== "failed" && history.state !== "failed", - message, - artifacts: { config, catalog, history }, - }; -} - export function getCodexConfigPath(): string { return CODEX_CONFIG_PATH; } @@ -2340,3 +935,53 @@ export function formatApplyHistoryFailure(outcome: CodexHistoryJobOutcome, legac : "Codex resume history NOT changed"; return ` ⚠️ ${headline}: ${describeHistoryJobFailure(outcome, "apply", legacyMode)}\n`; } + +export { + providerBaseHost, + standaloneCodexRoutingTarget, +} from "./inject/routing-target"; +export type { CodexRoutingTarget } from "./inject/routing-target"; + +export { + applyEol, + buildOpenaiBaseUrlLine, + buildProfileFile, + buildProviderTableBlock, + buildRealtimeWsBaseUrlLine, + chooseCatalogPathForInjection, + currentExternalCodexModelProvider, + dominantEol, + externalCodexModelProvider, + setRootOpenaiBaseUrl, + setRootRealtimeWsBaseUrl, + stripInjectedOpenaiBaseUrl, + stripRootContextWindowOverrides, +} from "./inject/config-toml"; + +export { + classifyCodexRouting, + getCodexRoutingKind, + isCodexRoutingInjected, +} from "./inject/routing-classify"; +export type { CodexRoutingKind } from "./inject/routing-classify"; + +export { + removeCodexConfig, + stripOpencodexConfig, +} from "./inject/remove"; + +export type { + CodexNativeRestoreResult, + CodexRestoreArtifactState, + CodexRestoreCatalogResult, + CodexRestoreConfigResult, + CodexRestoreHistoryResult, +} from "./inject/restore"; +export { + failedHistoryRestoreFromOutcome, + restoreNativeCodex, + restoreNativeCodexAsync, + setBeforeRestoreConfigForTests, + skippedRestoreEnvelope, +} from "./inject/restore"; + diff --git a/src/codex/inject/config-toml.ts b/src/codex/inject/config-toml.ts new file mode 100644 index 0000000000..f8fd67b61f --- /dev/null +++ b/src/codex/inject/config-toml.ts @@ -0,0 +1,563 @@ +// Holds INV-TOML-01 from structure/overview.md; keep the id here if this file is split or renamed. +import { existsSync, readFileSync } from "node:fs"; +import { contextCompatibleBaseLine } from "../context-compat"; +import { resolveEffectiveProjectModelProvider } from "../project-config-warnings"; +import { + OCX_SECTION_MARKER, + REALTIME_WS_BASE_URL_KEY, + isRootOpenaiBaseUrlLine, + isRootRealtimeWsBaseUrlLine, + tomlStringPattern, +} from "../injected-marker"; +import { + CODEX_CONFIG_PATH, + DEFAULT_CATALOG_PATH, + parseTomlString, + resolveCodexConfigPath, + tomlString, +} from "../paths"; +import { + type CodexRoutingTarget, + providerBaseHost, + routingTargetOrigin, + usesProviderTable, + validateCodexRoutingTarget, +} from "./routing-target"; + +export function externalCodexModelProvider(content: string): string | null { + const provider = resolveEffectiveProjectModelProvider(content).provider; + return provider && provider !== "openai" && provider !== "opencodex" + ? provider + : null; +} + +export function currentExternalCodexModelProvider(): string | null { + if (!existsSync(CODEX_CONFIG_PATH)) return null; + return externalCodexModelProvider(readFileSync(CODEX_CONFIG_PATH, "utf8")); +} + +/** + * Detect the file's dominant line ending. Every transform in this module is LF-pure + * (split("\n") + hard "\n" joins), so CRLF configs (Windows-edited config.toml) are + * normalized to LF at the pipeline boundary and converted back on write — otherwise a + * single inject would leave a mixed-EOL file. + */ +export function dominantEol(content: string): "\r\n" | "\n" { + const crlf = (content.match(/\r\n/g) ?? []).length; + if (crlf === 0) return "\n"; + const bareLf = (content.match(/\n/g) ?? []).length - crlf; + return crlf >= bareLf ? "\r\n" : "\n"; +} + +/** Normalize all line endings to `eol` (CRLF first collapsed to LF, then expanded). */ +export function applyEol(content: string, eol: "\r\n" | "\n"): string { + const lf = content.replace(/\r\n/g, "\n"); + return eol === "\n" ? lf : lf.replace(/\n/g, "\r\n"); +} + +export function buildProviderTableBlock( + port: number, + supportsWebsockets?: boolean, + includeApiAuthHeader?: boolean, + hostname?: string, +): string; +export function buildProviderTableBlock( + target: CodexRoutingTarget, + supportsWebsockets?: boolean, +): string; +export function buildProviderTableBlock( + portOrTarget: number | CodexRoutingTarget, + supportsWebsockets = false, + includeApiAuthHeader = false, + hostname?: string, +): string { + const target = typeof portOrTarget === "number" + ? validateCodexRoutingTarget({ + baseUrl: `http://${providerBaseHost(hostname)}:${portOrTarget}/v1`, + requiresAdmissionToken: includeApiAuthHeader, + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + }) + : validateCodexRoutingTarget(portOrTarget); + return buildProviderTableBlockForTarget(target, supportsWebsockets); +} + +export function buildProviderTableBlockForTarget( + target: CodexRoutingTarget, + supportsWebsockets = false, +): string { + const lines = [ + "", + OCX_SECTION_MARKER, + "[model_providers.opencodex]", + 'name = "OpenCodex Proxy"', + `base_url = ${tomlString(target.baseUrl)}`, + 'wire_api = "responses"', + // false only in the authless Desktop opt-in (#1107); true keeps the App/TUI account gate. + `requires_openai_auth = ${target.desktopAuthless === true ? "false" : "true"}`, + ]; + if (target.requiresAdmissionToken) { + // codex-cli 0.146+ contract (#2073): env_key sends Authorization: Bearer $VAR and + // hard-errors on a missing/empty variable instead of silently omitting auth. It + // coexists with requires_openai_auth (env_key wins wire auth; the flag keeps the + // login/account UX), and the server substitutes stored main auth for our admission + // bearer (#1686), so the modern form is strictly better than the legacy + // env_http_headers table this line used to emit. + lines.push(`env_key = ${tomlString(target.tokenEnv)}`); + } + if (supportsWebsockets) lines.push("supports_websockets = true"); + return lines.join("\n") + "\n"; +} + +export function buildOpenaiBaseUrlLine( + port: number, + hostname?: string, +): string; +export function buildOpenaiBaseUrlLine(target: CodexRoutingTarget): string; +export function buildOpenaiBaseUrlLine( + portOrTarget: number | CodexRoutingTarget, + hostname?: string, +): string { + return typeof portOrTarget === "number" + ? `openai_base_url = "http://${providerBaseHost(hostname)}:${portOrTarget}/v1"` + : buildOpenaiBaseUrlLineForTarget(validateCodexRoutingTarget(portOrTarget)); +} + +function buildOpenaiBaseUrlLineForTarget(target: CodexRoutingTarget): string { + return `openai_base_url = ${tomlString(target.baseUrl)}`; +} + +/** + * Realtime sideband override (codex-rs `experimental_realtime_ws_base_url`), written with the + * SAME value as `openai_base_url`. Desktop voice creates its WebRTC call through the proxy + * (`POST /v1/live`, answered under the Pool account the proxy selects) but, since openai/codex + * 438c9e98d (#35830), joins the sideband at `wss://api.openai.com/v1/live/{callId}` with the + * app's own login unless this key redirects it. Two accounts, one call: the join 404s. Pointing + * the key at the proxy sends the join through `GET /v1/live/{callId}` (src/server/live.ts), + * where the same Pool account is reused. codex-rs turns `http` into `ws` and appends + * `/live/{callId}` itself; the value must stay the canonical `/v1` root. + */ +export function buildRealtimeWsBaseUrlLine(target: CodexRoutingTarget): string { + return `${REALTIME_WS_BASE_URL_KEY} = ${tomlString(target.baseUrl)}`; +} + +/** + * Design B root-key injection: place `OCX_SECTION_MARKER` + `openai_base_url` at the document + * ROOT (before the first table header). Idempotent: an existing marker-owned line is rewritten + * in place. A user's OWN root `openai_base_url` (no marker above it) is respected — we keep it + * and inject nothing, reporting `keptUserBaseUrl` so the caller can surface it. + */ +export function setRootOpenaiBaseUrl( + content: string, + port: number, + hostname?: string, +): { content: string; keptUserBaseUrl: boolean }; +export function setRootOpenaiBaseUrl( + content: string, + target: CodexRoutingTarget, +): { content: string; keptUserBaseUrl: boolean }; +export function setRootOpenaiBaseUrl( + content: string, + portOrTarget: number | CodexRoutingTarget, + hostname?: string, +): { content: string; keptUserBaseUrl: boolean } { + if (typeof portOrTarget !== "number") { + return setRootOpenaiBaseUrlForTarget(content, validateCodexRoutingTarget(portOrTarget)); + } + const lines = content.split("\n"); + const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); + const rootEnd = firstTable === -1 ? lines.length : firstTable; + const key = contextCompatibleBaseLine(content, buildOpenaiBaseUrlLine(portOrTarget, hostname)); + + for (let i = 0; i < rootEnd; i++) { + if (!isRootOpenaiBaseUrlLine(lines[i])) continue; + const markerOwned = i > 0 && lines[i - 1].includes(OCX_SECTION_MARKER); + if (!markerOwned) return { content, keptUserBaseUrl: true }; + lines[i] = key; + return { content: lines.join("\n"), keptUserBaseUrl: false }; + } + + if (firstTable === -1) { + return { + content: + content.replace(/\n+$/, "") + + "\n" + + OCX_SECTION_MARKER + + "\n" + + key + + "\n", + keptUserBaseUrl: false, + }; + } + let insertAt = firstTable; + while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--; + lines.splice(insertAt, 0, OCX_SECTION_MARKER, key); + return { content: lines.join("\n"), keptUserBaseUrl: false }; +} + +export function setRootOpenaiBaseUrlForTarget( + content: string, + target: CodexRoutingTarget, +): { content: string; keptUserBaseUrl: boolean } { + const lines = content.split("\n"); + const firstTable = lines.findIndex((line) => /^\s*\[/.test(line)); + const rootEnd = firstTable === -1 ? lines.length : firstTable; + const key = contextCompatibleBaseLine(content, buildOpenaiBaseUrlLineForTarget(target)); + for (let index = 0; index < rootEnd; index += 1) { + if (!isRootOpenaiBaseUrlLine(lines[index])) continue; + const markerOwned = index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER); + if (!markerOwned) return { content, keptUserBaseUrl: true }; + lines[index] = key; + return { content: lines.join("\n"), keptUserBaseUrl: false }; + } + if (firstTable === -1) { + return { + content: `${content.replace(/\n+$/, "")}\n${OCX_SECTION_MARKER}\n${key}\n`, + keptUserBaseUrl: false, + }; + } + let insertAt = firstTable; + while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt -= 1; + lines.splice(insertAt, 0, OCX_SECTION_MARKER, key); + return { content: lines.join("\n"), keptUserBaseUrl: false }; +} + +/** + * Companion to `setRootOpenaiBaseUrlForTarget` for the realtime sideband override. Same + * ownership rule, applied per key: the line is ours only when the marker sits directly + * above it; a user's own line (no marker above it) is kept and nothing is injected. The + * key gets its OWN marker line rather than sharing the routing override's, so a user line + * that happens to sit right under our `openai_base_url` is never mistaken for ours. + * Placement: directly after the marker-owned `openai_base_url` pair. Only ever called on + * the Design B (loopback) path right after the routing override was written — the legacy + * provider-table form needs the admission-token header, which the sideband cannot carry. + */ +export function setRootRealtimeWsBaseUrl( + content: string, + target: CodexRoutingTarget, +): { content: string; keptUserRealtimeWsBaseUrl: boolean } { + const lines = content.split("\n"); + const firstTable = lines.findIndex((line) => /^\s*\[/.test(line)); + const rootEnd = firstTable === -1 ? lines.length : firstTable; + const key = buildRealtimeWsBaseUrlLine(validateCodexRoutingTarget(target)); + for (let index = 0; index < rootEnd; index += 1) { + if (!isRootRealtimeWsBaseUrlLine(lines[index])) continue; + const markerOwned = index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER); + if (!markerOwned) return { content, keptUserRealtimeWsBaseUrl: true }; + lines[index] = key; + return { content: lines.join("\n"), keptUserRealtimeWsBaseUrl: false }; + } + for (let index = 0; index < rootEnd; index += 1) { + if (!isRootOpenaiBaseUrlLine(lines[index])) continue; + if (!(index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER))) continue; + lines.splice(index + 1, 0, OCX_SECTION_MARKER, key); + return { content: lines.join("\n"), keptUserRealtimeWsBaseUrl: false }; + } + // No marker-owned routing override to attach to: the override has no owner, so inject nothing. + return { content, keptUserRealtimeWsBaseUrl: false }; +} + +/** + * Remove the marker-owned root `openai_base_url` (marker line + the key line right after it). + * A user's own root override (no marker) survives; an orphaned marker with no key line after + * it is dropped too so repeated strip/inject cycles cannot accumulate marker comments. + * A marker-owned `experimental_realtime_ws_base_url` pair is removed by the same rule. + */ +export function stripInjectedOpenaiBaseUrl(content: string): string { + const lines = content.split("\n"); + const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); + const rootEnd = firstTable === -1 ? lines.length : firstTable; + const drop = new Set(); + for (let i = 0; i < rootEnd; i++) { + if (!lines[i].includes(OCX_SECTION_MARKER)) continue; + if (i + 1 < rootEnd && (isRootOpenaiBaseUrlLine(lines[i + 1]) || isRootRealtimeWsBaseUrlLine(lines[i + 1]))) { + drop.add(i); + drop.add(i + 1); + } else if (i + 1 >= rootEnd || lines[i + 1].trim() === "") { + drop.add(i); // orphaned marker at root + } + } + if (drop.size === 0) return content; + return lines.filter((_, i) => !drop.has(i)).join("\n"); +} + +/** + * Strip every existing `model_provider` line that we must not duplicate: any line set to + * "opencodex" (wherever it sits — including a previously mis-nested one under a table), plus any + * ROOT-level model_provider (before the first table) of any value, since we override the global. + * A `model_provider` legitimately inside a user table/profile with a non-opencodex value is left + * untouched. + */ +export function stripExistingModelProvider(content: string): string { + const lines = content.split("\n"); + const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); + const out: string[] = []; + lines.forEach((line, i) => { + if (/^\s*model_provider\s*=/.test(line)) { + const isOurs = /^\s*model_provider\s*=\s*"opencodex"\s*$/.test(line); + const isRoot = firstTable === -1 || i < firstTable; + if (isOurs || isRoot) return; // drop it + } + out.push(line); + }); + return out.join("\n"); +} + +/** + * Drop ROOT-level `model_context_window` overrides (keys before the first table header). Codex + * treats this root key as a global override that wins over the per-model catalog values, so a stale + * `model_context_window = 1000000` makes every model (e.g. gpt-5.5) report a 1M window. User-owned + * compaction limits do not alter the advertised context window and must survive reinjection. + */ +export function stripRootContextWindowOverrides(content: string): string { + const lines = content.split("\n"); + const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); + return lines + .filter((line, i) => { + const isRoot = firstTable === -1 || i < firstTable; + return !isRoot || !/^\s*model_context_window\s*=/.test(line); + }) + .join("\n"); +} + +export function stripRootRoutedModel(content: string): string { + const lines = content.split("\n"); + const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); + return lines + .filter((line, i) => { + const isRoot = firstTable === -1 || i < firstTable; + if (!isRoot) return true; + const m = line.match(/^\s*model\s*=\s*("(?:\\.|[^"\\])*"|'[^']*')\s*$/); + if (!m) return true; + const model = parseTomlString(m[1]); + return !model?.includes("/"); + }) + .join("\n"); +} + +/** + * Insert `model_provider = "opencodex"` at the document ROOT — immediately before the first table + * header (TOML root keys must precede all tables). If there are no tables, append it to the root body. + */ +export function setRootModelProvider(content: string): string { + const lines = content.split("\n"); + const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); + const key = 'model_provider = "opencodex"'; + if (firstTable === -1) { + return content.replace(/\n+$/, "") + "\n" + key + "\n"; + } + let insertAt = firstTable; + while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--; + lines.splice(insertAt, 0, key); + return lines.join("\n"); +} + +function readRootModelCatalogPath(content: string): string | null { + const lines = content.split("\n"); + const firstTable = lines.findIndex((line) => /^\s*\[/.test(line)); + const rootEnd = firstTable === -1 ? lines.length : firstTable; + const modelCatalogAssignment = tomlStringPattern("model_catalog_json"); + let ownedCatalogPath: string | null = null; + for (let index = 0; index < rootEnd; index += 1) { + const match = modelCatalogAssignment.exec(lines[index]); + if (!match) continue; + const catalogPath = parseTomlString(match[1]); + if (!isOpencodexCatalogPath(catalogPath)) return catalogPath; + ownedCatalogPath ??= catalogPath; + } + return ownedCatalogPath; +} + +export function setRootModelCatalogPath(content: string, catalogPath: string): string { + const lines = content.split("\n"); + const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); + const key = `model_catalog_json = ${tomlString(catalogPath)}`; + const modelCatalogAssignment = tomlStringPattern("model_catalog_json"); + const rootEnd = firstTable === -1 ? lines.length : firstTable; + const ownedAssignments: number[] = []; + let hasUserAssignment = false; + for (let i = 0; i < rootEnd; i++) { + const m = modelCatalogAssignment.exec(lines[i]); + if (!m) continue; + const existing = parseTomlString(m[1]); + if (isOpencodexCatalogPath(existing)) { + ownedAssignments.push(i); + } else { + hasUserAssignment = true; + } + } + if (hasUserAssignment) { + const owned = new Set(ownedAssignments); + return lines.filter((_, index) => !owned.has(index)).join("\n"); + } + if (ownedAssignments.length > 0) { + lines[ownedAssignments[0]] = key; + const duplicates = new Set(ownedAssignments.slice(1)); + return lines.filter((_, index) => !duplicates.has(index)).join("\n"); + } + if (firstTable === -1) { + return content.replace(/\n+$/, "") + "\n" + key + "\n"; + } + let insertAt = firstTable; + while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--; + lines.splice(insertAt, 0, key); + return lines.join("\n"); +} + +export function removeProfileSection(content: string): string { + const lines = content.split("\n"); + const filtered: string[] = []; + let inProfile = false; + for (const line of lines) { + if (line.trim() === "[profiles.opencodex]") { + inProfile = true; + continue; + } + if (inProfile) { + if (/^\s*\[/.test(line) && line.trim() !== "[profiles.opencodex]") { + inProfile = false; + filtered.push(line); + } + continue; + } + filtered.push(line); + } + return ( + filtered + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trimEnd() + "\n" + ); +} + +export function normalizeServiceTier(content: string): string { + return content.replace( + /^(\s*service_tier\s*=\s*)["']priority["']\s*$/gm, + '$1"fast"', + ); +} + +export function ensureFastModeFeature(content: string, fastMode?: boolean): string { + // Tri-state fast mode (see OcxConfig.fastMode): true forces `fast_mode = true`, + // false forces `fast_mode = false`, and undefined leaves the user's config + // untouched (no [features] table is added and an existing fast_mode line is + // preserved as-is). Table and key matching accept the valid TOML spellings + // `[features] # comment`, `["features"]` / `['features']`, and quoted keys. + const lines = content.split("\n"); + const featuresHeader = /^\s*\[(["']?)\s*features\s*\1\]\s*(?:#.*)?$/; + const fastModeKey = /^\s*(?:"fast_mode"|'fast_mode'|fast_mode)\s*=/; + const featuresStart = lines.findIndex(line => featuresHeader.test(line)); + if (featuresStart === -1) { + if (fastMode === undefined) return content; + return content.trimEnd() + "\n\n[features]\nfast_mode = " + (fastMode ? "true" : "false") + "\n"; + } + + const nextTable = lines.findIndex( + (line, index) => index > featuresStart && /^\s*\[/.test(line), + ); + const featuresEnd = nextTable === -1 ? lines.length : nextTable; + for (let i = featuresStart + 1; i < featuresEnd; i++) { + if (fastModeKey.test(lines[i])) { + if (fastMode === undefined) return lines.join("\n"); + lines[i] = lines[i].replace(/^(\s*)(?:"fast_mode"|'fast_mode'|fast_mode)\s*=.*$/, `$1fast_mode = ${fastMode ? "true" : "false"}`); + return lines.join("\n"); + } + } + + if (fastMode === undefined) return lines.join("\n"); + let insertAt = featuresEnd; + while (insertAt > featuresStart + 1 && lines[insertAt - 1].trim() === "") insertAt--; + lines.splice(insertAt, 0, `fast_mode = ${fastMode ? "true" : "false"}`); + return lines.join("\n"); +} + +function isOpencodexCatalogPath(path: string): boolean { + return path.replace(/\\/g, "/").split("/").pop() === "opencodex-catalog.json"; +} + +export function stripOpencodexCatalogPath(content: string): string { + const modelCatalogAssignment = tomlStringPattern("model_catalog_json"); + const lines = content.split("\n"); + const firstTable = lines.findIndex((line) => /^\s*\[/.test(line)); + const rootEnd = firstTable === -1 ? lines.length : firstTable; + return lines + .filter((line, index) => { + if (index >= rootEnd) return true; + const m = modelCatalogAssignment.exec(line); + return !m || !isOpencodexCatalogPath(parseTomlString(m[1])); + }) + .join("\n"); +} + +export function buildProfileFile(port: number, catalogPath?: string | null, supportsWebsockets?: boolean, includeApiAuthHeader?: boolean, hostname?: string, fastMode?: boolean): string; +export function buildProfileFile(target: CodexRoutingTarget, catalogPath?: string | null, supportsWebsockets?: boolean, fastMode?: boolean): string; +export function buildProfileFile( + portOrTarget: number | CodexRoutingTarget, + catalogPath?: string | null, + supportsWebsockets = false, + includeApiAuthHeaderOrFastMode?: boolean, + hostname?: string, + fastMode?: boolean, +): string { + const target = typeof portOrTarget === "number" + ? validateCodexRoutingTarget({ + baseUrl: `http://${providerBaseHost(hostname)}:${portOrTarget}/v1`, + requiresAdmissionToken: includeApiAuthHeaderOrFastMode === true, + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + }) + : validateCodexRoutingTarget(portOrTarget); + return buildProfileFileForTarget( + target, + catalogPath, + supportsWebsockets, + typeof portOrTarget === "number" ? fastMode : includeApiAuthHeaderOrFastMode, + ); +} + +export function buildProfileFileForTarget( + target: CodexRoutingTarget, + catalogPath?: string | null, + supportsWebsockets = false, + fastMode?: boolean, +): string { + const origin = routingTargetOrigin(target); + const host = new URL(origin).host; + // Design B (loopback): the reference/fallback file documents the root override form. + // Non-loopback keeps the legacy provider-table shape (built-in provider cannot carry + // the x-opencodex-api-key env header); explicit Desktop policies share that shape. + if (!usesProviderTable(target)) { + const lines = [ + "# OpenCodex proxy fallback config (Design B)", + `# Root override that points Codex's built-in openai provider at the proxy on ${host}.`, + "# Merge these root keys into ~/.codex/config.toml manually if auto-injection was removed.", + buildOpenaiBaseUrlLineForTarget(target), + ]; + if (catalogPath) lines.push(`model_catalog_json = ${tomlString(catalogPath)}`); + if (fastMode !== undefined) lines.push("", "[features]", `fast_mode = ${fastMode ? "true" : "false"}`, ""); + return lines.join("\n"); + } + const lines = [ + "# OpenCodex proxy profile — use with: codex --profile opencodex", + `# Routes all model requests through the opencodex proxy at ${host}`, + 'model_provider = "opencodex"', + ]; + if (catalogPath) lines.push(`model_catalog_json = ${tomlString(catalogPath)}`); + if (fastMode !== undefined) lines.push("", "[features]", `fast_mode = ${fastMode ? "true" : "false"}`); + lines.push(buildProviderTableBlockForTarget(target, supportsWebsockets).trimEnd(), ""); + return lines.join("\n"); +} + +export function chooseCatalogPathForInjection( + content: string, + requested?: string | null, +): string | null { + if (requested !== undefined) return requested; + + const existing = readRootModelCatalogPath(content); + if (existing) { + const resolved = resolveCodexConfigPath(existing); + if (!isOpencodexCatalogPath(resolved) || existsSync(resolved)) + return existing; + } + + return existsSync(DEFAULT_CATALOG_PATH) ? DEFAULT_CATALOG_PATH : null; +} diff --git a/src/codex/inject/remove.ts b/src/codex/inject/remove.ts new file mode 100644 index 0000000000..fb56b71a44 --- /dev/null +++ b/src/codex/inject/remove.ts @@ -0,0 +1,192 @@ +import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { atomicWriteFile } from "../../config"; +import { + OCX_SECTION_MARKER, + REALTIME_WS_BASE_URL_KEY, + hasInjectedOpenaiBaseUrl, + rootTomlString, + stripJournaledOpenaiBaseUrl, +} from "../injected-marker"; +import { preflightCodexHistoryInjection } from "../history-provider"; +import { + journaledInjectedOpenaiBaseUrl, + journaledInjectedRealtimeWsBaseUrl, +} from "../journal"; +import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, readRootTomlString } from "../paths"; +import { transformManagedSubagentDefaults } from "../subagent-defaults"; +import { + applyEol, + dominantEol, + removeProfileSection, + stripInjectedOpenaiBaseUrl, + stripOpencodexCatalogPath, + stripRootRoutedModel, +} from "./config-toml"; + +/** + * Sub-table headers like `[model_providers.opencodex.env_http_headers]` appear when a Codex app + * config rewrite re-serializes the provider's inline `env_http_headers` table. They define the + * same `model_providers.opencodex` provider, so cleanup must remove them too — otherwise the + * provider survives with no `name` and Codex rejects the whole config + * ("provider name must not be empty"). The dot terminator keeps a user's + * `[model_providers.opencodex_backup]`-style tables out of scope. + */ +function isOcxProviderHeaderLine(trimmedLine: string): boolean { + // Root form matched by regex, not equality: TOML v1.0 allows a trailing comment + // (`[model_providers.opencodex] # comment`), and an exact compare would miss that form. + // The sub-table prefix check already tolerates trailing comments by construction. + return ( + /^\[model_providers\.opencodex\]\s*(?:#.*)?$/.test(trimmedLine) || + trimmedLine.startsWith("[model_providers.opencodex.") + ); +} + +export function hasOcxProviderTable(content: string): boolean { + return content + .split("\n") + .some((line) => isOcxProviderHeaderLine(line.trim())); +} + +export function removeOcxSection(content: string): string { + const lines = content.split("\n"); + const filtered: string[] = []; + let inOcxSection = false; + for (const line of lines) { + if ( + line.includes(OCX_SECTION_MARKER) || + isOcxProviderHeaderLine(line.trim()) + ) { + inOcxSection = true; + continue; + } + if (inOcxSection) { + // End the injected section at the next table header that ISN'T our own. Exact match on the + // provider name (plus our own sub-tables) so a user's + // "[model_providers.opencodex_backup]" (or similar) is preserved, not swallowed. + if (/^\s*\[/.test(line) && !isOcxProviderHeaderLine(line.trim())) { + inOcxSection = false; + filtered.push(line); + } + continue; + } + filtered.push(line); + } + return ( + filtered + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trimEnd() + "\n" + ); +} + +interface StripOpencodexConfigResult { + content: string; + managedDefaultsError: string | null; +} + +/** + * Detailed form used by the on-disk restore path. A damaged ownership marker is + * ambiguous: keep the associated value, but return the transform error so the + * caller cannot report a complete restore. + */ +function stripOpencodexConfigResult( + content: string, + journaledBaseUrl: string | null = null, + journaledRealtimeWsBaseUrl: string | null = null, +): StripOpencodexConfigResult { + let out = content; + const hadRootOcxProvider = + readRootTomlString(out, "model_provider") === "opencodex"; + // #1798: marker adjacency is FORMATTING evidence, and a Codex app rewrite keeps values + // while dropping comments. Fall back to VALUE evidence -- the exact URL we recorded + // writing -- so an app-rewritten config is still recognized as ours. + const hadInjectedBaseUrl = hasInjectedOpenaiBaseUrl(out) + || (journaledBaseUrl !== null && rootTomlString(out, "openai_base_url") === journaledBaseUrl); + out = stripInjectedOpenaiBaseUrl(out); // before removeOcxSection — it keys on the marker line too + out = stripJournaledOpenaiBaseUrl(out, journaledBaseUrl, journaledRealtimeWsBaseUrl); + if (hasOcxProviderTable(out)) { + out = removeOcxSection(out); + } + out = removeProfileSection(out); + // Regex (not exact-string) removal so compact `model_provider="opencodex"` is stripped too — + // must match the detection regex above, or a detected line could survive un-removed. + out = out + .split("\n") + .filter((l) => !/^\s*model_provider\s*=\s*"opencodex"\s*$/.test(l)) + .join("\n"); + // Routed root model ids (`model = "provider/slug"`) only make sense while the proxy serves + // them — strip on both the legacy re-tag form and the Design B injected-base-url form. + if (hadRootOcxProvider || hadInjectedBaseUrl) out = stripRootRoutedModel(out); + const managedDefaults = transformManagedSubagentDefaults(out, null); + if (managedDefaults.ok) out = managedDefaults.content; + out = stripOpencodexCatalogPath(out); + return { + content: out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n", + managedDefaultsError: !managedDefaults.ok ? managedDefaults.error : null, + }; +} + +/** Pure transform: strip the opencodex provider block + `model_provider = "opencodex"` lines. */ +export function stripOpencodexConfig(content: string): string { + return stripOpencodexConfigResult(content).content; +} + +function hasOpencodexRouting(content: string): boolean { + return ( + hasOcxProviderTable(content) || + /^\s*model_provider\s*=\s*"opencodex"/m.test(content) || + hasInjectedOpenaiBaseUrl(content) + ); +} + +export function removeCodexConfig( + options: { preserveProfile?: boolean } = {}, +): { success: boolean; message: string } { + const historyError = preflightCodexHistoryInjection(false, false); + if (historyError) return { success: false, message: `Codex configuration preserved: ${historyError}. Native writer coordination is required.` }; + if (!existsSync(CODEX_CONFIG_PATH)) { + if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) + unlinkSync(CODEX_PROFILE_PATH); + return { + success: true, + message: `Codex config not found; no native restore was needed${options.preserveProfile ? "." : ", and the opencodex profile was removed if present."}`, + }; + } + const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8"); + // Same EOL boundary as inject: strip in LF space, write back in the file's own ending. + // The unchanged fast path compares in LF space so an untouched file is never rewritten. + const eol = dominantEol(rawContent); + const content = applyEol(rawContent, "\n"); + // Read the recorded injection once: the strip below consumes it, and so does the + // ownership verdict, which must agree with what was actually removed. + const journaledBaseUrl = journaledInjectedOpenaiBaseUrl(); + const journaledRealtimeWsBaseUrl = journaledInjectedRealtimeWsBaseUrl(); + const had = hasOpencodexRouting(content) + || (journaledBaseUrl !== null && rootTomlString(content, "openai_base_url") === journaledBaseUrl) + || (journaledRealtimeWsBaseUrl !== null + && rootTomlString(content, REALTIME_WS_BASE_URL_KEY) === journaledRealtimeWsBaseUrl); + const stripped = stripOpencodexConfigResult(content, journaledBaseUrl, journaledRealtimeWsBaseUrl); + if (had || stripped.content !== content) { + atomicWriteFile(CODEX_CONFIG_PATH, applyEol(stripped.content, eol)); + } + if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) + unlinkSync(CODEX_PROFILE_PATH); + const removedMessage = had + ? `Removed opencodex routing from Codex config${options.preserveProfile ? "." : " + profile."}` + : "opencodex not present in Codex config."; + if (stripped.managedDefaultsError) { + const routingMessage = had + ? removedMessage + : "No opencodex routing was present in Codex config."; + return { + success: false, + message: + `${routingMessage} Native Codex sub-agent defaults could not be safely removed: ${stripped.managedDefaultsError}. ` + + "The ambiguous marker and adjacent value were preserved; inspect $CODEX_HOME/config.toml before using native Codex.", + }; + } + return { + success: true, + message: removedMessage, + }; +} diff --git a/src/codex/inject/restore.ts b/src/codex/inject/restore.ts new file mode 100644 index 0000000000..15282ea771 --- /dev/null +++ b/src/codex/inject/restore.ts @@ -0,0 +1,540 @@ +import { loadConfig } from "../../config"; +import { shouldSyncCodexOnStart } from "../desired-state"; +import { withCatalogWriteSerialization } from "../catalog-write-serialization"; +import { restoreCodexCatalogWithPermit } from "../catalog/sync"; +import { withCodexWriteLock, CodexWriteLockSkipped } from "../codex-write-lock"; +import { inspectNativeCodexOwnership } from "../../integrations/native/ownership-preflight"; +import { resolveCodexHistoryTransition } from "../history-transition"; +import { + captureCodexPreImages, + codexWriteCoordinationEligibility, + CodexPartialWriteError, + CodexWriteConflictError, + DEFAULT_INJECT_LOCK_TIMEOUT_MS, + recordCodexNativeTransactionProvenance, + restoreCodexPreImages, +} from "../inject-coordination"; +import { readIntegrationRecord } from "../integration-record"; +import { classifyNativeRoutedResidue } from "../native-residue"; +import { + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "../user-identity"; +import { + journaledInjectedCatalogPath, + removeJournal, + restoreJournalState, +} from "../journal"; +import { + preflightCodexHistoryInjection, + syncCodexHistoryProvider, + type CodexHistoryFailureReason, +} from "../history-provider"; +import { + describeHistoryJobFailure, + deriveCodexHistoryOperation, + resolveCodexHistoryJobTarget, + runCodexHistoryJob, + type CodexHistoryJobOutcome, +} from "../history-job"; +import { + DEFAULT_CATALOG_PATH, + getCodexHome, + tomlString, +} from "../paths"; +import { shouldInjectApiAuthHeader } from "../loopback-target"; +import { currentExternalCodexModelProvider } from "./config-toml"; +import { removeCodexConfig } from "./remove"; + +class CodexRestoreRefusal extends Error { + constructor(readonly config: CodexRestoreConfigResult) { + super(config.message); + } +} + +let beforeRestoreConfigForTests: ((kind: string) => void) | undefined; +export function setBeforeRestoreConfigForTests(hook: typeof beforeRestoreConfigForTests): void { + beforeRestoreConfigForTests = hook; +} + +export type CodexRestoreArtifactState = "ok" | "skipped" | "failed"; + +export interface CodexRestoreConfigResult { + state: CodexRestoreArtifactState; + changed: boolean; + action: "journal-restored" | "owned-fields-stripped" | "external-provider-preserved" | "failed"; + message: string; +} + +export interface CodexRestoreCatalogResult { + state: CodexRestoreArtifactState; + changed: boolean; + removed: number; + kept: number; + path: string | null; + message: string; +} + +export interface CodexRestoreHistoryResult { + state: CodexRestoreArtifactState; + changed: boolean; + reason?: CodexHistoryFailureReason; + rows: number; + files: number; + ejectedRows: number; + message: string; +} + +export interface CodexNativeRestoreResult { + success: boolean; + message: string; + externalProvider?: string; + artifacts: { + config: CodexRestoreConfigResult; + catalog: CodexRestoreCatalogResult; + history: CodexRestoreHistoryResult; + }; +} + +function failedHistoryRestore( + reason?: CodexHistoryFailureReason, + detail?: string, + progress: { rows?: number; files?: number } = {}, +): CodexRestoreHistoryResult { + const rows = progress.rows ?? 0; + const files = progress.files ?? 0; + const changed = rows > 0 || files > 0; + return { + state: "failed", + changed, + ...(reason ? { reason } : {}), + rows, + files, + ejectedRows: 0, + message: reason === "permission" + ? changed + ? "Codex resume history changed but did NOT converge because permission was denied while finalizing the backup manifest; the manifest was retained for review and safe retry." + : "Codex resume history could NOT be restored because permission was denied." + : reason === "busy" + ? changed + ? "Codex resume history changed but did NOT converge because backup-manifest finalization remained busy; the manifest was retained for review and safe retry." + : detail ?? "Codex resume history could NOT be restored — the Codex app appears to be holding the history database." + : reason === "integrity" + ? changed + ? "Codex resume history changed but did NOT converge because the backup or target changed; the manifest was retained for review and safe retry." + : "Codex resume history could NOT be restored because the backup or restore target failed integrity checks; unverified provider metadata was left unchanged." + : detail + ? `Codex resume history could NOT be restored: ${detail}` + : "Codex resume history could NOT be restored; the reason was not recorded. Run 'ocx doctor'.", + }; +} + +/** + * Restore failure wording for a Worker outcome. + * + * Only a genuine busy result blames the Codex app. An unsafe-path refusal, an + * unavailable coordinator database, a permission denial, or a dead/timed-out + * worker is a different problem; the old collapse made every one of those read + * as "the Codex app is holding the database" (issue #1191). `busy` and + * `permission` keep the restore-specific sentence built by + * `failedHistoryRestore`; every other reason reuses the single formatter so + * the two modules cannot drift apart. + */ +export function failedHistoryRestoreFromOutcome( + outcome: Extract, +): CodexRestoreHistoryResult { + if (outcome.kind === "blocked" && outcome.reason === "busy") return failedHistoryRestore("busy"); + if (outcome.kind === "failed" && outcome.historyFailureReason === "busy") { + return failedHistoryRestore( + "busy", + describeHistoryJobFailure(outcome, "restore"), + { rows: outcome.rows, files: outcome.files }, + ); + } + if (outcome.kind === "failed" && outcome.historyFailureReason === "permission") { + return failedHistoryRestore("permission", undefined, { rows: outcome.rows, files: outcome.files }); + } + if (outcome.kind === "failed" && outcome.historyFailureReason === "integrity") { + return failedHistoryRestore("integrity", undefined, { rows: outcome.rows, files: outcome.files }); + } + return failedHistoryRestore(undefined, describeHistoryJobFailure(outcome, "restore")); +} + +function externalProviderRestoreResult(activeProvider: string): CodexNativeRestoreResult { + const message = `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.`; + return { + success: true, + message, + externalProvider: activeProvider, + artifacts: { + config: { state: "skipped", changed: false, action: "external-provider-preserved", message }, + catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message }, + history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message }, + }, + }; +} + +/** A foreign service claim is an authority boundary, including explicit CLI restore. */ +function foreignOwnershipRestoreRefusal(message: string): CodexNativeRestoreResult { + return { + success: false, + message: `Codex native restore refused: ${message}`, + artifacts: { + config: { state: "skipped", changed: false, action: "failed", message }, + catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message }, + history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message }, + }, + }; +} + +function desiredEnabledRestoreSkip(): CodexNativeRestoreResult { + const message = "Codex integration was re-enabled; native restore was skipped."; + return skippedRestoreEnvelope(true, message); +} + +/** + * A schema-complete all-skipped envelope for outcomes decided before any + * restore machinery runs. Every `restore --json` path must stay shape-stable + * with `CodexNativeRestoreResult`; consumers never special-case early exits. + */ +export function skippedRestoreEnvelope(success: boolean, message: string): CodexNativeRestoreResult { + return { + success, + message, + artifacts: { + config: { state: "skipped", changed: false, action: "owned-fields-stripped", message }, + catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message }, + history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message }, + }, + }; +} + +/** Config was attempted and failed; downstream artifacts were never attempted. */ +function failedConfigRestoreEnvelope(config: CodexRestoreConfigResult): CodexNativeRestoreResult { + const result = skippedRestoreEnvelope(false, config.message); + result.artifacts.config = config; + return result; +} + +/** The config/profile half of a native restore, reported as one artifact. */ +function restoreCodexConfigInline(kind = "sync"): CodexRestoreConfigResult { + const preImages = captureCodexPreImages(); + const result = restoreCodexConfigInlineImpl(kind); + if (result.state === "failed") { + const compensated = restoreCodexPreImages(preImages); + if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored); + } + return result; +} + +function restoreCodexConfigInlineImpl(kind: string): CodexRestoreConfigResult { + try { + beforeRestoreConfigForTests?.(kind); + const historyError = preflightCodexHistoryInjection(false, false); + if (historyError) return { state: "failed", changed: false, action: "failed", message: `Codex configuration and journal preserved: ${historyError}.` }; + const journal = restoreJournalState(); + if (journal.unverified) { + return { + state: "failed", changed: false, action: "failed", + message: "Codex journal recovery was not verified; current configuration files and the journal were preserved.", + }; + } + const restored = journal.configRestored + ? { success: true, message: "Codex config restored from opencodex journal." } + : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged }); + if (restored.success) { + // A successful journal/fallback write can race native history migration too. + // Refuse here while preimage compensation and the remove transaction can roll back. + const finalHistoryError = preflightCodexHistoryInjection(false, false); + if (finalHistoryError) return { state: "failed", changed: false, action: "failed", message: `Codex configuration and journal preserved: ${finalHistoryError}.` }; + } + return restored.success + ? { + state: "ok", + changed: journal.configRestored || journal.profileRestored || journal.profileChanged || restored.message.startsWith("Removed"), + action: journal.configRestored ? "journal-restored" : "owned-fields-stripped", + message: restored.message, + } + : { state: "failed", changed: false, action: "failed", message: restored.message }; + } catch (error) { + return { state: "failed", changed: false, action: "failed", message: error instanceof Error ? error.message : String(error) }; + } +} + +/** The catalog half, always inside its own K acquisition. */ +/** + * The catalog half, always inside its own K acquisition. + * + * `journaledCatalogPath` must be captured by the CALLER, before the config half runs: a + * successful journal restore deletes the journal, and a config restore can remove + * `model_catalog_json`. Reading it here would be too late in both cases (#1798). + */ +function restoreCodexCatalogArtifact( + revalidateDesiredState: boolean, + journaledCatalogPath: string | null, +): CodexRestoreCatalogResult { + const owningCodexHome = getCodexHome(); + try { + const restored = withCatalogWriteSerialization(owningCodexHome, permit => + revalidateDesiredState && shouldSyncCodexOnStart(loadConfig()) + ? null + : restoreCodexCatalogWithPermit(permit, owningCodexHome, journaledCatalogPath)); + return restored.kind === "completed" && restored.value !== null + ? { state: "ok", changed: restored.value.removed > 0, ...restored.value, message: "Codex catalog restored." } + : restored.kind === "completed" + ? { + state: "skipped", changed: false, removed: 0, kept: 0, path: null, + message: "Codex integration was re-enabled; native catalog restoration was skipped.", + } + : { + state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH, + message: `Codex catalog could not be restored: ${restored.reason}.`, + }; + } catch (error) { + return { + state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH, + message: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Restore native Codex, running history in a Worker under H. + * + * On a coordinated home the config/profile restore happens INSIDE the Codex + * write lock, publishing a `remove` transition — the same serialization inject + * uses. Without it, an older restore could overwrite a config a concurrent + * enable had just written under the lock, and then honestly report success + * while desired intent said ON. The desired-state re-read under the lock turns + * that lost race into the discriminated `desired_enabled` skip. + */ +export async function restoreNativeCodexAsync( + options: { revalidateDesiredState?: boolean } = {}, +): Promise { + try { + return await restoreNativeCodexAsyncImpl(options); + } catch (error) { + if (!(error instanceof CodexRestoreRefusal)) throw error; + return failedConfigRestoreEnvelope(error.config); + } +} + +async function restoreNativeCodexAsyncImpl( + options: { revalidateDesiredState?: boolean }, +): Promise { + const activeProvider = currentExternalCodexModelProvider(); + if (activeProvider) { + // External-provider courtesy: only the stale journal is removed. The + // history worker must not launch — it would turn a read-mostly courtesy + // result into a history mutation on a home we do not own. + removeJournal(); + return externalProviderRestoreResult(activeProvider); + } + + // `restore` normally honours a human request even when an unrelated + // service-manager probe is unavailable. A recorded FOREIGN home is not an + // unrelated probe: it is positive evidence another installation owns these + // native artifacts, so do not create profile/claim locks before refusing. + if (options.revalidateDesiredState) { + const ownership = inspectNativeCodexOwnership(); + if (ownership.ownership === "foreign") return foreignOwnershipRestoreRefusal(ownership.reason); + if (shouldSyncCodexOnStart(loadConfig())) return desiredEnabledRestoreSkip(); + } + + const historyError = preflightCodexHistoryInjection(false, false); + if (historyError) return skippedRestoreEnvelope(false, `Native restore refused: ${historyError}. Config, catalog, history and provenance were preserved.`); + + const eligibility = codexWriteCoordinationEligibility({ + coordinatorPath: () => + resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), getCodexHome()), + residue: () => classifyNativeRoutedResidue(), + integrationRecord: () => readIntegrationRecord(), + }); + + // Captured before the config half: a successful journal restore DELETES the journal, and + // restoring the config can drop `model_catalog_json`. Either one would hide the routed + // catalog we actually wrote (#1798). + const journaledCatalogPath = journaledInjectedCatalogPath(); + let config: CodexRestoreConfigResult; + let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined; + + if (eligibility.kind === "coordinated" || eligibility.kind === "adopt") { + // The restore has no candidate bytes to witness; freshness comes from the + // filesystem reads and the desired-state re-read performed under the lock. + const witness = { authoritySnapshotId: "codex-native-restore" }; + const coordinated = await withCodexWriteLock( + { + timeoutMs: DEFAULT_INJECT_LOCK_TIMEOUT_MS, + ...(eligibility.kind === "adopt" ? { adoption: { direction: "remove" as const } } : {}), + admitted: witness, + readAdmissionUnderLock: () => witness, + }, + (ctx) => { + if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) { + throw new CodexWriteLockSkipped("desired_enabled"); + } + const published = ctx.coordinator.beginTransition( + { + nativeGeneration: ctx.expectation.nativeBefore, + currentTxId: ctx.currentTxId, + }, + { + txId: ctx.expectation.txId, + direction: "remove", + authoritySnapshotId: ctx.admission.authoritySnapshotId, + nextRetryAt: new Date().toISOString(), + }, + ); + if (published.kind !== "updated") { + throw new CodexWriteConflictError( + `The Codex transition could not be published: ${published.kind}.`, + ); + } + const preImages = captureCodexPreImages(); + let restored: CodexRestoreConfigResult; + try { + restored = restoreCodexConfigInline(eligibility.kind); + // Throw inside N so the published remove transition rolls back too. + if (restored.state === "failed") throw new CodexRestoreRefusal(restored); + } catch (error) { + const compensated = restoreCodexPreImages(preImages); + if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored); + throw error; + } + return { + config: restored, + preImages, + receipt: { + nativeGeneration: ctx.expectation.nativeAfter, + currentTxId: ctx.expectation.txId, + }, + }; + }, + ); + if (coordinated.status === "skipped") return desiredEnabledRestoreSkip(); + if (coordinated.status !== "acquired") { + config = { + state: "failed", + changed: false, + action: "failed", + message: coordinated.status === "busy" + ? `Another process is writing Codex configuration right now (waited ${coordinated.waitedMs}ms). Retry shortly.` + : `Codex configuration was not restored: ${coordinated.message}`, + }; + } else { + recordCodexNativeTransactionProvenance( + coordinated.value.preImages, + coordinated.value.receipt.currentTxId, + ); + config = coordinated.value.config; + transitionReceipt = coordinated.value.receipt; + } + } else { + // Legacy-uncoordinated (or unresolvable) homes keep the unserialized path + // they have always had; restore is the escape hatch and must not strand + // them. The plain re-read still honors an intervening re-enable. + if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) { + return desiredEnabledRestoreSkip(); + } + config = restoreCodexConfigInline(eligibility.kind); + } + + if (config.state === "failed") return failedConfigRestoreEnvelope(config); + const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath); + const outcome = await runCodexHistoryJob({ + ...resolveCodexHistoryJobTarget(), + ...(options.revalidateDesiredState ? { expectedDesiredEnabled: false } : {}), + operation: deriveCodexHistoryOperation({ direction: "restore", resumeHistory: true, legacyMode: false }), + }); + if (transitionReceipt) { + resolveCodexHistoryTransition(transitionReceipt, outcome); + } + const history: CodexRestoreHistoryResult = outcome.kind === "converged" + ? { + state: "ok", changed: outcome.rows > 0 || outcome.files > 0, rows: outcome.rows, files: outcome.files, ejectedRows: 0, + message: outcome.rows > 0 + ? `Resume history metadata restored from opencodex backup (${outcome.rows} thread(s)); original providers preserved.` + : "No backed-up resume-history metadata was pending; untracked routed history was left unchanged.", + } + : outcome.kind === "skipped" + ? { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message: "Codex resume history was skipped." } + : outcome.kind === "blocked" && (outcome.reason === "desired_disabled" || outcome.reason === "desired_enabled") + ? { + state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, + message: outcome.reason === "desired_disabled" + ? "Codex integration was disabled; history restoration was skipped." + : "Codex integration was enabled; history restoration was skipped.", + } + : outcome.kind === "blocked" || outcome.kind === "failed" + ? failedHistoryRestoreFromOutcome(outcome) + : failedHistoryRestore(); + const base = catalog.removed > 0 + ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).` + : config.message; + const success = catalog.state !== "failed" + && history.state !== "failed"; + return { + success, + message: `${base}${history.state === "failed" ? ` ⚠️ ${history.message}` : ""}`, + artifacts: { config, catalog, history }, + }; +} + +export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateDesiredState?: boolean } = {}): CodexNativeRestoreResult { + const activeProvider = currentExternalCodexModelProvider(); + if (activeProvider) { + removeJournal(); + return externalProviderRestoreResult(activeProvider); + } + if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) { + return desiredEnabledRestoreSkip(); + } + const historyError = preflightCodexHistoryInjection(false, false); + if (historyError) return skippedRestoreEnvelope(false, `Native restore refused: ${historyError}. Config, catalog, history and provenance were preserved.`); + // Captured before the config half: a successful journal restore DELETES the journal, and + // restoring the config can drop `model_catalog_json`. Either one would hide the routed + // catalog we actually wrote (#1798). + const journaledCatalogPath = journaledInjectedCatalogPath(); + const config = restoreCodexConfigInline(); + if (config.state === "failed") return failedConfigRestoreEnvelope(config); + const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath); + // Design B (loopback) steady state: threads are already tagged openai, so prove the + // no-op with a readonly probe instead of write-opening a DB the Codex app may hold + // (Windows: WAL writer lock -> seconds of stalling + a false warning on every stop). + // Legacy (non-loopback) installs keep the unconditional write-open restore. + let skipWhenProvablyNoop = false; + try { + skipWhenProvablyNoop = !shouldInjectApiAuthHeader(loadConfig()); + } catch { + /* unreadable config: keep the conservative write-open restore */ + } + // `skipHistory` is how the async wrapper takes this work for itself: the + // native files come down here, and history runs in the Worker under H. + const rawHistory = options.skipHistory + ? { rows: 0, files: 0 } + : syncCodexHistoryProvider("openai", undefined, undefined, { + skipWhenProvablyNoop, + }); + const history: CodexRestoreHistoryResult = options.skipHistory + ? { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message: "History restoration runs asynchronously." } + : rawHistory.failed + ? failedHistoryRestore(rawHistory.failureReason, undefined, rawHistory) + : { + state: "ok", + changed: rawHistory.rows > 0 || rawHistory.files > 0 || (rawHistory.ejectedRows ?? 0) > 0, + rows: rawHistory.rows, + files: rawHistory.files, + ejectedRows: rawHistory.ejectedRows ?? 0, + message: rawHistory.rows > 0 + ? `Resume history metadata restored from opencodex backup (${rawHistory.rows} thread(s)); original providers preserved.` + : "No backed-up resume-history metadata was pending; untracked routed history was left unchanged.", + }; + const message = catalog.removed > 0 + ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).` + : config.message; + return { + success: catalog.state !== "failed" && history.state !== "failed", + message, + artifacts: { config, catalog, history }, + }; +} diff --git a/src/codex/inject/routing-classify.ts b/src/codex/inject/routing-classify.ts new file mode 100644 index 0000000000..abb7a92d52 --- /dev/null +++ b/src/codex/inject/routing-classify.ts @@ -0,0 +1,109 @@ +import { existsSync, readFileSync } from "node:fs"; +import { + hasInjectedCodexRouting, + hasInjectedOpenaiBaseUrl, + providerTableStart, + providerTableString, + rootTomlString, +} from "../injected-marker"; +import { CODEX_CONFIG_PATH } from "../paths"; + +export type CodexRoutingKind = + "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown"; + +type RoutingEndpointKind = "local" | "remote" | "unknown"; + +function ipv4Octets(hostname: string): number[] | null { + const dotted = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname); + if (dotted) { + const octets = dotted.slice(1).map(Number); + return octets.some((octet) => octet > 255) ? null : octets; + } + const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(hostname); + if (!mapped) return null; + const high = Number.parseInt(mapped[1], 16); + const low = Number.parseInt(mapped[2], 16); + return [high >>> 8, high & 0xff, low >>> 8, low & 0xff]; +} + +function classifyRoutingEndpoint(value: string): RoutingEndpointKind { + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return "unknown"; + const hostname = url.hostname + .toLowerCase() + .replace(/^\[|\]$/g, "") + .replace(/\.$/, ""); + if (!hostname) return "unknown"; + if (hostname === "localhost" || hostname.endsWith(".localhost")) + return "local"; + if (hostname === "::" || hostname === "::1" || hostname === "0.0.0.0") + return "local"; + const octets = ipv4Octets(hostname); + if (octets) { + if (octets.every((octet) => octet === 0)) return "local"; + if (octets[0] === 127) return "local"; + return "remote"; + } + if (/^::ffff:/i.test(hostname)) return "unknown"; + return "remote"; + } catch { + return "unknown"; + } +} + +/** Classify actual routing dependency separately from opencodex ownership. */ +export function classifyCodexRouting(content: string): CodexRoutingKind { + const rootBaseUrl = rootTomlString(content, "openai_base_url"); + if (rootBaseUrl) { + const endpoint = classifyRoutingEndpoint(rootBaseUrl); + if (endpoint === "unknown") return "unknown"; + if (hasInjectedOpenaiBaseUrl(content)) return "opencodex-local"; + return endpoint === "local" ? "custom-local" : "custom-remote"; + } + const rootProvider = rootTomlString(content, "model_provider"); + if (rootProvider) { + const providerTableExists = + providerTableStart(content.split("\n"), rootProvider) !== -1; + const providerBaseUrl = providerTableString( + content, + rootProvider, + "base_url", + ); + if (providerBaseUrl) { + const endpoint = classifyRoutingEndpoint(providerBaseUrl); + if (endpoint === "unknown") return "unknown"; + if (rootProvider === "opencodex") return "opencodex-local"; + return endpoint === "local" ? "custom-local" : "custom-remote"; + } + if ( + rootProvider === "opencodex" || + providerTableExists || + rootProvider !== "openai" + ) + return "unknown"; + } + return "native"; +} + +/** Read-only probe used by status, doctor, and the dashboard. */ +export function isCodexRoutingInjected(): boolean { + const path = CODEX_CONFIG_PATH; + if (!existsSync(path)) return false; + try { + return hasInjectedCodexRouting(readFileSync(path, "utf8")); + } catch { + return false; + } +} + +export function getCodexRoutingKind(): CodexRoutingKind { + const path = CODEX_CONFIG_PATH; + if (!existsSync(path)) return "native"; + try { + return classifyCodexRouting(readFileSync(path, "utf8")); + } catch { + return "unknown"; + } +} + diff --git a/src/codex/inject/routing-target.ts b/src/codex/inject/routing-target.ts new file mode 100644 index 0000000000..67a4bf6322 --- /dev/null +++ b/src/codex/inject/routing-target.ts @@ -0,0 +1,125 @@ +import { subagentDefaultSyncEffective } from "../../config"; +import { + effectiveLoopbackListenerPort, + isLoopbackHostname, + shouldInjectApiAuthHeader, +} from "../loopback-target"; +import type { ManagedSubagentDefaults } from "../subagent-defaults"; +import type { OcxConfig } from "../../types"; + +export interface CodexRoutingTarget { + baseUrl: string; + requiresAdmissionToken: boolean; + tokenEnv: "OPENCODEX_API_AUTH_TOKEN"; + /** + * Opt-in authless Codex Desktop mode (#1107): inject the dedicated provider table with + * `requires_openai_auth = false` so Desktop skips the ChatGPT login gate. Only ever true for + * loopback targets that need no admission token; non-loopback admission is a separate layer + * and is never weakened by this flag. + */ + desktopAuthless?: boolean; + /** Select the dedicated provider identity so Codex owns compaction locally. */ + clientCompaction?: boolean; +} + +export function validateCodexRoutingTarget(target: CodexRoutingTarget): CodexRoutingTarget { + let parsed: URL; + try { + parsed = new URL(target.baseUrl); + } catch { + throw new TypeError("Codex routing target must be an absolute HTTP(S) /v1 URL"); + } + if ( + (parsed.protocol !== "http:" && parsed.protocol !== "https:") + || parsed.username + || parsed.password + || parsed.pathname !== "/v1" + || parsed.search + || parsed.hash + || target.tokenEnv !== "OPENCODEX_API_AUTH_TOKEN" + ) { + throw new TypeError("Codex routing target must be a canonical HTTP(S) /v1 URL without credentials, query, or fragment"); + } + return { ...target, baseUrl: `${parsed.origin}/v1` }; +} + +/** Provider-table form is used when auth, admission, or compaction policy needs a dedicated provider. */ +export function usesProviderTable(target: CodexRoutingTarget): boolean { + return target.requiresAdmissionToken + || target.desktopAuthless === true + || target.clientCompaction === true; +} + +export function standaloneCodexRoutingTarget( + port: number, + config?: Pick< + OcxConfig, + "hostname" | "unauthenticatedLoopbackListener" | "codexDesktopAuthless" | "codexClientCompaction" + >, +): CodexRoutingTarget { + // An enabled listener with no `port` is the companion form: it answers on `port` itself, + // bound to 127.0.0.1 (#4236). Resolving it through the shared helper is what makes the + // one-port hub work without every writer repeating `?? port`. + const loopback = config?.unauthenticatedLoopbackListener; + const effectivePort = effectiveLoopbackListenerPort(config, port) ?? port; + const hostname = loopback?.enabled ? undefined : config?.hostname; + const requiresAdmissionToken = loopback?.enabled ? false : shouldInjectApiAuthHeader(config); + return { + baseUrl: `http://${providerBaseHost(hostname)}:${effectivePort}/v1`, + requiresAdmissionToken, + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + ...(config?.codexDesktopAuthless === true && !requiresAdmissionToken + ? { desktopAuthless: true } + : {}), + ...(config?.codexClientCompaction === true && !requiresAdmissionToken + ? { clientCompaction: true } + : {}), + }; +} + +export function routingTargetOrigin(target: CodexRoutingTarget): string { + return target.baseUrl.slice(0, -3); +} + +export function configuredManagedSubagentDefaults( + config: + | Pick< + OcxConfig, + "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults" + > + | undefined, +): ManagedSubagentDefaults | null { + if (!subagentDefaultSyncEffective(config ?? {})) return null; + return { + model: config!.injectionModel!.trim(), + ...(config!.injectionEffort?.trim() + ? { reasoningEffort: config!.injectionEffort.trim() } + : {}), + }; +} + +/** + * The `[model_providers.opencodex]` TABLE only. A table is position-independent in TOML, so it is + * safe to append at EOF. The bare root key `model_provider = "opencodex"` is NOT included here — + * it must live at the document root (before any table header) and is set separately by + * setRootModelProvider(). Appending the bare key at EOF was the original bug: it nested under + * whatever `[table]` happened to be open last (e.g. `[plugins."chrome@openai-bundled"]`), so Codex + * never saw a global model_provider and silently fell back to the `openai` (ChatGPT) provider. + */ +export function providerBaseHost(hostname: string | undefined): string { + const trimmed = (hostname ?? "127.0.0.1").trim(); + const lower = trimmed.toLowerCase(); + // Match what the server actually binds. Writing "localhost" while binding IPv4-only + // 127.0.0.1 breaks on Windows, where localhost commonly resolves to ::1 first. + if (lower === "::1" || lower === "[::1]") return "[::1]"; + if ( + isLoopbackHostname(trimmed) || + trimmed === "0.0.0.0" || + trimmed === "::" || + trimmed === "[::]" + ) + return "127.0.0.1"; + if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed; + return trimmed.includes(":") ? `[${trimmed}]` : trimmed; +} + diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 51779de4c1..71901089c3 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -1,409 +1,191 @@ -import { randomUUID } from "node:crypto"; import { saveConfigPreservingClaudeCode } from "../config"; -import { isCodexAccountGenerationLive, readCodexAccountRecord, type CodexRefreshProvenance } from "./account-store"; +import { isCodexAccountGenerationLive } from "./account-store"; import { codexAccountLogLabel } from "./account-label"; -import { NATIVE_RESERVE_MODEL } from "./catalog/native-models"; import { isCodexAccountPaused } from "./account-pause"; -import { clearCodexAccountPin, codexAccountPriorityLookup, pinnedCodexAccountId } from "./account-priority"; +import { clearCodexAccountPin, pinnedCodexAccountId } from "./account-priority"; import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "./account-usability"; -import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; -import { - POOL_KEY_CODEX, - normalizeAccountPoolStickyLimit, - normalizeCodexAccountPoolStrategy, - notePoolRotationFailure, - notePoolRotationSuccess, - peekRoundRobinAccount, - pickRoundRobinAccount, - seedPoolRotationAccount, - selectPriorityTier, -} from "./pool-rotation"; -import { - CODEX_EXHAUSTED_USAGE_PERCENT, - CODEX_UNKNOWN_USAGE_SCORE, - getAccountQuota, - isRetiredCodexSparkModel, - resetAtToMs, -} from "./quota"; -import { codexPlanKey, isThirtyDayOnlyCodexPlan } from "./plan"; -import { - MAIN_CODEX_ACCOUNT_ID, - getMainAccountPlan, - hasMainAccountRefreshGrant, -} from "./main-account"; +import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; +import { POOL_KEY_CODEX, notePoolRotationFailure } from "./pool-rotation"; +import { getAccountQuota, isRetiredCodexSparkModel } from "./quota"; +import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; import { isSelectableCodexPoolAccount } from "./account-id"; import type { OcxConfig } from "../types"; import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper"; -import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; -import { retainedUtf8Bytes } from "../lib/admission"; import { recordUpstreamHostFailure } from "./upstream-host-health"; - -type ThreadAffinityEntry = { - accountId: string; - generation: number; - createdAt: number; - lastUsedAt: number; - // Last time the bound account's quota threshold was re-evaluated for this - // thread (interval-gated to avoid per-request flapping). See REEVAL_INTERVAL_MS. - lastReevalAt: number; - // When a transient failure streak first forced this thread onto another account - // while the binding was HELD (#4546). Cleared the moment the bound account serves - // again; once it ages past CODEX_TRANSIENT_AFFINITY_HOLD_MS the binding is - // released through the ordinary path instead of detouring forever. - transientHoldSince?: number; - // Which account is serving this thread while its own is held under a transient hold. - // Remembered rather than re-picked per request: under round-robin a fresh pick each turn - // would walk the ring and start cold on every hop, which is the behaviour the hold exists - // to prevent. Cleared with transientHoldSince when the bound account serves again. - transientDetourAccountId?: string; -}; - -export type CodexThreadResolution = - | { status: "selected"; accountId: string; affinity?: CodexAffinityDecision } - | { status: "none"; affinity?: CodexAffinityDecision } - | { status: "expired"; accountId: string; affinity?: CodexAffinityDecision }; - -/** What happened to this thread's binding on this request (#4546). */ -export type CodexAffinityMove = - /** Served by its own bound account, which was healthy. */ - | "reused" - /** Served by its own bound account while something transient was wrong with it. */ - | "held" - /** Served by another account while the binding stayed put. */ - | "detour" - /** The binding was released and a different account took the thread. */ - | "rebound" - /** There was no live binding; this request established one. */ - | "new_bind" - /** The binding was released without a replacement on this request. */ - | "cleared"; - -/** - * Why. A move is the expensive event -- it discards the prompt-cache prefix warmed on the old - * account -- so the operator should not have to infer it from account labels across log lines, - * which is how #4546 had to be diagnosed. - */ -export type CodexAffinityReason = - | "healthy" - | "quota_headroom" - | "quota_refusal" - | "transient" - | "transient_hold_expired" - | "unusable" - | "paused" - | "plan_excluded" - | "cooldown" - | "quota_avoided" - | "generation" - | "expired" - | "model_lane"; - -export interface CodexAffinityDecision { - move: CodexAffinityMove; - reason: CodexAffinityReason; -} - -/** The decision to report once a binding has been released and selection starts over. */ -function affinityAfterRelease( - threadId: string | null, - releaseReason: CodexAffinityReason | undefined, -): CodexAffinityDecision { - // Reported now, so it must not be reported again by the next request. - clearPendingReleaseReason(threadId); - return releaseReason === undefined - ? { move: "new_bind", reason: "healthy" } - : { move: "rebound", reason: releaseReason }; -} - -/** - * What to report when selection produced no account at all. The binding is gone and nothing took - * it, which is a `cleared`, and the pending reason is deliberately NOT consumed: a no-account - * result reaches no auth context and therefore no usage entry, so the next resolve that does - * produce one is the first place this release can actually be seen. - */ -function affinityOnNoAccount( - threadId: string | null, - releaseReason: CodexAffinityReason | undefined, -): CodexAffinityDecision | undefined { - if (releaseReason === undefined) return undefined; - // Hand it forward as well as reporting it. A reason derived from the entry this request just - // released lives only in a local, so without this the next resolve finds no entry and no - // pending reason and calls the rebind a fresh healthy bind. - notePendingReleaseReason(threadId, releaseReason); - return { move: "cleared", reason: releaseReason }; -} - -/** - * Process-local cursor for automatic RR/fill-first (and quota-429 when not - * sync-writing) picks. Keeps unrelated `saveConfig` from persisting transient - * rotation as the operator's `activeCodexAccountId`. Manual selection clears it - * so disk/`config.activeCodexAccountId` remains authoritative. - */ -let runtimeActiveCodexAccountId: string | undefined; - -type CodexUpstreamHealth = { - consecutiveFailures: number; - /** Consecutive healthy terminals observed while recovering from escalation level 2+. */ - consecutiveSuccesses?: number; - lastFailureStatus?: number; - lastFailureAt?: number; - /** Hard cooldown (quota 429). Survives a later 2xx; blocks auth + selection. */ - cooldownUntil?: number; - /** - * How long a quota refusal keeps selection away from this account (or this native quota - * group), as opposed to how long it is hard-blocked. - * - * The two are deliberately different lengths. {@link CODEX_MAX_RESET_DERIVED_COOLDOWN_MS} - * caps the hard cooldown at 15 minutes because a reset announcement is advisory and plan - * quota usually frees up before it — an account must stay reachable so the pool can find - * that out (#433). The window the refusal announced is not 15 minutes, though, so once the - * cooldown lapses the account is selectable again while its burst window is still spent, - * and the strategy picks it straight back: this proxy reads a weekly bar a burst limit never - * touches, so a refused account still scores as the coolest in the pool. Every request then - * earns the same 429 until the process restarts, which is the only thing that drops this map. - * - * So the announcement governs avoidance and the cap still governs blocking. Avoidance is soft - * in the {@link softAvoidUntil} sense: it reorders the pool and releases a bound thread, and - * the last-resort paths still reach the account when nothing else can serve, so one pessimistic - * announcement cannot stall routing. - */ - quotaAvoidUntil?: number; - /** When the current cooldown was recorded; origin of the probe interval clock. */ - cooldownSince?: number; - /** - * What produced the cooldown. An explicit Retry-After is a literal retry - * directive and is never probed; a quota resetAt only announces a window - * refresh, so it may be probed early (#433). - */ - cooldownSource?: CodexCooldownSource; - /** - * Bumped on every cooldown write. A probe lease records the generation it was - * issued for so a lease cannot clear a cooldown that a later 429 replaced. - */ - cooldownGeneration?: number; - /** - * Identity of the in-flight probe. A cooled-down account sends no traffic, so - * no organic 2xx can prove recovery; only the outcome carrying this id may - * clear the cooldown. - */ - probeLeaseId?: string; - /** Cooldown generation at the moment the lease was granted. */ - probeLeaseGeneration?: number; - /** Last probe grant or conclusion; paces the probe interval. */ - lastProbeAt?: number; - /** - * Soft avoid after connect_error / timeout / transient 5xx. Cleared on 2xx. - * Blocks pool selection + thread affinity reuse so a sticky session can leave a - * flaky account without throwing CodexAccountCooldownError (hard-only). - */ - softAvoidUntil?: number; - /** - * Credential generation a 401/403 quarantine was derived from (#2892 gap 4). - * - * Provenance lives ON the entry rather than in a side map keyed by account id. A side map spends - * "whatever health is current when the old credential is found dead", which deletes a later - * unrelated entry: a G1 401, then a G2 save, then a genuine G2 503 would lose the 503. Only the - * entry that carries this field can be spent, and any later write simply replaces it. - */ - credentialFailureGeneration?: number; -}; - -const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000; -const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000; -/** - * A weekly/monthly quota `resetAt` announces when the window refreshes; it is not - * a "come back after this" directive like Retry-After. Plan quota routinely frees - * up long before the advertised reset, so cap reset-derived cooldowns far below - * the Retry-After ceiling (#433). - */ -const CODEX_MAX_RESET_DERIVED_COOLDOWN_MS = 15 * 60_000; -/** - * Ceiling on quota-refusal avoidance. Generous enough to cover a full five-hour burst window, - * tight enough that a weekly or monthly reset four days out cannot take an account out of - * rotation for the {@link CODEX_MAX_QUOTA_COOLDOWN_MS} day the Retry-After ceiling allows. - */ -const CODEX_MAX_QUOTA_AVOID_MS = 6 * 60 * 60_000; -/** Minimum gap between probe leases for one cooled-down account. */ -export const CODEX_QUOTA_PROBE_INTERVAL_MS = 5 * 60_000; -export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000; -/** - * How recently a 100% burst reading must have been OBSERVED to exclude an account when it - * carries no reset timestamp (#3425). Deliberately far tighter than the 6h disk-hydration - * horizon in `quota.ts`: shorter than any plausible five-hour burst window, so a persisted - * reading can never strand a recovered account, and long enough that a snapshot taken at - * admission is still fresh when selection reads it. - */ -export const TERMINAL_SHORT_WINDOW_FRESHNESS_MS = 5 * 60_000; -/** How long a transient failure keeps the account out of pool selection. */ -export const CODEX_TRANSIENT_SOFT_AVOID_MS = 30_000; -const CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS = [ +import { + classifyCodexUpstreamOutcome, + computeCodexUsageScore, + computeQuotaCooldown, + quotaAvoidUntilFor, + CODEX_FAILURE_WINDOW_MS, + CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS, + type CodexUpstreamOutcome, + type CodexUpstreamOutcomeMeta, +} from "./routing/cooldown-math"; +import { + codexPoolKeyForScope, + codexQuotaScopeForModel, + deleteAccountHealth, + deleteAllScopedHealth, + deleteScopedHealth, + dropSpentCredentialFailure, + getAccountHealth, + getCodexAccountCooldownUntil, + getCodexAccountSoftAvoidUntil, + getCodexQuotaHealthSnapshot, + isCodexAccountSoftAvoided, + isCodexQuotaAvoided, + isHealthAccountAdmissible, + isHealthGenerationReconciled, + isIndependentCodexQuotaScope, + preservedCooldownFields, + pruneHealthAccountsForContext, + commitHealthReconcile, + clearUpstreamHealthState, + resetHealthReconcileState, + deleteAllHealthForAccount, + scopedHealthFor, + setAccountHealth, + setScopedHealth, + type CodexQuotaScope, + type CodexUpstreamHealth, +} from "./routing/health-store"; +import { ownsProbeLease, probeMayClearCooldown, withProbeLeaseReleased } from "./routing/probe-lease"; +import { + affinityAfterRelease, + affinityOnNoAccount, + bindModelDetourAffinity, + bindThreadAffinity, + clearThreadAccountMapForAccount, + deleteModelDetourAffinity, + deleteThreadAffinity, + deleteThreadAffinitiesForAccount, + getThreadAffinity, + getThreadAffinityScopes, + getModelDetourAffinity, + isThreadAffinityExpired, + isThreadAffinityGenerationLive, + peekPendingReleaseReason, + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS, + CODEX_TRANSIENT_AFFINITY_HOLD_MS, + type CodexAffinityReason, + type CodexThreadResolution, + type ThreadAffinityEntry, +} from "./routing/thread-affinity"; +import { + accountPoolStrategyForScope, + applyFailureFailover, + applyQuotaAutoSwitch, + codexAccountBlockReason, + getEligiblePoolAccounts, + getPoolAccountPlanForSelection, + hasCodexQuotaHeadroom, + isCodexAccountPlanExcluded, + isCacheAffinityEnabled, + isCodexAccountSelectable, + isHealthySharedCodexSelection, + isUnknownUsage, + pickAlternateCodexAccount, + pickLowerUsageAccount, + pickLowestUsageAmong, + pickLowestUsageCodexAccount, + pickPriorityPreemption, + pickResetFirstCodexAccount, + pickUnboundStrategyAccount, + sharedStateSelectionOptions, + strategySelectionOptionsForModelDetour, + shouldFailover, + peekAlternateCodexAccount, +} from "./routing/selection"; +import { + clearAllManualPreferences, + consumeManualPreference, + forgetManualPreference, + forgetRoutingPreferencesOutside, + forgetRuntimeActiveCodexAccount, + getEffectiveActiveCodexAccountId, + manualPreferenceBlocks, + promoteActiveCodexAccount, + rememberActiveCodexAccount, + setActiveCodexAccount, +} from "./routing/active-account"; + +export { + CODEX_QUOTA_PROBE_INTERVAL_MS, + CODEX_FAILURE_WINDOW_MS, + TERMINAL_SHORT_WINDOW_FRESHNESS_MS, CODEX_TRANSIENT_SOFT_AVOID_MS, - 2 * 60_000, - 10 * 60_000, - 30 * 60_000, -] as const; -export const CODEX_THREAD_AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000; -export const CODEX_THREAD_AFFINITY_MAX_ENTRIES = 2048; -const MAX_AFFINITY_COMPONENT_BYTES = 512; -// Min interval between quota threshold re-evaluations for a single bound thread. -// Well under the 5h/weekly quota windows, but enough to stop per-request flapping. -export const CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS = 60_000; - -/** - * How long a live binding outlives a TRANSIENT failure streak on its own account (#4546). - * - * Being unable to send right now is not the same as losing ownership of the conversation. - * A 5xx streak is frequently provider-wide rather than account-specific, and deleting the - * binding for it discards a prompt-cache prefix that the next turn then pays for again -- - * the same cost the quota threshold used to impose, arriving through a different door. - * So the request detours to another account while the binding is held here. - * - * Bounded, because an unbounded hold is its own defect: an account that never recovers - * would keep a thread detouring indefinitely while the conversation's real warm prefix - * accumulates somewhere else. Ten minutes is longer than the whole soft-avoid escalation - * ladder up to its final step, so an ordinary outage resolves inside the hold and a - * genuine one converts to a real rebind instead of a permanent detour. - */ -export const CODEX_TRANSIENT_AFFINITY_HOLD_MS = 10 * 60_000; - -const upstreamHealth = new Map(); -/** - * Reset-derived 429s can describe a quota owned by one native model family, - * rather than the whole ChatGPT account. Keep those advisory cooldowns apart - * from account-wide Retry-After/default throttles and transient health. - */ -const quotaScopedHealth = new Map>(); -/** - * Spend a credential-failure health entry whose credential no longer exists (#2892 gap 4). - * - * A 401/403 describes one CREDENTIAL, not an account, and a replacement can land at any point after - * the outcome is recorded — so re-reading the store inside `recordCodexUpstreamOutcome` narrows the - * window without closing it. The reader decides instead, and it may only spend an entry that - * actually carries credential provenance: a later transient or quota write replaces the entry and - * with it the tag, so this can never delete evidence that belongs to a different failure. - */ -function dropSpentCredentialFailure(accountId: string): void { - const health = upstreamHealth.get(accountId); - const generation = health?.credentialFailureGeneration; - if (health === undefined || generation === undefined) return; - if (isCodexAccountGenerationLive(accountId, generation)) return; - upstreamHealth.delete(accountId); -} -let lastReconciledGeneration = 0; -let liveHealthAccountIds = new Set(); - -export type CodexUpstreamOutcome = number | "connect_error" | "timeout" | "connect_neutral"; -export type CodexUpstreamOutcomeClass = "success" | "credential" - | "workspace" | "quota" | "transient" | "caller" | "neutral" | "unknown"; -export type CodexCooldownSource = "retry-after" | "reset-derived" | "default"; -/** - * Native Codex quota groups known to be independent upstream. Keep the mapping - * deliberately conservative: unlisted models share the normal native group. - * Add a new explicit group here only when its independent upstream quota is - * confirmed, so shared limits never receive cross-model bypasses. - */ -export type CodexQuotaScope = "shared" | "reserve"; - -export type CodexQuotaRecoveryProbeClaim = { - accountId: string; - scope?: CodexQuotaScope; - leaseId: string; - cooldownGeneration: number; - credentialGeneration: number; - /** Claim-time `replacedAt`; unchanged after a probe-owned refresh, stamped on external replacement. */ - credentialReplacedAt?: number; -}; - -export type CodexQuotaRecoveryProbeProof = { - credentialGeneration?: number; -}; - -/** - * Requests without a resolved native model retain the historic one-account-per- - * thread behavior. Requests with a known quota scope get an independent - * affinity so a Reserve failover cannot displace the same thread's Terra/Luna - * account (and vice versa). - */ -type BaseThreadAffinityScope = CodexQuotaScope | "legacy"; -type ModelDetourAffinityScope = `model-detour:${BaseThreadAffinityScope}:${string}`; -type ThreadAffinityScope = BaseThreadAffinityScope | ModelDetourAffinityScope; -const LEGACY_THREAD_AFFINITY_SCOPE = "legacy" as const; -const threadAccountMap = new Map>(); -let threadAffinityEntryTotal = 0; - -function isModelDetourAffinityScope(scope: ThreadAffinityScope): scope is ModelDetourAffinityScope { - return scope.startsWith("model-detour:"); -} - -const NATIVE_MODEL_QUOTA_SCOPES: Readonly> = { - [NATIVE_RESERVE_MODEL]: "reserve", -}; - -export function codexQuotaScopeForModel(modelId: string | undefined): CodexQuotaScope | undefined { - if (!modelId?.trim()) return undefined; - return NATIVE_MODEL_QUOTA_SCOPES[modelId.trim().toLowerCase()] ?? "shared"; -} - -/** Independent quota groups must not mutate the shared active-account cursor. */ -function isIndependentCodexQuotaScope(quotaScope?: CodexQuotaScope): boolean { - return quotaScope !== undefined && quotaScope !== "shared"; -} - -function codexPoolKeyForScope(quotaScope?: CodexQuotaScope): string { - return isIndependentCodexQuotaScope(quotaScope) ? `${POOL_KEY_CODEX}:${quotaScope}` : POOL_KEY_CODEX; -} - -export type CodexUpstreamOutcomeMeta = { - retryAfter?: string | null; - resetAt?: unknown | unknown[]; - now?: number; - /** (provider, host) ledger key for account-neutral reachability failures (#914). */ - hostKey?: string; - /** - * Upstream denial evidence for a 403. A workspace/entitlement denial means the CREDENTIAL - * is fine and the account simply cannot reach this workspace, so it must not be quarantined - * for reauthentication (#1789). Absent evidence keeps the historical credential handling. - */ - denial?: "workspace" | "entitlement"; - /** Stable transport code recorded alongside a neutral host failure. */ - lastFailureCode?: string; - /** Native model selected for this request; used only for confirmed scoped quotas. */ - modelId?: string; - /** When set, clears affinity for this thread immediately on transient failure. */ - threadId?: string | null; - /** - * Suppress Pool rotation and quota/transient affinity mutations for an account-qualified - * request. Credential failures still sweep stale affinities because reauthentication is - * account-wide. - */ - fixedAccount?: boolean; - /** - * Probe lease held by this request, when it was admitted through an active - * quota cooldown. Only the outcome carrying the current lease may clear the - * cooldown (#433). - */ - probeLeaseId?: string; - /** Scope of `probeLeaseId` when it was granted against a model-scoped cooldown. */ - probeQuotaScope?: CodexQuotaScope; - /** - * Already-chosen alternate for same-request 429 retry. When set, promotion - * reuses this account instead of calling {@link pickAlternateCodexAccount} - * again (which would advance a round-robin ring twice). - */ - promoteAccountId?: string; - /** Generation captured when this routed account was selected. */ - writerGeneration?: number; - /** - * Credential generation this request's bearer was read at. Distinct from - * `writerGeneration`, which tracks the config store. - * - * A 401 that arrives after the credential was already replaced is evidence about a - * token nobody is using any more, so it must not quarantine the replacement. Absent - * means the caller cannot supply lineage and the historical unfenced handling stands. - */ - credentialGeneration?: number; -}; - + classifyCodexUpstreamOutcome, + computeCodexUsageScore, + computeQuotaCooldown, + computeQuotaCooldownUntil, + parseRetryAfterMs, + parseResetCooldownMs, +} from "./routing/cooldown-math"; +export type { + CodexUpstreamOutcome, + CodexUpstreamOutcomeClass, + CodexCooldownSource, + CodexUpstreamOutcomeMeta, +} from "./routing/cooldown-math"; +export { + codexQuotaScopeForModel, + listLiveCodexAccountIds, + getCodexUpstreamHealth, + getCodexAccountCooldownUntil, + getCodexAccountHealthSnapshot, + getCodexQuotaHealthSnapshot, + isCodexAccountInCooldown, + clearCodexAccountCooldown, + getCodexAccountSoftAvoidUntil, + isCodexAccountSoftAvoided, +} from "./routing/health-store"; +export type { CodexQuotaScope } from "./routing/health-store"; +export { + tryAcquireCodexQuotaProbeLease, + canAcquireCodexQuotaProbeLease, + claimDueCodexQuotaRecoveryProbes, + claimManualResetCooldowns, + settleManualResetCooldown, + settleCodexQuotaRecoveryProbe, + tryAcquireCodexQuotaScopeProbeLease, + canAcquireCodexQuotaScopeProbeLease, + releaseCodexQuotaProbeLease, + releaseCodexQuotaScopeProbeLease, +} from "./routing/probe-lease"; +export type { + CodexQuotaRecoveryProbeClaim, + CodexQuotaRecoveryProbeProof, + ManualResetCooldownClaim, + ManualResetRefreshLineage, +} from "./routing/probe-lease"; +export { + CODEX_THREAD_AFFINITY_IDLE_TTL_MS, + CODEX_THREAD_AFFINITY_MAX_ENTRIES, + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS, + CODEX_TRANSIENT_AFFINITY_HOLD_MS, + clearThreadAccountMap, + clearThreadAccountMapForAccount, + debugCodexAffinityGenerations, + handOffThreadAffinityGeneration, +} from "./routing/thread-affinity"; +export type { + CodexThreadResolution, + CodexAffinityMove, + CodexAffinityReason, + CodexAffinityDecision, +} from "./routing/thread-affinity"; +export { + isCodexAccountPlanExcluded, + getPoolAccountPlan, + pickLowestUsageCodexAccount, + pickAlternateCodexAccount, +} from "./routing/selection"; +export { + resetCodexRoutingForManualSelection, + getEffectiveActiveCodexAccountId, + isEffectiveCodexAccountPinned, +} from "./routing/active-account"; function hasConfiguredPoolAccount( config: OcxConfig, accountId: string, @@ -416,1284 +198,41 @@ function hasConfiguredPoolAccount( .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId); } -export function listLiveCodexAccountIds(config: OcxConfig): ReadonlySet { - const ids = new Set((config.codexAccounts ?? []).map(account => account.id)); - const openai = config.providers.openai; - if (openai && openai.disabled !== true && isCanonicalOpenAiForwardProvider(openai)) { - ids.add(MAIN_CODEX_ACCOUNT_ID); - } - return ids; -} - -export function clearThreadAccountMap(): void { - threadAccountMap.clear(); - threadAffinityEntryTotal = 0; -} - -export function clearThreadAccountMapForAccount( - accountId: string, - reason: CodexAffinityReason = "unusable", -): void { - for (const [threadId, affinities] of threadAccountMap) { - for (const [scope, entry] of affinities) { - if (entry.accountId === accountId && affinities.delete(scope)) { - threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1); - notePendingReleaseReason(threadId, reason); - } - } - if (affinities.size === 0) threadAccountMap.delete(threadId); - } -} - -/** - * Why a binding was released, held until that thread's next resolve can report it (#4546). - * - * A release and the request that pays for it are two different moments: a 429 clears the pin - * inside the outcome recorder, and the next request arrives with nothing left to explain why it - * is starting cold. Bounded, because it is a diagnostic and must not become a leak. - */ -const pendingReleaseReasons = new Map(); -const MAX_PENDING_RELEASE_REASONS = 4096; - -function notePendingReleaseReason(threadId: string | null, reason: CodexAffinityReason): void { - if (threadId === null) return; - if (!pendingReleaseReasons.has(threadId) && pendingReleaseReasons.size >= MAX_PENDING_RELEASE_REASONS) { - const oldest = pendingReleaseReasons.keys().next(); - if (!oldest.done) pendingReleaseReasons.delete(oldest.value); - } - pendingReleaseReasons.set(threadId, reason); -} - -function peekPendingReleaseReason(threadId: string | null): CodexAffinityReason | undefined { - if (threadId === null) return undefined; - return pendingReleaseReasons.get(threadId); -} - -/** - * Forget a release only once it has actually been reported. - * - * Consuming it at derivation time lost it whenever selection then failed to produce an account: - * a no-account return carries no payload, so the release went unrecorded and the next successful - * resolve claimed a fresh healthy bind (#4598). A release survives until some resolve reports it. - */ -function clearPendingReleaseReason(threadId: string | null): void { - if (threadId !== null) pendingReleaseReasons.delete(threadId); -} - export function clearCodexUpstreamHealth(): void { // Operator preferences are routing state, not health, but they live and die with the same - // reset points. Leaving them behind lets a selection from one context suppress the - // automatic cursor in the next one. - manualPreference.clear(); - upstreamHealth.clear(); - quotaScopedHealth.clear(); - runtimeActiveCodexAccountId = undefined; - // The reconcile watermark is part of this state, not something that outlives it. Keeping - // it across a full reset is incoherent: there is no health left to protect, yet - // recordCodexUpstreamOutcome would still drop a writer whose generation predates the - // watermark for any account missing from the equally stale live set. Left behind, it also - // leaks between test files, which is how it was found. - lastReconciledGeneration = 0; - liveHealthAccountIds = new Set(); -} - -export function clearCodexUpstreamHealthForAccount(accountId: string): void { - upstreamHealth.delete(accountId); - quotaScopedHealth.delete(accountId); - // Deletion is the third operator exit, next to pause and exclusion, and it is the one - // with no reconcile path behind it: once the account is gone nothing can succeed on it, - // so an unspent preference naming it would suppress the automatic cursor for every other - // account until the process restarts. - forgetManualPreference(accountId); -} - -export function reconcileCodexRoutingHealth(context: GenerationContext): number { - if (context.generation <= lastReconciledGeneration) return 0; - let removed = 0; - for (const accountId of upstreamHealth.keys()) { - if (context.codexAccountIds.has(accountId)) continue; - upstreamHealth.delete(accountId); - removed += 1; - } - for (const accountId of quotaScopedHealth.keys()) { - if (context.codexAccountIds.has(accountId)) continue; - quotaScopedHealth.delete(accountId); - removed += 1; - } - // Sweep preferences the same way, for the account set this generation actually has. The - // delete path above is the direct route; this is the one that catches an account removed - // by an edit the runtime never saw. Deliberately not counted in `removed`, which reports - // health rows. - for (const [poolKey, preferred] of manualPreference) { - if (context.codexAccountIds.has(preferred)) continue; - manualPreference.delete(poolKey); - } - liveHealthAccountIds = new Set(context.codexAccountIds); - lastReconciledGeneration = context.generation; - return removed; -} - -export function getCodexUpstreamHealth( - accountId: string, -): CodexUpstreamHealth | null { - dropSpentCredentialFailure(accountId); - return upstreamHealth.get(accountId) ?? null; -} - -function scopedHealthFor(accountId: string, scope: CodexQuotaScope): CodexUpstreamHealth | undefined { - return quotaScopedHealth.get(accountId)?.get(scope); -} - -function setScopedHealth(accountId: string, scope: CodexQuotaScope, health: CodexUpstreamHealth): void { - let scopes = quotaScopedHealth.get(accountId); - if (!scopes) { - scopes = new Map(); - quotaScopedHealth.set(accountId, scopes); - } - scopes.set(scope, health); -} - -function deleteScopedHealth(accountId: string, scope: CodexQuotaScope): void { - const scopes = quotaScopedHealth.get(accountId); - if (!scopes) return; - scopes.delete(scope); - if (scopes.size === 0) quotaScopedHealth.delete(accountId); -} - -export function computeCodexUsageScore(quota: { - weeklyPercent?: number; - monthlyPercent?: number; - shortPercent?: number; - shortResetAt?: number; - shortObservedAt?: number; -} | null, plan?: unknown, now: number = Date.now()): number { - if (!quota) return CODEX_UNKNOWN_USAGE_SCORE; - const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); - const longWindows = isThirtyDayOnlyCodexPlan(plan) - ? [quota.monthlyPercent] - : [quota.weeklyPercent, quota.monthlyPercent]; - const knownLong = longWindows.filter(finite); - // The short burst window only REFINES a known long-window position; it cannot stand in for - // one. A snapshot carrying just `shortPercent: 0` would otherwise score a flat 0 and make an - // account whose weekly/monthly usage is entirely unverified look like the emptiest in the - // pool, so `pickLowestUsageAmong` would send every request to it. Unknown has to stay - // unknown until a governing window is actually observed. - // - // A FULL burst window is the exception (#3029). It is not an optimistic guess about an - // unobserved window — it is a direct observation that the account cannot serve a request - // right now, whatever its monthly position turns out to be. Unknown-means-selectable is - // correct for uncertainty and wrong for a measured refusal: the account stays selected, - // `applyQuotaAutoSwitch` never fires, and the pool wedges on an exhausted credential. - if (knownLong.length === 0) { - return isTerminalShortWindow(quota, now) ? CODEX_EXHAUSTED_USAGE_PERCENT : CODEX_UNKNOWN_USAGE_SCORE; - } - const values = finite(quota.shortPercent) ? [...knownLong, quota.shortPercent] : knownLong; - return Math.max(...values); -} - -/** - * A short-only reading that proves the account is blocked NOW. - * - * Freshness is not optional. `getAccountQuota` performs no expiry check, partial updates - * carry a still-open short tuple forward, and disk hydration accepts a persisted reading for - * hours — so scoring 100 from `shortPercent` alone would keep excluding an account whose - * five-hour window has since reset. Merge no longer carries an elapsed shortResetAt, but an - * explicit incoming elapsed tuple is still stored, and a missing reset cannot be aged there. - * That is #3029 pointed the other way: the issue is that - * an exhausted account stays selected, and "a recovered account stays excluded" trades one - * unusable pool for another. - * - * A reading with no `shortResetAt` cannot be aged, so it stays unknown. The conservative - * direction here is the one that keeps an account selectable: a wrongly-selected account - * fails one request, while a wrongly-excluded one is invisible until someone reads the pool - * by hand. - * - * A missing reset can instead be aged by shortObservedAt (#3425). General updatedAt is not - * sufficient: credit-only updates preserve the old short tuple but advance that timestamp. - * Old disk snapshots without short-window provenance remain unknown. - */ -function isTerminalShortWindow( - quota: { shortPercent?: number; shortResetAt?: number; shortObservedAt?: number }, - now: number, -): boolean { - if (typeof quota.shortPercent !== "number" || !Number.isFinite(quota.shortPercent)) return false; - if (quota.shortPercent < CODEX_EXHAUSTED_USAGE_PERCENT) return false; - const resetAt = quota.shortResetAt; - if (typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt <= 0) { - const observedAt = quota.shortObservedAt; - if (typeof observedAt !== "number" || !Number.isFinite(observedAt)) return false; - const age = now - observedAt; - return age >= 0 && age <= TERMINAL_SHORT_WINDOW_FRESHNESS_MS; - } - // Seconds and milliseconds both reach storage, so the split lives in one place next to the - // merge that also ages a stored reset instant (`resetAtToMs`, src/codex/quota.ts). - return resetAtToMs(resetAt) > now; -} - -export function classifyCodexUpstreamOutcome( - outcome: CodexUpstreamOutcome, - denial?: "workspace" | "entitlement", -): CodexUpstreamOutcomeClass { - if (outcome === "connect_neutral") return "neutral"; - if (outcome === "connect_error" || outcome === "timeout") return "transient"; - if (!Number.isFinite(outcome)) return "unknown"; - if (outcome >= 200 && outcome < 300) return "success"; - // Explicit 3xx policy (#914): a redirect response is relayed as-is and is - // never account or host health evidence — it proves the host is reachable - // and says nothing about the credential. Relayed as the neutral class so a - // stray 3xx cannot increment an account's transient streak. - if (outcome >= 300 && outcome < 400) return "neutral"; - // 401 is always a credential problem. A 403 is only a credential problem when nothing - // tells us otherwise: a workspace/entitlement denial (#1789) means the credential is valid - // and the account simply lacks access here, so quarantining it for reauth is wrong advice. - // Absent denial evidence the historical mapping stands, so the change fails safe. - if (outcome === 403 && denial !== undefined) return "workspace"; - if (outcome === 401 || outcome === 403) return "credential"; - // 402 Payment Required is treated as quota exhaustion for pool cooldown/failover - // (same-request alternate retry records this outcome for the depleted account). - if (outcome === 429 || outcome === 402) return "quota"; - if (outcome >= 400 && outcome < 500) return "caller"; - if (outcome >= 500 && outcome < 600) return "transient"; - return "unknown"; -} - -function clampCooldownMs(ms: number): number { - return Math.min(Math.max(ms, 1), CODEX_MAX_QUOTA_COOLDOWN_MS); -} - -export function parseRetryAfterMs(value: string | null | undefined, now = Date.now()): number | undefined { - const text = value?.trim(); - if (!text) return undefined; - if (/^\d+(?:\.\d+)?$/.test(text)) { - const seconds = Number(text); - if (Number.isFinite(seconds) && seconds > 0) return clampCooldownMs(Math.ceil(seconds * 1000)); - } - const timestamp = Date.parse(text); - if (!Number.isFinite(timestamp)) return undefined; - const delay = timestamp - now; - return delay > 0 ? clampCooldownMs(delay) : undefined; -} - -function resetTimestampMs(value: unknown): number | undefined { - const numeric = typeof value === "number" - ? value - : typeof value === "string" && value.trim() !== "" - ? Number(value) - : undefined; - if (typeof numeric !== "number" || !Number.isFinite(numeric) || numeric <= 0) return undefined; - return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric; -} - -export function parseResetCooldownMs(resetAt: unknown | unknown[] | undefined, now = Date.now()): number | undefined { - const values = Array.isArray(resetAt) ? resetAt : [resetAt]; - let best: number | undefined; - for (const value of values) { - const timestamp = resetTimestampMs(value); - if (timestamp === undefined) continue; - const delay = timestamp - now; - if (delay <= 0) continue; - // A far-future reset must not pin the account for the full Retry-After - // ceiling: quota usually frees up well before the advertised window (#433). - const clamped = Math.min(clampCooldownMs(delay), CODEX_MAX_RESET_DERIVED_COOLDOWN_MS); - if (best === undefined || clamped < best) best = clamped; - } - return best; -} - -export function computeQuotaCooldown(meta: CodexUpstreamOutcomeMeta = {}): { - until: number; - source: CodexCooldownSource; -} { - const now = meta.now ?? Date.now(); - const retryAfterMs = parseRetryAfterMs(meta.retryAfter, now); - if (retryAfterMs !== undefined) return { until: now + retryAfterMs, source: "retry-after" }; - const resetCooldownMs = parseResetCooldownMs(meta.resetAt, now); - if (resetCooldownMs !== undefined) return { until: now + resetCooldownMs, source: "reset-derived" }; - return { until: now + CODEX_DEFAULT_QUOTA_COOLDOWN_MS, source: "default" }; -} - -/** - * When the pool should stop preferring an account after it refused on quota. - * - * The earliest window the refusal actually announced, bounded by {@link CODEX_MAX_QUOTA_AVOID_MS}, - * and never shorter than the cooldown the same refusal produced — a Retry-After directive that - * outlasts every announcement still governs. - */ -function quotaAvoidUntilFor(meta: CodexUpstreamOutcomeMeta, now: number, cooldownUntil: number): number { - const values = Array.isArray(meta.resetAt) ? meta.resetAt : [meta.resetAt]; - let announced: number | undefined; - for (const value of values) { - const timestamp = resetTimestampMs(value); - if (timestamp === undefined) continue; - const delay = timestamp - now; - if (delay <= 0) continue; - const until = now + Math.min(delay, CODEX_MAX_QUOTA_AVOID_MS); - if (announced === undefined || until < announced) announced = until; - } - return Math.max(cooldownUntil, announced ?? 0); -} - -/** Live quota-refusal avoidance for an account, including the lane the request belongs to. */ -function codexQuotaAvoidUntil( - accountId: string, - quotaScope: CodexQuotaScope | undefined, - now: number, -): number | null { - const live = (value: number | undefined): number | null => - typeof value === "number" && Number.isFinite(value) && value > now ? value : null; - const account = live(upstreamHealth.get(accountId)?.quotaAvoidUntil); - const scoped = quotaScope === undefined - ? null - : live(scopedHealthFor(accountId, quotaScope)?.quotaAvoidUntil); - if (account === null) return scoped; - return scoped === null ? account : Math.max(account, scoped); -} - -function isCodexQuotaAvoided( - accountId: string, - quotaScope: CodexQuotaScope | undefined, - now: number, -): boolean { - return codexQuotaAvoidUntil(accountId, quotaScope, now) !== null; -} - -export function computeQuotaCooldownUntil(meta: CodexUpstreamOutcomeMeta = {}): number { - return computeQuotaCooldown(meta).until; -} - -/** - * Grant at most one probe lease per interval for a cooled-down account. - * - * A cooled-down account is short-circuited locally, so it never sends traffic and - * no organic 2xx can prove that upstream quota recovered — the cooldown can only - * end by expiry or a proxy restart (#433). Releasing a single probe breaks that - * deadlock. Explicit Retry-After cooldowns are excluded: those are literal retry - * directives, not window announcements. - * - * Returns the lease id, or null when no probe may go out right now. - */ -export function tryAcquireCodexQuotaProbeLease(accountId: string, now = Date.now()): string | null { - if (!canAcquireCodexQuotaProbeLease(accountId, now)) return null; - const health = upstreamHealth.get(accountId)!; - const probeLeaseId = randomUUID(); - upstreamHealth.set(accountId, { - ...health, - probeLeaseId, - probeLeaseGeneration: health.cooldownGeneration ?? 0, - lastProbeAt: now, - }); - return probeLeaseId; -} - -/** Side-effect-free check mirroring {@link tryAcquireCodexQuotaProbeLease} eligibility. */ -export function canAcquireCodexQuotaProbeLease(accountId: string, now = Date.now()): boolean { - return canAcquireQuotaProbeLease(upstreamHealth.get(accountId), now); -} - -function canAcquireQuotaProbeLease(health: CodexUpstreamHealth | undefined, now: number): boolean { - if (!health) return false; - const cooldownUntil = health.cooldownUntil; - if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return false; - if (health.cooldownSource === "retry-after") return false; - if (health.probeLeaseId !== undefined) return false; - const origin = health.lastProbeAt ?? health.cooldownSince ?? cooldownUntil; - return now - origin >= CODEX_QUOTA_PROBE_INTERVAL_MS; -} - -/** - * Claim due reset-derived cooldown probes without consulting account selection. - * Added Pool credentials only; owned main usage recovery is handled separately. - */ -export function claimDueCodexQuotaRecoveryProbes( - config: OcxConfig, - limit: number, - now = Date.now(), -): CodexQuotaRecoveryProbeClaim[] { - const boundedLimit = Math.max(0, Math.floor(limit)); - if (boundedLimit === 0) return []; - const candidates: Array<{ - accountId: string; - scope?: CodexQuotaScope; - health: CodexUpstreamHealth; - credentialGeneration: number; - credentialReplacedAt?: number; - order: number; - }> = []; - for (const [order, account] of (config.codexAccounts ?? []).entries()) { - if (!isSelectableCodexPoolAccount(account) - || isCodexAccountPaused(config, account.id) - || isAccountNeedsReauth(account.id)) continue; - const record = readCodexAccountRecord(account.id); - if (!record?.credential || record.deletedAt != null) continue; - const due = [ - { scope: undefined, health: upstreamHealth.get(account.id) }, - ...[...(quotaScopedHealth.get(account.id) ?? [])].map(([scope, health]) => ({ scope, health })), - ].filter((entry): entry is { scope?: CodexQuotaScope; health: CodexUpstreamHealth } => - // Generic WHAM evidence can recover only ordinary quota, never Reserve. - // Do not spend this account's one claim per pass on an independent scope and - // delay the shared scope that the response can actually recover. - (entry.scope === undefined || entry.scope === "shared") - && entry.health?.cooldownSource === "reset-derived" - && canAcquireQuotaProbeLease(entry.health, now)) - .sort((a, b) => - (a.health.lastProbeAt ?? a.health.cooldownSince ?? 0) - - (b.health.lastProbeAt ?? b.health.cooldownSince ?? 0)); - const candidate = due[0]; - if (candidate) candidates.push({ - accountId: account.id, - ...(candidate.scope ? { scope: candidate.scope } : {}), - health: candidate.health, - credentialGeneration: record.generation, - ...(record.replacedAt !== undefined ? { credentialReplacedAt: record.replacedAt } : {}), - order, - }); - } - candidates.sort((a, b) => { - const age = (a.health.lastProbeAt ?? a.health.cooldownSince ?? 0) - - (b.health.lastProbeAt ?? b.health.cooldownSince ?? 0); - return age || a.order - b.order; - }); - return candidates.slice(0, boundedLimit).map(candidate => { - const leaseId = randomUUID(); - const next = { - ...candidate.health, - probeLeaseId: leaseId, - probeLeaseGeneration: candidate.health.cooldownGeneration ?? 0, - lastProbeAt: now, - }; - if (candidate.scope) setScopedHealth(candidate.accountId, candidate.scope, next); - else upstreamHealth.set(candidate.accountId, next); - return { - accountId: candidate.accountId, - ...(candidate.scope ? { scope: candidate.scope } : {}), - leaseId, - cooldownGeneration: candidate.health.cooldownGeneration ?? 0, - credentialGeneration: candidate.credentialGeneration, - ...(candidate.credentialReplacedAt !== undefined - ? { credentialReplacedAt: candidate.credentialReplacedAt } - : {}), - }; - }); -} - -type CooldownRecoveryLease = Pick; - -export type ManualResetCooldownClaim = - | { kind: "pool"; probe: CodexQuotaRecoveryProbeClaim } - | { kind: "main"; probe: CooldownRecoveryLease }; - -function manualResetAccountEligible(config: OcxConfig, accountId: string): boolean { - return !isCodexAccountPaused(config, accountId) && !isAccountNeedsReauth(accountId) - && (accountId === MAIN_CODEX_ACCOUNT_ID - || (config.codexAccounts ?? []).some(account => account.id === accountId && isSelectableCodexPoolAccount(account))); -} - -/** Explicit reset bypasses probe pacing, never another owner's lease or quota scope. */ -export function claimManualResetCooldowns( - config: OcxConfig, - accountId: string, - now = Date.now(), - expectedPoolGeneration?: number, -): ManualResetCooldownClaim[] { - if (!manualResetAccountEligible(config, accountId)) return []; - const record = accountId === MAIN_CODEX_ACCOUNT_ID ? undefined : readCodexAccountRecord(accountId); - if (accountId !== MAIN_CODEX_ACCOUNT_ID && (!record?.credential || record.deletedAt != null)) return []; - if (record && expectedPoolGeneration !== undefined && record.generation !== expectedPoolGeneration) return []; - const claims: ManualResetCooldownClaim[] = []; - for (const scope of [undefined, "shared"] as const) { - const health = scope ? scopedHealthFor(accountId, scope) : upstreamHealth.get(accountId); - if (!health || health.cooldownSource !== "reset-derived" || health.probeLeaseId !== undefined - || !Number.isFinite(health.cooldownUntil) || !(health.cooldownUntil! > now)) continue; - const leaseId = randomUUID(); - const cooldownGeneration = health.cooldownGeneration ?? 0; - const next = { ...health, probeLeaseId: leaseId, probeLeaseGeneration: cooldownGeneration, lastProbeAt: now }; - if (scope) setScopedHealth(accountId, scope, next); - else upstreamHealth.set(accountId, next); - const probe = { accountId, scope, leaseId, cooldownGeneration }; - claims.push(record ? { kind: "pool", probe: { - ...probe, credentialGeneration: record.generation, credentialReplacedAt: record.replacedAt, - } } : { kind: "main", probe }); - } - return claims; -} - -export type ManualResetRefreshLineage = Readonly<{ - fromGeneration: number; - toGeneration: number; - provenance: CodexRefreshProvenance; -}>; - -type ManualResetQuotaProof = CodexQuotaRecoveryProbeProof & { - refreshLineage?: ManualResetRefreshLineage; -}; - -/** Main proof is checked by the already-owned auth operation, never by a Pool record. */ -export function settleManualResetCooldown( - config: OcxConfig, - claim: ManualResetCooldownClaim, - recovered: boolean, - proof: ManualResetQuotaProof = {}, - now = Date.now(), -): boolean { - if (!recovered) return settleCooldownRecoveryLease(claim.probe, false, now); - const eligible = manualResetAccountEligible(config, claim.probe.accountId); - if (claim.kind === "main") return settleCooldownRecoveryLease(claim.probe, eligible, now); - const lineage = proof.refreshLineage; - // Equal wall-clock replacement stamps do not establish ancestry. Manual +1 - // recovery additionally needs the actual forced-refresh result for this edge. - const ownedGeneration = proof.credentialGeneration === claim.probe.credentialGeneration - || (proof.credentialGeneration === claim.probe.credentialGeneration + 1 - && lineage?.fromGeneration === claim.probe.credentialGeneration - && lineage.toGeneration === proof.credentialGeneration - && (lineage.provenance === "self-refresh" || lineage.provenance === "joined-lineage")); - return settleCodexQuotaRecoveryProbe(claim.probe, eligible && ownedGeneration, proof, now); -} - -/** Settle one background recovery claim without mutating account-wide outcome state. */ -export function settleCodexQuotaRecoveryProbe( - claim: CodexQuotaRecoveryProbeClaim, - recovered: boolean, - proof: CodexQuotaRecoveryProbeProof, - now = Date.now(), -): boolean { - const health = claim.scope - ? scopedHealthFor(claim.accountId, claim.scope) - : upstreamHealth.get(claim.accountId); - if (!health || health.probeLeaseId !== claim.leaseId) return false; - const currentRecord = readCodexAccountRecord(claim.accountId); - const proofGeneration = proof.credentialGeneration; - // A probe-owned token refresh (getValidCodexToken) advances the credential generation by - // exactly one while preserving `replacedAt`; an external credential replacement bumps the - // generation too but stamps a fresh `replacedAt`. Accept the +1 transition only when the - // claim-time lineage is intact AND the generation the fresh quota was proven under is live. - const generationFenced = proofGeneration !== undefined - && (proofGeneration === claim.credentialGeneration - ? isCodexAccountGenerationLive(claim.accountId, proofGeneration) - : proofGeneration === claim.credentialGeneration + 1 - && currentRecord?.replacedAt === claim.credentialReplacedAt - && isCodexAccountGenerationLive(claim.accountId, proofGeneration)); - return settleCooldownRecoveryLease(claim, recovered && generationFenced, now); -} - -function settleCooldownRecoveryLease(claim: CooldownRecoveryLease, recovered: boolean, now: number): boolean { - const health = claim.scope ? scopedHealthFor(claim.accountId, claim.scope) : upstreamHealth.get(claim.accountId); - if (!health || health.probeLeaseId !== claim.leaseId) return false; - const fenced = (claim.scope === undefined || claim.scope === "shared") - && health.cooldownSource === "reset-derived" - && (health.cooldownGeneration ?? 0) === claim.cooldownGeneration - && (health.probeLeaseGeneration ?? 0) === claim.cooldownGeneration; - if (!recovered || !fenced) { - const released = withProbeLeaseReleased(health, now); - if (claim.scope) setScopedHealth(claim.accountId, claim.scope, released); - else upstreamHealth.set(claim.accountId, released); - return false; - } - if (claim.scope) { - deleteScopedHealth(claim.accountId, claim.scope); - } else { - const { - cooldownUntil: _until, - cooldownSince: _since, - cooldownSource: _source, - probeLeaseId: _leaseId, - probeLeaseGeneration: _leaseGeneration, - // "The quota window moved" is a statement about the whole refusal, so the avoidance it - // announced goes with the block it produced. Leaving it would make this escape hatch stop - // escaping: the account would still be passed over by every selection it is meant to win. - quotaAvoidUntil: _avoid, - ...rest - } = health; - upstreamHealth.set(claim.accountId, { - ...rest, - cooldownGeneration: claim.cooldownGeneration + 1, - lastProbeAt: now, - }); - } - return true; -} - -/** Acquire the recovery probe for one confirmed model-specific quota group. */ -export function tryAcquireCodexQuotaScopeProbeLease( - accountId: string, - scope: CodexQuotaScope, - now = Date.now(), -): string | null { - const health = scopedHealthFor(accountId, scope); - if (!canAcquireQuotaProbeLease(health, now)) return null; - const probeLeaseId = randomUUID(); - setScopedHealth(accountId, scope, { - ...health!, - probeLeaseId, - probeLeaseGeneration: health!.cooldownGeneration ?? 0, - lastProbeAt: now, - }); - return probeLeaseId; -} - -/** Side-effect-free check for a confirmed model-specific quota probe. */ -export function canAcquireCodexQuotaScopeProbeLease( - accountId: string, - scope: CodexQuotaScope, - now = Date.now(), -): boolean { - return canAcquireQuotaProbeLease(scopedHealthFor(accountId, scope), now); -} - -/** - * Hand a probe lease back without recording an upstream outcome. Used by paths - * that take a lease and then fail before any request reaches upstream. - */ -export function releaseCodexQuotaProbeLease(accountId: string, leaseId: string, now = Date.now()): void { - const health = upstreamHealth.get(accountId); - if (!health || health.probeLeaseId !== leaseId) return; - upstreamHealth.set(accountId, withProbeLeaseReleased(health, now)); -} - -/** Release a model-specific quota probe when the request never reaches upstream. */ -export function releaseCodexQuotaScopeProbeLease( - accountId: string, - scope: CodexQuotaScope, - leaseId: string, - now = Date.now(), -): void { - const health = scopedHealthFor(accountId, scope); - if (!health || health.probeLeaseId !== leaseId) return; - setScopedHealth(accountId, scope, withProbeLeaseReleased(health, now)); -} - -/** - * True when this outcome belongs to the account's in-flight probe. The - * undefined-id guard matters: without it an outcome carrying no lease would match - * an account holding no lease and be mistaken for the probe owner. - */ -function ownsProbeLease(health: CodexUpstreamHealth | undefined, meta: CodexUpstreamOutcomeMeta): boolean { - return meta.probeLeaseId !== undefined && meta.probeLeaseId === health?.probeLeaseId; -} - -/** - * True when the owning probe may still clear the cooldown. A later 429 bumps the - * generation, so a probe that started under an older cooldown must not erase the - * newer restriction (which may carry an explicit Retry-After). - */ -function probeMayClearCooldown(health: CodexUpstreamHealth | undefined, meta: CodexUpstreamOutcomeMeta): boolean { - return ownsProbeLease(health, meta) - && (health!.probeLeaseGeneration ?? 0) === (health!.cooldownGeneration ?? 0); -} - -/** Strip the in-flight lease while preserving every hard-cooldown field. */ -function withProbeLeaseReleased(health: CodexUpstreamHealth, now: number): CodexUpstreamHealth { - const { probeLeaseId: _id, probeLeaseGeneration: _gen, ...rest } = health; - return { ...rest, lastProbeAt: now }; -} - -/** - * Hard-cooldown bookkeeping that ordinary success/transient transitions rebuild - * their health object from. Dropping these would let one late unrelated response - * erase a Retry-After source, a cooldown generation, or someone else's live probe. - */ -function preservedCooldownFields(health: CodexUpstreamHealth | undefined): Partial { - if (!health) return {}; - // `credentialFailureGeneration` is provenance for ONE credential failure, so it must not survive - // into a later transient or quota entry — otherwise that entry inherits the tag and gets spent - // when the old credential dies, deleting evidence that was never about it (#2892 gap 4 review). - const { - consecutiveFailures: _f, consecutiveSuccesses: _s, lastFailureStatus: _st, lastFailureAt: _at, - softAvoidUntil: _sa, credentialFailureGeneration: _cg, ...cooldownFields - } = health; - return cooldownFields; -} - -/** Manual selection resets transient routing evidence without bypassing a real 429 cooldown. */ -export function resetCodexRoutingForManualSelection(accountId: string): void { - clearThreadAccountMap(); - // Manual selection is the operator source of truth — drop any automatic runtime cursor. - runtimeActiveCodexAccountId = undefined; - // Record the pick as an unspent one-shot on the SHARED scope only. An independent scope - // gets no entry on purpose: every write site the guard protects is already skipped for - // independent scopes, so an entry there would be state nothing reads — and state nothing - // reads is what the next reader mistakes for a rule. - // - // Seeding happens ONLY here. A pool-driven promote must never create or move a preference, - // or the pool would manufacture an operator intent nobody expressed. - manualPreference.set(POOL_KEY_CODEX, accountId); - // Seed the RR ring so the next unbound new session honors the manually selected account - // under round-robin (affinity-cleared threads / null threadId). Fill-first already follows - // config.activeCodexAccountId, which the caller persists before invoking this. - seedPoolRotationAccount(POOL_KEY_CODEX, accountId); - for (const scope of new Set(Object.values(NATIVE_MODEL_QUOTA_SCOPES))) { - if (isIndependentCodexQuotaScope(scope)) { - seedPoolRotationAccount(codexPoolKeyForScope(scope), accountId); - } - } - // Quota avoidance is a preference, like the soft avoid dropped above, and an operator naming - // this account has overruled it. The hard cooldown is the part that survives. - const overrule = (health: CodexUpstreamHealth) => { - const { quotaAvoidUntil: _avoid, ...retained } = preservedCooldownFields(health); - return retained; - }; - const current = upstreamHealth.get(accountId); - if (current) { - const retained = overrule(current); - if (Object.keys(retained).length === 0) upstreamHealth.delete(accountId); - else upstreamHealth.set(accountId, { consecutiveFailures: 0, ...retained }); - } - // A reset-derived refusal records its avoidance on the SCOPED map and returns before the - // account-wide entry is written, so naming the account has to reach that map too. Stopping - // at `upstreamHealth` — and returning early when it holds nothing — overruled nothing in - // the case that produces the avoidance this function exists to overrule. - for (const [scope, health] of [...(quotaScopedHealth.get(accountId) ?? [])]) { - const retained = overrule(health); - if (Object.keys(retained).length === 0) deleteScopedHealth(accountId, scope); - else setScopedHealth(accountId, scope, { consecutiveFailures: 0, ...retained }); - } -} - -export function getCodexAccountCooldownUntil(accountId: string, now = Date.now()): number | null { - const cooldownUntil = upstreamHealth.get(accountId)?.cooldownUntil; - return typeof cooldownUntil === "number" && Number.isFinite(cooldownUntil) && cooldownUntil > now ? cooldownUntil : null; -} - -/** Read-only cooldown snapshot for shared OAuth health projection (no write side effects). */ -export function getCodexAccountHealthSnapshot(accountId: string, now = Date.now()): { - cooldownUntil?: number; - cooldownSource?: CodexCooldownSource; -} | null { - const cooldownUntil = getCodexAccountCooldownUntil(accountId, now); - if (cooldownUntil === null) return null; - const source = upstreamHealth.get(accountId)?.cooldownSource; - return { - cooldownUntil, - ...(source ? { cooldownSource: source } : {}), - }; -} - -/** - * Read the cooldown relevant to a routed native model. Account-wide cooldowns - * (Retry-After/default) always win; reset-derived scoped state applies only to - * its confirmed quota group. - */ -export function getCodexQuotaHealthSnapshot( - accountId: string, - quotaScope: CodexQuotaScope | undefined, - now = Date.now(), -): { - cooldownUntil?: number; - cooldownSource?: CodexCooldownSource; - quotaScope?: CodexQuotaScope; -} | null { - const account = getCodexAccountHealthSnapshot(accountId, now); - if (account) return account; - if (!quotaScope) return null; - const scoped = scopedHealthFor(accountId, quotaScope); - const cooldownUntil = scoped?.cooldownUntil; - if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return null; - return { - cooldownUntil, - ...(scoped?.cooldownSource ? { cooldownSource: scoped.cooldownSource } : {}), - quotaScope, - }; -} - -export function isCodexAccountInCooldown(accountId: string, now = Date.now()): boolean { - return getCodexAccountCooldownUntil(accountId, now) !== null; -} - -/** - * Manually lift a hard quota cooldown without touching failure history. - * - * Injected Codex routing makes this proxy the ONLY model path for Codex Desktop, so a - * cooldown that outlives the real upstream limit reads to the user as "the whole app is - * broken" with no escape but editing config.toml. This is that escape hatch. - * - * Deliberately narrow: - * - Failure counters and softAvoid survive. Clearing a cooldown says "the quota window - * moved", not "this account is healthy"; failover must keep its knowledge. - * - Dropping `probeLeaseId` is what stops a stale in-flight probe from later "proving" - * recovery against a NEWER cooldown: {@link ownsProbeLease} needs the id to match. - * `cooldownGeneration` is preserved and bumped as redundancy only — a fresh 429 already - * bumps it in {@link recordCodexUpstreamOutcome}, so the bump here is not load-bearing - * today and is kept so the invariant survives a future change that retains the lease. - * - * Returns false when the account carried neither a live cooldown nor a live avoidance window. - * The window outlives the cooldown by design — the cooldown caps at fifteen minutes and the - * window runs up to six hours — so the moment an operator actually reaches for this escape - * hatch is usually after the cooldown lapsed and only the window is still keeping the account - * out of rotation. Refusing to look at the window then would leave the hatch shut in the one - * case it exists for. - */ -export function clearCodexAccountCooldown(accountId: string, now = Date.now()): boolean { - const clear = (health: CodexUpstreamHealth): CodexUpstreamHealth | null => { - const cooldownUntil = health.cooldownUntil; - const liveCooldown = typeof cooldownUntil === "number" && Number.isFinite(cooldownUntil) && cooldownUntil > now; - const avoidUntil = health.quotaAvoidUntil; - const liveAvoidance = typeof avoidUntil === "number" && Number.isFinite(avoidUntil) && avoidUntil > now; - if (!liveCooldown && !liveAvoidance) return null; - const { - cooldownUntil: _until, - cooldownSince: _since, - cooldownSource: _source, - probeLeaseId: _leaseId, - probeLeaseGeneration: _leaseGeneration, - // Same reasoning as the probe recovery above: "the quota window moved" is a statement - // about the whole refusal, so the avoidance it announced goes with the block it - // produced. Keeping it would leave this escape hatch not escaping, because selection - // would still pass over the account for as long as the announced window runs. - quotaAvoidUntil: _avoid, - ...rest - } = health; - return { - ...rest, - cooldownGeneration: (health.cooldownGeneration ?? 0) + 1, - lastProbeAt: now, - }; - }; - - let cleared = false; - const accountHealth = upstreamHealth.get(accountId); - if (accountHealth) { - const next = clear(accountHealth); - if (next) { - upstreamHealth.set(accountId, next); - cleared = true; - } - } - for (const [scope, health] of quotaScopedHealth.get(accountId) ?? []) { - const next = clear(health); - if (next) { - setScopedHealth(accountId, scope, next); - cleared = true; - } - } - return cleared; -} - -export function getCodexAccountSoftAvoidUntil(accountId: string, now = Date.now()): number | null { - const softAvoidUntil = upstreamHealth.get(accountId)?.softAvoidUntil; - return typeof softAvoidUntil === "number" && Number.isFinite(softAvoidUntil) && softAvoidUntil > now - ? softAvoidUntil - : null; -} - -export function isCodexAccountSoftAvoided(accountId: string, now = Date.now()): boolean { - return getCodexAccountSoftAvoidUntil(accountId, now) !== null; -} - -/** - * Plan keys the operator excluded from automatic rotation. Absent or empty means no policy, so an - * existing install rotates exactly as before. Compared with `codexPlanKey` because the stored plan - * is an unrestricted provider string whose casing this repository does not control. - */ -function excludedCodexPoolPlanKeys(config: OcxConfig): ReadonlySet | undefined { - const configured = config.codexPool?.excludedPlans; - if (!configured?.length) return undefined; - const keys = configured - .map(plan => codexPlanKey(plan)) - .filter((key): key is string => key !== undefined); - return keys.length > 0 ? new Set(keys) : undefined; -} - -/** - * Whether the operator's plan policy removes this account from automatic selection. - * - * Modelled on pause rather than usability: an excluded account keeps its credential, quota history, - * and affinity, stays visible on the account surface, and is still reachable by explicit account - * selection. Only automatic rotation skips it, which is the distinction #4211 asked for. - * - * It is checked in the same two places pause is checked, and that is not redundancy. The eligible - * list is consulted only when routing picks a NEW account; an already-active or already-affined - * account is served straight from {@link isCodexAccountSelectable}. A lapsed subscription leaves - * behind exactly that account, so a policy that filtered only the eligible list would miss the case - * it exists for. - * - * `__main__` is exempt. {@link getPoolAccountPlanForSelection} withholds the main plan during a - * selection-only drain so routing never reads the fenced native credential for it, so a rule that - * covered main would disagree with itself between drain and ordinary routing. - */ -export function isCodexAccountPlanExcluded( - config: OcxConfig, - accountId: string, - precomputed?: ReadonlySet, -): boolean { - if (accountId === MAIN_CODEX_ACCOUNT_ID) return false; - // Callers that test a whole list pass the set once rather than rebuilding it per row. - const excluded = precomputed ?? excludedCodexPoolPlanKeys(config); - if (!excluded) return false; - const plan = codexPlanKey(getPoolAccountPlan(config, accountId)); - return plan !== undefined && excluded.has(plan); -} - -function isCodexAccountSelectable( - config: OcxConfig, - accountId: string, - now: number, - quotaScope?: CodexQuotaScope, - selectionOptions?: CodexAccountUsabilityOptions, -): boolean { - return !isCodexAccountPaused(config, accountId) - && !isCodexAccountPlanExcluded(config, accountId) - && getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null - && !isCodexQuotaAvoided(accountId, quotaScope, now) - && !isCodexAccountSoftAvoided(accountId, now) - && isCodexAccountUsable(config, accountId, selectionOptions); -} - -/** - * Which guard in {@link isCodexAccountSelectable} refused this account, if any. - * - * Deliberately the same predicates in the same order as that function, because the point is to - * REPORT the guard that actually fired rather than to re-derive a plausible-looking cause. An - * earlier version of the release reason checked only a subset and let a paused, plan-excluded, - * cooled-down or quota-avoided release fall through to a quota fallback, which named something - * routing never used -- a diagnostic that is confidently wrong in exactly the cases an operator - * would consult it for (#4598). - */ -function codexAccountBlockReason( - config: OcxConfig, - accountId: string, - now: number, - quotaScope?: CodexQuotaScope, - selectionOptions?: CodexAccountUsabilityOptions, -): CodexAffinityReason | undefined { - if (isCodexAccountPaused(config, accountId)) return "paused"; - if (isCodexAccountPlanExcluded(config, accountId)) return "plan_excluded"; - if (getCodexQuotaHealthSnapshot(accountId, quotaScope, now) !== null) return "cooldown"; - if (isCodexQuotaAvoided(accountId, quotaScope, now)) return "quota_avoided"; - if (isCodexAccountSoftAvoided(accountId, now)) return "transient"; - if (!isCodexAccountUsable(config, accountId, selectionOptions)) return "unusable"; - return undefined; -} - -function threadAffinityScope(quotaScope?: CodexQuotaScope): BaseThreadAffinityScope { - return quotaScope ?? LEGACY_THREAD_AFFINITY_SCOPE; -} - -function admissibleAffinityComponent(value: string): boolean { - return retainedUtf8Bytes(value) <= MAX_AFFINITY_COMPONENT_BYTES; -} - -function modelDetourAffinityScope( - modelId: string | undefined, - quotaScope?: CodexQuotaScope, -): ModelDetourAffinityScope | undefined { - const canonicalModelId = modelId?.trim().toLowerCase(); - if (!canonicalModelId || !admissibleAffinityComponent(canonicalModelId)) return undefined; - return `model-detour:${threadAffinityScope(quotaScope)}:${canonicalModelId}`; -} - -function getThreadAffinityForScope( - threadId: string, - scope: ThreadAffinityScope, -): ThreadAffinityEntry | undefined { - if (!admissibleAffinityComponent(threadId)) return undefined; - return threadAccountMap.get(threadId)?.get(scope); -} - -function getThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): ThreadAffinityEntry | undefined { - return getThreadAffinityForScope(threadId, threadAffinityScope(quotaScope)); -} - -function getModelDetourAffinity( - threadId: string, - modelId: string | undefined, - quotaScope?: CodexQuotaScope, -): ThreadAffinityEntry | undefined { - const scope = modelDetourAffinityScope(modelId, quotaScope); - return scope ? getThreadAffinityForScope(threadId, scope) : undefined; -} - -function deleteThreadAffinityForScope(threadId: string, scope: ThreadAffinityScope): void { - if (!admissibleAffinityComponent(threadId)) return; - const affinities = threadAccountMap.get(threadId); - if (!affinities) return; - if (affinities.delete(scope)) { - threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1); - } - if (affinities.size === 0) threadAccountMap.delete(threadId); -} - -function deleteThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): void { - deleteThreadAffinityForScope(threadId, threadAffinityScope(quotaScope)); -} - -function deleteModelDetourAffinity( - threadId: string, - modelId: string | undefined, - quotaScope?: CodexQuotaScope, -): void { - const scope = modelDetourAffinityScope(modelId, quotaScope); - if (scope) deleteThreadAffinityForScope(threadId, scope); -} - -/** Remove only the matching failed account's affinities for one thread. */ -function deleteThreadAffinitiesForAccount(threadId: string, accountId: string): void { - if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return; - const affinities = threadAccountMap.get(threadId); - if (!affinities) return; - for (const [scope, entry] of affinities) { - if (entry.accountId === accountId && affinities.delete(scope)) { - threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1); - } - } - if (affinities.size === 0) threadAccountMap.delete(threadId); -} - -function threadAffinityEntryCount(): number { - return threadAffinityEntryTotal; -} - -function isThreadAffinityExpired(entry: ThreadAffinityEntry, now: number): boolean { - return now - entry.lastUsedAt > CODEX_THREAD_AFFINITY_IDLE_TTL_MS; -} - -function isThreadAffinityGenerationLive(entry: ThreadAffinityEntry): boolean { - if (entry.accountId === MAIN_CODEX_ACCOUNT_ID) return entry.generation === 0; - return isCodexAccountGenerationLive(entry.accountId, entry.generation); -} - -/** Generations this account's affinity entries are bound at. Test observability only. */ -export function debugCodexAffinityGenerations(accountId: string): number[] { - const generations: number[] = []; - for (const affinities of threadAccountMap.values()) { - for (const entry of affinities.values()) { - if (entry.accountId === accountId) generations.push(entry.generation); - } - } - return generations; -} - -/** - * Advance this account's affinity entries from the generation a rejected credential - * was bound under to the generation its own refresh produced. - * - * A 401 refresh-and-replay keeps the request on the same account, but the CAS write - * moves the credential from G to G+1, and {@link isThreadAffinityGenerationLive} - * demands exact equality — so without this the entry the replay just preserved is - * dead on the next request. Not quarantining an account is not the same as keeping - * its affinity. - * - * Lineage is proven by the CALLER, which must pass only a generation its own refresh - * produced. Re-deriving it here from `replacedAt` cannot work: the caller reads that - * field after the refresh and this function would re-read the same record, so the - * comparison is tautological and an external replacement passes it. An external - * replacement must retire the affinity, because that credential may belong to a - * different upstream identity. - */ -export function handOffThreadAffinityGeneration( - accountId: string, - fromGeneration: number, - toGeneration: number, -): boolean { - if (accountId === MAIN_CODEX_ACCOUNT_ID) return false; - if (toGeneration !== fromGeneration + 1) return false; - const record = readCodexAccountRecord(accountId); - if (!record?.credential || record.deletedAt != null) return false; - if (record.generation !== toGeneration) return false; - let handedOff = false; - for (const affinities of threadAccountMap.values()) { - for (const entry of affinities.values()) { - if (entry.accountId !== accountId || entry.generation !== fromGeneration) continue; - entry.generation = toGeneration; - handedOff = true; - } - } - return handedOff; -} - -function pruneExpiredThreadAffinities(now: number): void { - for (const [threadId, affinities] of threadAccountMap) { - for (const [scope, entry] of affinities) { - if (isThreadAffinityExpired(entry, now) && affinities.delete(scope)) { - threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1); - } - } - if (affinities.size === 0) threadAccountMap.delete(threadId); - } -} - -function pruneLruThreadAffinities(): void { - if (threadAffinityEntryCount() <= CODEX_THREAD_AFFINITY_MAX_ENTRIES) return; - while (threadAffinityEntryCount() > CODEX_THREAD_AFFINITY_MAX_ENTRIES) { - let oldestThreadId: string | null = null; - let oldestScope: ThreadAffinityScope | null = null; - let oldestLastUsedAt = Number.POSITIVE_INFINITY; - let oldestIsDetour = false; - for (const [threadId, affinities] of threadAccountMap) { - for (const [scope, entry] of affinities) { - const candidateIsDetour = isModelDetourAffinityScope(scope); - if ( - (candidateIsDetour && !oldestIsDetour) - || (candidateIsDetour === oldestIsDetour && entry.lastUsedAt < oldestLastUsedAt) - ) { - oldestThreadId = threadId; - oldestScope = scope; - oldestLastUsedAt = entry.lastUsedAt; - oldestIsDetour = candidateIsDetour; - } - } - } - if (!oldestThreadId || !oldestScope) return; - deleteThreadAffinityForScope(oldestThreadId, oldestScope); - } -} - -function bindThreadAffinityForScope( - threadId: string, - accountId: string, - now: number, - scope: ThreadAffinityScope, -): void { - if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return; - const record = accountId === MAIN_CODEX_ACCOUNT_ID ? undefined : readCodexAccountRecord(accountId); - if (accountId !== MAIN_CODEX_ACCOUNT_ID && (!record?.credential || record.deletedAt != null)) return; - pruneExpiredThreadAffinities(now); - const affinities = threadAccountMap.get(threadId) ?? new Map(); - const previous = affinities.get(scope); - affinities.set(scope, { - accountId, - generation: accountId === MAIN_CODEX_ACCOUNT_ID ? 0 : record!.generation, - createdAt: previous?.createdAt ?? now, - lastUsedAt: now, - lastReevalAt: now, - }); - if (!previous) threadAffinityEntryTotal += 1; - threadAccountMap.set(threadId, affinities); - pruneLruThreadAffinities(); -} - -function bindThreadAffinity( - threadId: string, - accountId: string, - now: number, - quotaScope?: CodexQuotaScope, -): void { - bindThreadAffinityForScope(threadId, accountId, now, threadAffinityScope(quotaScope)); -} - -function bindModelDetourAffinity( - threadId: string, - accountId: string, - now: number, - modelId: string | undefined, - quotaScope?: CodexQuotaScope, -): void { - const scope = modelDetourAffinityScope(modelId, quotaScope); - if (scope) bindThreadAffinityForScope(threadId, accountId, now, scope); -} - -function getEligiblePoolAccounts( - config: OcxConfig, - excludeId?: string, - now = Date.now(), - quotaScope?: CodexQuotaScope, - selectionOptions?: CodexAccountUsabilityOptions, - skipFailoverReadyCandidates = false, -): readonly string[] { - const excludedPlans = excludedCodexPoolPlanKeys(config); - const ids = (config.codexAccounts ?? []) - .filter(account => isSelectableCodexPoolAccount(account) - && account.id !== excludeId - && !isCodexAccountPaused(config, account.id) - && !isCodexAccountPlanExcluded(config, account.id, excludedPlans) - && !isAccountNeedsReauth(account.id) - && (!skipFailoverReadyCandidates || !shouldFailover(config, account.id, now))) - .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null) - .filter(account => !isCodexAccountSoftAvoided(account.id, now)) - .filter(account => !isCodexQuotaAvoided(account.id, quotaScope, now)) - .filter(account => isCodexAccountUsable(config, account.id, selectionOptions)) - .map(account => account.id); - // The main Codex account is not stored in config.codexAccounts; include it as a - // first-class rotation candidate when its read-only token is usable (Option A). - if ( - excludeId !== MAIN_CODEX_ACCOUNT_ID - && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) - && (!isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) || hasMainAccountRefreshGrant()) - && getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null - && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now) - // The main login is not in `config.codexAccounts`, so it never passes through the - // filters above and this is the only place an avoidance window can exclude it. Without - // this the window a refusal announced applies to the pool but not to the account that - // earned it: the cooldown caps at fifteen minutes, the window runs up to six hours, and - // in between the main account returns as a first-class candidate. - && !isCodexQuotaAvoided(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) - && (!skipFailoverReadyCandidates || !shouldFailover(config, MAIN_CODEX_ACCOUNT_ID, now)) - && isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions) - ) { - ids.unshift(MAIN_CODEX_ACCOUNT_ID); - } - // Single choke point for selection order: every strategy, failover, and preview - // reaches the pool through here, so tiering applies once rather than per picker. - // Eligibility above is unchanged — this only narrows an already-eligible list. - return selectPriorityTier( - ids, - codexAccountPriorityLookup(config), - id => hasCodexQuotaHeadroom(config, id, selectionOptions, now), - pinnedCodexAccountId(config), - ); -} - -function listEligibleCodexAccountIds( - config: OcxConfig, - now: number, - quotaScope?: CodexQuotaScope, - selectionOptions?: CodexAccountUsabilityOptions, -): readonly string[] { - return getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions); -} - -/** Shared reset timestamps are not evidence for independent model-quota groups. */ -function accountPoolStrategyForScope(config: OcxConfig, quotaScope?: CodexQuotaScope) { - const strategy = normalizeCodexAccountPoolStrategy(config.accountPoolStrategy); - return strategy === "reset-first" && isIndependentCodexQuotaScope(quotaScope) ? "quota" : strategy; -} - -function stickyLimitForConfig(config: OcxConfig): number { - return normalizeAccountPoolStickyLimit(config.accountPoolStickyLimit); -} - -/** - * Whether an account still has quota to give under the auto-switch threshold. - * - * Fill-first and the priority tier filter share this predicate, and share both of - * its escape hatches. A disabled threshold means only health, pause, and reauth - * may drain an account; unknown usage is a guess, so it must neither force - * fill-first off the active account nor drain a tier that was simply never - * primed. A genuinely exhausted account 429s into cooldown and leaves - * eligibility on its own. - */ -function hasCodexQuotaHeadroom( - config: OcxConfig, - accountId: string, - selectionOptions?: CodexAccountUsabilityOptions, - now: number = Date.now(), -): boolean { - const threshold = config.autoSwitchThreshold ?? 80; - if (threshold <= 0) return true; - const usage = computeCodexUsageScore( - getAccountQuota(accountId), - getPoolAccountPlanForSelection(config, accountId, selectionOptions), - now, - ); - if (isUnknownUsage(usage)) return true; - return usage < threshold; + // reset points. Leaving them behind lets a selection from one context suppress the + // automatic cursor in the next one. + clearAllManualPreferences(); + clearUpstreamHealthState(); + forgetRuntimeActiveCodexAccount(); + // The reconcile watermark is part of this state, not something that outlives it. Keeping + // it across a full reset is incoherent: there is no health left to protect, yet + // recordCodexUpstreamOutcome would still drop a writer whose generation predates the + // watermark for any account missing from the equally stale live set. Left behind, it also + // leaks between test files, which is how it was found. + resetHealthReconcileState(); } -/** - * Is a live binding held for its prompt cache? - * - * Unset means yes. Cache affinity shipped as an opt-in flag (#4292) and then #4546 measured - * what the default costs: a pool whose accounts all sit in the 80-99% band hands a bound - * conversation from account to account, and because provider prompt caches are account-isolated - * every hop re-sends the entire prefix. An install that has never heard of this flag is exactly - * the install that gets hurt by it, so the protection cannot be something you have to find. - * - * `false` restores capacity-first routing byte-for-byte. It is a real choice -- a pinned thread - * on a busy account pays latency -- and it stays available; it is just no longer the default. - */ -function isCacheAffinityEnabled(config: OcxConfig): boolean { - return config.pool?.cacheAffinity !== false; +export function clearCodexUpstreamHealthForAccount(accountId: string): void { + deleteAllHealthForAccount(accountId); + // Deletion is the third operator exit, next to pause and exclusion, and it is the one + // with no reconcile path behind it: once the account is gone nothing can succeed on it, + // so an unspent preference naming it would suppress the automatic cursor for every other + // account until the process restarts. + forgetManualPreference(accountId); } +export function reconcileCodexRoutingHealth(context: GenerationContext): number { + if (isHealthGenerationReconciled(context.generation)) return 0; + const removed = pruneHealthAccountsForContext(context.codexAccountIds); + // Sweep preferences the same way, for the account set this generation actually has. The + // delete path above is the direct route; this is the one that catches an account removed + // by an edit the runtime never saw. Deliberately not counted in `removed`, which reports + // health rows. + forgetRoutingPreferencesOutside(context.codexAccountIds); + commitHealthReconcile(context.generation, context.codexAccountIds); + return removed; +} /** * Is a transient failure streak the ONLY thing standing between this thread and its account? * @@ -1742,7 +281,7 @@ function isTransientHoldExpired(entry: ThreadAffinityEntry, now: number): boolea * that chance away. */ function isTransientHoldSpentForAccount(threadId: string, accountId: string, now: number): boolean { - const affinities = threadAccountMap.get(threadId); + const affinities = getThreadAffinityScopes(threadId); if (!affinities) return false; let matched = false; for (const entry of affinities.values()) { @@ -1784,431 +323,6 @@ function transientDetourAccount( : pickAlternateCodexAccount(config, entry.accountId, now, quotaScope, selectionOptions); } -/** Earliest future shared short/weekly reset; missing evidence and ties use usage order. */ -function pickResetFirstCodexAccount( - config: OcxConfig, - ids: readonly string[], - now: number, - selectionOptions?: CodexAccountUsabilityOptions, -): string | null { - const available = ids.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now)); - if (available.length === 0) return pickLowestUsageAmong(config, ids, selectionOptions, now); - let earliest = Number.POSITIVE_INFINITY; - let candidates: string[] = []; - for (const id of available) { - const quota = getAccountQuota(id); - const resets = [quota?.shortResetAt, quota?.weeklyResetAt] - .filter((reset): reset is number => typeof reset === "number" && Number.isFinite(reset)) - .map(resetAtToMs) - .filter(reset => reset > now); - const next = Math.min(...resets); - if (next < earliest) { - earliest = next; - candidates = [id]; - } else if (next === earliest) candidates.push(id); - } - return pickLowestUsageAmong(config, candidates, selectionOptions, now); -} - -/** - * Fill-first: keep selectable active under threshold; otherwise advance to the next - * eligible id in stable sorted order after the current active (wrapping). - */ -function pickFillFirstCodexAccount( - config: OcxConfig, - now: number, - quotaScope?: CodexQuotaScope, - selectionOptions?: CodexAccountUsabilityOptions, -): string | null { - const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions); - if (eligible.length === 0) return null; - - const active = getEffectiveActiveCodexAccountId(config); - if (active && eligible.includes(active) && hasCodexQuotaHeadroom(config, active, selectionOptions, now)) { - return active; - } - - return pickNextFillFirstCodexAccount(config, active ?? null, eligible, now, selectionOptions); -} - -/** Next eligible account in stable order after `afterId` (wrapping). */ -function pickNextFillFirstCodexAccount( - config: OcxConfig, - afterId: string | null, - eligible: readonly string[] = listEligibleCodexAccountIds(config, Date.now()), - now = Date.now(), - selectionOptions?: CodexAccountUsabilityOptions, -): string | null { - if (eligible.length === 0) return null; - const ordered = [...eligible].sort((a, b) => a.localeCompare(b)); - if (!afterId) { - // Prefer an under-threshold account when starting with no active cursor. - for (const id of ordered) { - if (hasCodexQuotaHeadroom(config, id, selectionOptions, now)) return id; - } - return ordered[0] ?? null; - } - - const allConfigured = [ - ...(isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions) || afterId === MAIN_CODEX_ACCOUNT_ID - ? [MAIN_CODEX_ACCOUNT_ID] - : []), - ...(config.codexAccounts ?? []).filter(account => !account.isMain).map(account => account.id), - ]; - const stableAll = [...new Set(allConfigured)].sort((a, b) => a.localeCompare(b)); - const startIdx = stableAll.indexOf(afterId); - if (startIdx < 0) { - for (const id of ordered) { - if (hasCodexQuotaHeadroom(config, id, selectionOptions, now)) return id; - } - return ordered[0] ?? null; - } - - // Skip successors that are also at/above threshold (known drained usage). - let fallback: string | null = null; - for (let step = 1; step <= stableAll.length; step++) { - const candidate = stableAll[(startIdx + step) % stableAll.length]!; - if (!eligible.includes(candidate)) continue; - if (!fallback) fallback = candidate; - if (hasCodexQuotaHeadroom(config, candidate, selectionOptions, now)) return candidate; - } - return fallback ?? ordered[0] ?? null; -} - -/** - * Unbound new-session pick for round-robin / fill-first. Returns null to fall through - * to the legacy quota path (or when the strategy is quota). - * - * When `commit` is true (resolve path), advances RR state. `commitSharedActive` - * and `commitAffinity` independently control the two cross-request side effects: - * model-scoped entitlement selection can bind a new task without replacing an - * existing task binding or global active choice. Preview remains a dry-run peek. - * - * Automatic strategy picks never sync-write config; only manual selection persists active. - * - * Known limitation (follow-up): when a subagent preview peeks an RR account and the request - * then falls back to a non-Codex provider, the ring is not reserved/committed. Prefer seeding - * the peeked account if that path becomes load-bearing. - */ -function pickUnboundStrategyAccount( - config: OcxConfig, - threadId: string | null, - now: number, - commit: boolean, - quotaScope?: CodexQuotaScope, - selectionOptions?: CodexAccountUsabilityOptions, - commitSharedActive = commit, - commitAffinity = commit, -): string | null { - const strategy = accountPoolStrategyForScope(config, quotaScope); - if (strategy === "quota") return null; - const poolKey = codexPoolKeyForScope(quotaScope); - - let picked: string | null = null; - if (strategy === "round-robin") { - const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions); - const limit = stickyLimitForConfig(config); - if (!commit) { - return peekRoundRobinAccount(poolKey, eligible, limit); - } - picked = pickRoundRobinAccount(poolKey, eligible, limit); - if (!picked) return null; - if (commitSharedActive) { - if (!isIndependentCodexQuotaScope(quotaScope) - && !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) { - rememberActiveCodexAccount(config, picked); - } - } - if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope); - notePoolRotationSuccess(poolKey, picked, limit); - return picked; - } - - if (strategy === "fill-first" || strategy === "reset-first") { - picked = strategy === "reset-first" - ? pickResetFirstCodexAccount(config, listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions), now, selectionOptions) - : pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions); - if (!picked) return null; - if (commitSharedActive) { - if (!isIndependentCodexQuotaScope(quotaScope) - && !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) { - rememberActiveCodexAccount(config, picked); - } - } - if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope); - return picked; - } - - return null; -} - -export function getPoolAccountPlan(config: OcxConfig, accountId: string): string | undefined { - if (accountId === MAIN_CODEX_ACCOUNT_ID) return getMainAccountPlan(); - return (config.codexAccounts ?? []) - .find(account => isSelectableCodexPoolAccount(account) && account.id === accountId)?.plan; -} - -/** Selection-only main routing must not lazily read the fenced native credential for its plan. */ -function getPoolAccountPlanForSelection( - config: OcxConfig, - accountId: string, - selectionOptions?: CodexAccountUsabilityOptions, -): string | undefined { - if (accountId === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) { - return undefined; - } - return getPoolAccountPlan(config, accountId); -} - -/** Shared routing state must ignore a request-scoped entitlement roster. */ -function sharedStateSelectionOptions( - selectionOptions?: CodexAccountUsabilityOptions, -): Pick< - CodexAccountUsabilityOptions, - "nativeMainSelectionOnly" | "isMainAccountTokenLive" -> | undefined { - if (!selectionOptions) return undefined; - return { - ...(selectionOptions.nativeMainSelectionOnly !== undefined - ? { nativeMainSelectionOnly: selectionOptions.nativeMainSelectionOnly } - : {}), - ...(selectionOptions.isMainAccountTokenLive - ? { isMainAccountTokenLive: selectionOptions.isMainAccountTokenLive } - : {}), - }; -} - -function pickLowerUsageAccount( - config: OcxConfig, - active: string, - activeUsage: number, - now: number, - quotaScope?: CodexQuotaScope, - selectionOptions?: CodexAccountUsabilityOptions, - skipFailoverReadyCandidates = false, -): string { - let best = active; - let bestUsage = activeUsage; - for (const id of getEligiblePoolAccounts( - config, - active, - now, - quotaScope, - selectionOptions, - skipFailoverReadyCandidates, - )) { - const usage = computeCodexUsageScore( - getAccountQuota(id), - getPoolAccountPlanForSelection(config, id, selectionOptions), - now, - ); - if (usage < bestUsage) { - best = id; - bestUsage = usage; - } - } - return best; -} - -/** Coolest account in an already-selected candidate list; first index wins ties. */ -function pickLowestUsageAmong( - config: OcxConfig, - ids: readonly string[], - selectionOptions?: CodexAccountUsabilityOptions, - now: number = Date.now(), -): string | null { - let best: string | null = null; - let bestUsage = Number.POSITIVE_INFINITY; - for (const id of ids) { - const usage = computeCodexUsageScore( - getAccountQuota(id), - getPoolAccountPlanForSelection(config, id, selectionOptions), - now, - ); - if (usage < bestUsage) { - best = id; - bestUsage = usage; - } - } - return best; -} - -export function pickLowestUsageCodexAccount( - config: OcxConfig, - excludeId?: string, - now = Date.now(), - quotaScope?: CodexQuotaScope, - selectionOptions?: CodexAccountUsabilityOptions, -): string | null { - return pickLowestUsageAmong( - config, - getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions), - selectionOptions, - now, - ); -} - -/** - * Strategy-aware alternate after a cooled/excluded account (same-request 429 retry - * and active promotion). Quota keeps lowest-usage; fill-first advances stable order; - * round-robin takes the next ring pick (caller should have noted the failure). - */ -export function pickAlternateCodexAccount( - config: OcxConfig, - excludeId: string, - now = Date.now(), - quotaScope?: CodexQuotaScope, - selectionOptions?: CodexAccountUsabilityOptions, -): string | null { - const strategy = accountPoolStrategyForScope(config, quotaScope); - // The exclusion is passed into eligibility rather than post-filtered off its - // result: when the excluded account is the only healthy member of the top - // tier, the tier walk must be free to descend instead of selecting that tier - // and then handing back an empty list. - if (strategy === "round-robin") { - const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions); - return pickRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config)); - } - if (strategy === "fill-first") { - const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions); - return pickNextFillFirstCodexAccount(config, excludeId, eligible, now, selectionOptions); - } - if (strategy === "reset-first") { - return pickResetFirstCodexAccount(config, getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions), now, selectionOptions); - } - return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope, selectionOptions); -} - -/** - * The account {@link pickAlternateCodexAccount} WOULD return, without returning it. - * - * Only the round-robin branch has a side effect -- `pickRoundRobinAccount` commits the pick and - * advances the ring -- so every other strategy delegates rather than growing a second copy of - * the selection rule that could drift from it. - * - * This exists because preview and resolve have to agree on the FIRST transient detour, not just - * on later ones. Preview feeds subagent model-availability scoring, so a preview that reported - * the bound account while resolve was about to serve from a cool sibling could retire a model - * over usage the request would never have touched. - */ -function peekAlternateCodexAccount( - config: OcxConfig, - excludeId: string, - now: number, - quotaScope?: CodexQuotaScope, - selectionOptions?: CodexAccountUsabilityOptions, -): string | null { - if (accountPoolStrategyForScope(config, quotaScope) === "round-robin") { - const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions); - return peekRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config)); - } - return pickAlternateCodexAccount(config, excludeId, now, quotaScope, selectionOptions); -} - -/** Effective active: automatic runtime cursor, else operator/persisted selection. */ -/** - * Unspent operator selections, keyed by pool scope. - * - * Codex has no account-side equivalent of the Anthropic `selectionRevision`, so staleness - * cannot be detected by comparing values: a pool-driven promote legitimately moves the - * persisted active account, and reading that as staleness would silently spend the - * operator's one-shot. Invalidation is keyed to the OPERATOR path instead — another manual - * selection, the account leaving the pool, or a successful dispatch on it. - */ -const manualPreference = new Map(); - -/** - * Spend the one-shot for a pool scope once a dispatch on that account actually succeeded. - * This is the Codex analogue of `commitAnthropicSelectionRouting`, which Codex lacks. - * - * Wiring this BEFORE the guard below is not a style choice. Measured: with the guard in - * place and no consume site, the first manual selection freezes the automatic cursor - * permanently and 15 of 69 rotation tests fail. - */ -function consumeManualPreference(accountId: string, poolKey: string): void { - if (manualPreference.get(poolKey) === accountId) manualPreference.delete(poolKey); -} - -/** - * Drop an account's preference in every scope. Pause and exclusion do not route through - * `resetCodexRoutingForManualSelection`, so without this a preference could outlive the - * account it names and keep suppressing the automatic cursor. - */ -function forgetManualPreference(accountId: string): void { - for (const [poolKey, preferred] of manualPreference) { - if (preferred === accountId) manualPreference.delete(poolKey); - } -} - -/** - * True while an unspent operator selection for this scope names a DIFFERENT account than - * the automatic pick about to be recorded. - * - * Callers pass their own scope: an independent quota scope keeps its own entry and must - * never read the shared one. The failover promote does NOT consult this — see its call - * site for why. - */ -function manualPreferenceBlocks(poolKey: string, accountId: string): boolean { - const preferred = manualPreference.get(poolKey); - return preferred !== undefined && preferred !== accountId; -} - -export function getEffectiveActiveCodexAccountId(config: OcxConfig): string | undefined { - return runtimeActiveCodexAccountId ?? config.activeCodexAccountId; -} - -/** - * Whether the account routing is currently on is there because an operator asked - * for it, rather than because a strategy landed on it. Surfaces read this instead - * of comparing the stored pin themselves, which would report a pin that a later - * automatic pick has already moved past. - */ -export function isEffectiveCodexAccountPinned(config: OcxConfig): boolean { - const pinned = pinnedCodexAccountId(config); - return pinned !== undefined && pinned === getEffectiveActiveCodexAccountId(config); -} - -/** - * Automatic strategy / failover cursor only — never mutates `config.activeCodexAccountId` - * so an unrelated `saveConfig` cannot persist transient rotation as operator selection. - */ -function rememberActiveCodexAccount(_config: OcxConfig, accountId: string): void { - runtimeActiveCodexAccountId = accountId; -} - -/** - * End the manual pin when routing moves to a different account. Returns whether - * the pin changed so the caller can fold it into a write it was already making. - */ -function releaseCodexAccountPinFor(config: OcxConfig, accountId: string): boolean { - const pinned = pinnedCodexAccountId(config); - if (pinned === undefined || pinned === accountId) return false; - clearCodexAccountPin(config); - return true; -} - -/** Persist operator (or quota-strategy) active selection to config + disk. */ -function setActiveCodexAccount(config: OcxConfig, accountId: string): void { - runtimeActiveCodexAccountId = undefined; - const releasedPin = releaseCodexAccountPinFor(config, accountId); - if (config.activeCodexAccountId === accountId && !releasedPin) return; - config.activeCodexAccountId = accountId; - saveConfigPreservingClaudeCode(config); -} - -/** Quota strategy persists; RR/fill-first keep a process-local cursor only. */ -function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void { - if (normalizeCodexAccountPoolStrategy(config.accountPoolStrategy) === "quota") { - setActiveCodexAccount(config, accountId); - return; - } - // Runtime-only, like the cursor itself: a caller that persists (pause, delete) - // saves this release with its own write; a transient failover does not, so the - // pin survives a restart that also clears the failure history behind it. - releaseCodexAccountPinFor(config, accountId); - rememberActiveCodexAccount(config, accountId); -} - /** * Reconcile the effective active account after an administrative exclusion such as pause. * The operator's persisted selection is cleared when it names the excluded account; quota @@ -2234,54 +348,12 @@ export function reconcileCodexActiveAfterExclusion( clearCodexAccountPin(config, excludedAccountId); if (!wasEffective) return getEffectiveActiveCodexAccountId(config) ?? null; - runtimeActiveCodexAccountId = undefined; + forgetRuntimeActiveCodexAccount(); const fallback = pickAlternateCodexAccount(config, excludedAccountId, now); if (fallback) promoteActiveCodexAccount(config, fallback); return fallback; } -function isUnknownUsage(usage: number): boolean { - return usage >= CODEX_UNKNOWN_USAGE_SCORE; -} - -/** - * Move an unbound request back up when a higher tier regains headroom — the - * weekly-reset case. Returns null when nothing should change. - * - * Downward moves are deliberately left to {@link applyQuotaAutoSwitch}: this only - * fires when the tier filter has already excluded `active`, and only toward a - * tier that strictly outranks it. Threads bound by affinity never reach here. - */ -function pickPriorityPreemption( - config: OcxConfig, - active: string, - now: number, - quotaScope?: CodexQuotaScope, - selectionOptions?: CodexAccountUsabilityOptions, -): string | null { - const eligible = getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions); - if (eligible.length === 0 || eligible.includes(active)) return null; - const pinned = pinnedCodexAccountId(config); - // A live pin already lowered the tier ceiling; never preempt past an explicit - // operator choice. Same liveness test the tier filter applies, so preview and - // resolve agree even before the pin is garbage-collected. - if ( - pinned !== undefined - && eligible.includes(pinned) - && hasCodexQuotaHeadroom(config, pinned, selectionOptions, now) - ) return null; - const priorityOf = codexAccountPriorityLookup(config); - if (priorityOf(eligible[0]!) <= priorityOf(active)) return null; - // Members without headroom are in the tier only because a sibling has some; - // picking one would hand the request straight back to a drained account. - return pickLowestUsageAmong( - config, - eligible.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now)), - selectionOptions, - now, - ); -} - /** * Release a pin whose account is durably drained. "Use this account now" ends * when the account crosses the auto-switch threshold or stops being selectable @@ -2316,107 +388,6 @@ function releaseDrainedCodexAccountPin( saveConfigPreservingClaudeCode(config); } -function applyQuotaAutoSwitch( - config: OcxConfig, - active: string, - now: number, - quotaScope?: CodexQuotaScope, - selectionOptions?: CodexAccountUsabilityOptions, - commitSharedSelection = true, -): string { - const threshold = config.autoSwitchThreshold ?? 80; - if (threshold <= 0) return active; - const quota = getAccountQuota(active); - const activeUsage = computeCodexUsageScore( - quota, - getPoolAccountPlanForSelection(config, active, selectionOptions), - now, - ); - // Unknown usage is not evidence that a user's explicit selection crossed the - // threshold. Wait for quota priming instead of rotating among guesses. - if (isUnknownUsage(activeUsage)) return active; - if (activeUsage < threshold) return active; - const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope, selectionOptions); - if (best !== active) { - if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)) { - setActiveCodexAccount(config, best); - } - return best; - } - - return active; -} - -function shouldFailover(config: OcxConfig, accountId: string, now: number): boolean { - const threshold = config.upstreamFailoverThreshold ?? 3; - if (threshold <= 0) return false; - dropSpentCredentialFailure(accountId); - const health = upstreamHealth.get(accountId); - if (health?.lastFailureAt && now - health.lastFailureAt > CODEX_FAILURE_WINDOW_MS) return false; - return !!health && health.consecutiveFailures >= threshold; -} - -function isHealthySharedCodexSelection( - config: OcxConfig, - accountId: string, - now: number, - quotaScope: CodexQuotaScope | undefined, - selectionOptions: CodexAccountUsabilityOptions | undefined, -): boolean { - return isCodexAccountSelectable(config, accountId, now, quotaScope, selectionOptions) - && hasCodexQuotaHeadroom(config, accountId, selectionOptions, now) - && !shouldFailover(config, accountId, now); -} - -function strategySelectionOptionsForModelDetour( - config: OcxConfig, - now: number, - quotaScope: CodexQuotaScope | undefined, - selectionOptions: CodexAccountUsabilityOptions | undefined, -): CodexAccountUsabilityOptions | undefined { - if (selectionOptions?.modelEligibleAccountIds === undefined) return selectionOptions; - const sharedSelectionOptions = sharedStateSelectionOptions(selectionOptions) ?? {}; - return { - ...selectionOptions, - modelEligibleAccountIds: new Set( - [...selectionOptions.modelEligibleAccountIds].filter(accountId => - isHealthySharedCodexSelection( - config, - accountId, - now, - quotaScope, - sharedSelectionOptions, - ) - ), - ), - }; -} - -function applyFailureFailover( - config: OcxConfig, - active: string, - now: number, - quotaScope?: CodexQuotaScope, - selectionOptions?: CodexAccountUsabilityOptions, - commitSharedSelection = true, -): string { - if (!shouldFailover(config, active, now)) return active; - const best = pickAlternateCodexAccount(config, active, now, quotaScope, selectionOptions); - if (best) { - // The scope still routes away from the failing account — that is this request's - // own decision — but an independent one must not persist a new shared active - // account. recordCodexUpstreamOutcome only suppresses the promotion it makes at - // the moment of the failure; the streak outlives the soft avoid, so a later - // scoped resolve reaches here with the streak still tripped and would otherwise - // move the shared cursor after all. - if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)) { - promoteActiveCodexAccount(config, best); - } - return best; - } - return active; -} - export function resolveCodexAccountForThread( threadId: string | null, config: OcxConfig, @@ -2462,7 +433,7 @@ function carriesQuotaRefusal(health: CodexUpstreamHealth | undefined): boolean { * quota group, so a spent Spark window still cannot displace the same thread's Terra binding. */ function hasUnrecoveredCodexQuotaRefusal(accountId: string, quotaScope?: CodexQuotaScope): boolean { - if (carriesQuotaRefusal(upstreamHealth.get(accountId))) return true; + if (carriesQuotaRefusal(getAccountHealth(accountId))) return true; return quotaScope !== undefined && carriesQuotaRefusal(scopedHealthFor(accountId, quotaScope)); } @@ -3144,6 +1115,7 @@ export function resolveCodexAccountForThreadDetailed( return { status: "selected", accountId: active, affinity: affinityAfterRelease(threadId, releaseReason) }; } + export function recordCodexUpstreamOutcome( config: OcxConfig, accountId: string | null, @@ -3159,7 +1131,7 @@ export function recordCodexUpstreamOutcome( } if (!accountId) return; const writerGeneration = meta.writerGeneration ?? captureConfigGeneration(); - if (writerGeneration < lastReconciledGeneration && !liveHealthAccountIds.has(accountId)) return; + if (!isHealthAccountAdmissible(accountId, writerGeneration)) return; const now = meta.now ?? Date.now(); const outcomeClass = classifyCodexUpstreamOutcome(outcome, meta.denial); // Reject retired quota evidence before stale-credential cleanup or any shared mutation. @@ -3204,12 +1176,12 @@ export function recordCodexUpstreamOutcome( if (Object.keys(retained).length > 1) setScopedHealth(accountId, quotaScope, retained); else deleteScopedHealth(accountId, quotaScope); } - const current = upstreamHealth.get(accountId); + const current = getAccountHealth(accountId); const cooldownUntil = getCodexAccountCooldownUntil(accountId, now); // A leased probe that is still on its own cooldown generation proves the // account recovered: clear the hard cooldown outright (#433). if (cooldownUntil && probeMayClearCooldown(current, meta)) { - upstreamHealth.delete(accountId); + deleteAccountHealth(accountId); return; } // Owning probe on a stale generation: the lease is done, but a newer 429 @@ -3221,7 +1193,7 @@ export function recordCodexUpstreamOutcome( if (failoverEnabled && current && current.consecutiveFailures >= 2) { const consecutiveSuccesses = (current.consecutiveSuccesses ?? 0) + 1; if (consecutiveSuccesses < 2) { - upstreamHealth.set(accountId, { + setAccountHealth(accountId, { ...base!, ...preserved, consecutiveSuccesses, @@ -3231,14 +1203,14 @@ export function recordCodexUpstreamOutcome( } // Level 1 clears immediately; escalated accounts need two consecutive healthy terminals. // Hard quota cooldown intentionally survives either recovery path. - if (cooldownUntil) upstreamHealth.set(accountId, { consecutiveFailures: 0, ...preserved }); - else upstreamHealth.delete(accountId); + if (cooldownUntil) setAccountHealth(accountId, { consecutiveFailures: 0, ...preserved }); + else deleteAccountHealth(accountId); return; } if (outcomeClass === "caller") { // A 4xx does not change account health, but it does conclude an in-flight // probe — otherwise the lease would never be handed back. - const current = upstreamHealth.get(accountId); + const current = getAccountHealth(accountId); const scopedProbe = meta.probeQuotaScope ? scopedHealthFor(accountId, meta.probeQuotaScope) : undefined; @@ -3246,7 +1218,7 @@ export function recordCodexUpstreamOutcome( setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now)); } if (ownsProbeLease(current, meta)) { - upstreamHealth.set(accountId, withProbeLeaseReleased(current!, now)); + setAccountHealth(accountId, withProbeLeaseReleased(current!, now)); } return; } @@ -3257,7 +1229,7 @@ export function recordCodexUpstreamOutcome( // it and must not happen (#914). Conclude any owned probe lease, record the // failure under the (provider, host) ledger when one is named, and leave // account health, thread affinity, and the active account untouched. - const current = upstreamHealth.get(accountId); + const current = getAccountHealth(accountId); const scopedProbe = meta.probeQuotaScope ? scopedHealthFor(accountId, meta.probeQuotaScope) : undefined; @@ -3265,7 +1237,7 @@ export function recordCodexUpstreamOutcome( setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now)); } if (ownsProbeLease(current, meta)) { - upstreamHealth.set(accountId, withProbeLeaseReleased(current!, now)); + setAccountHealth(accountId, withProbeLeaseReleased(current!, now)); } return; } @@ -3276,8 +1248,8 @@ export function recordCodexUpstreamOutcome( // Record the failure so routing stops preferring it, but do not mark it for // reauthentication and do not sweep its thread affinities: telling the user to // re-login is wrong advice that cannot fix a workspace grant. - upstreamHealth.set(accountId, { - consecutiveFailures: (upstreamHealth.get(accountId)?.consecutiveFailures ?? 0) + 1, + setAccountHealth(accountId, { + consecutiveFailures: (getAccountHealth(accountId)?.consecutiveFailures ?? 0) + 1, lastFailureStatus, lastFailureAt: now, }); @@ -3315,7 +1287,7 @@ export function recordCodexUpstreamOutcome( * Affinity sweeping needs no tag: an affinity entry already carries a credential generation and * self-invalidates on the next check, and re-adding swept entries would be a worse bug. */ - upstreamHealth.set(accountId, { + setAccountHealth(accountId, { consecutiveFailures: 1, lastFailureStatus, lastFailureAt: now, @@ -3324,7 +1296,7 @@ export function recordCodexUpstreamOutcome( ? { credentialFailureGeneration: meta.credentialGeneration } : {}), }); - quotaScopedHealth.delete(accountId); + deleteAllScopedHealth(accountId); // The reauth flag carries the same provenance, so a replacement landing after this call cannot // inherit a quarantine that was never about it. markAccountNeedsReauth(accountId, writerGeneration, meta.credentialGeneration); @@ -3385,13 +1357,13 @@ export function recordCodexUpstreamOutcome( if (scopedProbe && meta.probeQuotaScope && ownsProbeLease(scopedProbe, meta)) { setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now)); } - const prior = upstreamHealth.get(accountId); + const prior = getAccountHealth(accountId); // Every cooldown write bumps the generation so a probe issued against the // previous cooldown can no longer clear this one (#433). const cooldownGeneration = (prior?.cooldownGeneration ?? 0) + 1; // A failed probe concludes its lease; an unrelated 429 leaves the live probe alone. const ownsLease = ownsProbeLease(prior, meta); - upstreamHealth.set(accountId, { + setAccountHealth(accountId, { consecutiveFailures: 0, lastFailureStatus, lastFailureAt: now, @@ -3431,7 +1403,7 @@ export function recordCodexUpstreamOutcome( } // transient (connect_error / timeout / 5xx) - const current = upstreamHealth.get(accountId); + const current = getAccountHealth(accountId); const scopedProbe = meta.probeQuotaScope ? scopedHealthFor(accountId, meta.probeQuotaScope) : undefined; @@ -3457,7 +1429,7 @@ export function recordCodexUpstreamOutcome( now + escalationMs, ) : undefined; - upstreamHealth.set(accountId, { + setAccountHealth(accountId, { ...preservedCooldownFields(transientBase), consecutiveFailures, lastFailureStatus, diff --git a/src/codex/routing/active-account.ts b/src/codex/routing/active-account.ts new file mode 100644 index 0000000000..e53ef06b26 --- /dev/null +++ b/src/codex/routing/active-account.ts @@ -0,0 +1,194 @@ +import { saveConfigPreservingClaudeCode } from "../../config"; +import { clearCodexAccountPin, pinnedCodexAccountId } from "../account-priority"; +import { + POOL_KEY_CODEX, + normalizeCodexAccountPoolStrategy, + seedPoolRotationAccount, +} from "../pool-rotation"; +import type { OcxConfig } from "../../types"; +import { clearThreadAccountMap } from "./thread-affinity"; +import { + NATIVE_MODEL_QUOTA_SCOPES, + codexPoolKeyForScope, + deleteAccountHealth, + deleteScopedHealth, + getAccountHealth, + isIndependentCodexQuotaScope, + listScopedHealthEntries, + preservedCooldownFields, + setAccountHealth, + setScopedHealth, + type CodexUpstreamHealth, +} from "./health-store"; + +/** + * Process-local cursor for automatic RR/fill-first (and quota-429 when not + * sync-writing) picks. Keeps unrelated `saveConfig` from persisting transient + * rotation as the operator's `activeCodexAccountId`. Manual selection clears it + * so disk/`config.activeCodexAccountId` remains authoritative. + */ +let runtimeActiveCodexAccountId: string | undefined; + +/** Manual selection resets transient routing evidence without bypassing a real 429 cooldown. */ +export function resetCodexRoutingForManualSelection(accountId: string): void { + clearThreadAccountMap(); + // Manual selection is the operator source of truth — drop any automatic runtime cursor. + runtimeActiveCodexAccountId = undefined; + // Record the pick as an unspent one-shot on the SHARED scope only. An independent scope + // gets no entry on purpose: every write site the guard protects is already skipped for + // independent scopes, so an entry there would be state nothing reads — and state nothing + // reads is what the next reader mistakes for a rule. + // + // Seeding happens ONLY here. A pool-driven promote must never create or move a preference, + // or the pool would manufacture an operator intent nobody expressed. + manualPreference.set(POOL_KEY_CODEX, accountId); + // Seed the RR ring so the next unbound new session honors the manually selected account + // under round-robin (affinity-cleared threads / null threadId). Fill-first already follows + // config.activeCodexAccountId, which the caller persists before invoking this. + seedPoolRotationAccount(POOL_KEY_CODEX, accountId); + for (const scope of new Set(Object.values(NATIVE_MODEL_QUOTA_SCOPES))) { + if (isIndependentCodexQuotaScope(scope)) { + seedPoolRotationAccount(codexPoolKeyForScope(scope), accountId); + } + } + // Quota avoidance is a preference, like the soft avoid dropped above, and an operator naming + // this account has overruled it. The hard cooldown is the part that survives. + const overrule = (health: CodexUpstreamHealth) => { + const { quotaAvoidUntil: _avoid, ...retained } = preservedCooldownFields(health); + return retained; + }; + const current = getAccountHealth(accountId); + if (current) { + const retained = overrule(current); + if (Object.keys(retained).length === 0) deleteAccountHealth(accountId); + else setAccountHealth(accountId, { consecutiveFailures: 0, ...retained }); + } + // A reset-derived refusal records its avoidance on the SCOPED map and returns before the + // account-wide entry is written, so naming the account has to reach that map too. Stopping + // at `upstreamHealth` — and returning early when it holds nothing — overruled nothing in + // the case that produces the avoidance this function exists to overrule. + for (const [scope, health] of [...(listScopedHealthEntries(accountId))]) { + const retained = overrule(health); + if (Object.keys(retained).length === 0) deleteScopedHealth(accountId, scope); + else setScopedHealth(accountId, scope, { consecutiveFailures: 0, ...retained }); + } +} + +/** Effective active: automatic runtime cursor, else operator/persisted selection. */ +/** + * Unspent operator selections, keyed by pool scope. + * + * Codex has no account-side equivalent of the Anthropic `selectionRevision`, so staleness + * cannot be detected by comparing values: a pool-driven promote legitimately moves the + * persisted active account, and reading that as staleness would silently spend the + * operator's one-shot. Invalidation is keyed to the OPERATOR path instead — another manual + * selection, the account leaving the pool, or a successful dispatch on it. + */ +const manualPreference = new Map(); + +/** + * Spend the one-shot for a pool scope once a dispatch on that account actually succeeded. + * This is the Codex analogue of `commitAnthropicSelectionRouting`, which Codex lacks. + * + * Wiring this BEFORE the guard below is not a style choice. Measured: with the guard in + * place and no consume site, the first manual selection freezes the automatic cursor + * permanently and 15 of 69 rotation tests fail. + */ +export function consumeManualPreference(accountId: string, poolKey: string): void { + if (manualPreference.get(poolKey) === accountId) manualPreference.delete(poolKey); +} + +/** + * Drop an account's preference in every scope. Pause and exclusion do not route through + * `resetCodexRoutingForManualSelection`, so without this a preference could outlive the + * account it names and keep suppressing the automatic cursor. + */ +export function forgetManualPreference(accountId: string): void { + for (const [poolKey, preferred] of manualPreference) { + if (preferred === accountId) manualPreference.delete(poolKey); + } +} + +/** + * True while an unspent operator selection for this scope names a DIFFERENT account than + * the automatic pick about to be recorded. + * + * Callers pass their own scope: an independent quota scope keeps its own entry and must + * never read the shared one. The failover promote does NOT consult this — see its call + * site for why. + */ +export function manualPreferenceBlocks(poolKey: string, accountId: string): boolean { + const preferred = manualPreference.get(poolKey); + return preferred !== undefined && preferred !== accountId; +} + +export function getEffectiveActiveCodexAccountId(config: OcxConfig): string | undefined { + return runtimeActiveCodexAccountId ?? config.activeCodexAccountId; +} + +/** + * Whether the account routing is currently on is there because an operator asked + * for it, rather than because a strategy landed on it. Surfaces read this instead + * of comparing the stored pin themselves, which would report a pin that a later + * automatic pick has already moved past. + */ +export function isEffectiveCodexAccountPinned(config: OcxConfig): boolean { + const pinned = pinnedCodexAccountId(config); + return pinned !== undefined && pinned === getEffectiveActiveCodexAccountId(config); +} + +/** + * Automatic strategy / failover cursor only — never mutates `config.activeCodexAccountId` + * so an unrelated `saveConfig` cannot persist transient rotation as operator selection. + */ +export function rememberActiveCodexAccount(_config: OcxConfig, accountId: string): void { + runtimeActiveCodexAccountId = accountId; +} + +/** + * End the manual pin when routing moves to a different account. Returns whether + * the pin changed so the caller can fold it into a write it was already making. + */ +function releaseCodexAccountPinFor(config: OcxConfig, accountId: string): boolean { + const pinned = pinnedCodexAccountId(config); + if (pinned === undefined || pinned === accountId) return false; + clearCodexAccountPin(config); + return true; +} + +/** Persist operator (or quota-strategy) active selection to config + disk. */ +export function setActiveCodexAccount(config: OcxConfig, accountId: string): void { + runtimeActiveCodexAccountId = undefined; + const releasedPin = releaseCodexAccountPinFor(config, accountId); + if (config.activeCodexAccountId === accountId && !releasedPin) return; + config.activeCodexAccountId = accountId; + saveConfigPreservingClaudeCode(config); +} + +/** Quota strategy persists; RR/fill-first keep a process-local cursor only. */ +export function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void { + if (normalizeCodexAccountPoolStrategy(config.accountPoolStrategy) === "quota") { + setActiveCodexAccount(config, accountId); + return; + } + // Runtime-only, like the cursor itself: a caller that persists (pause, delete) + // saves this release with its own write; a transient failover does not, so the + // pin survives a restart that also clears the failure history behind it. + releaseCodexAccountPinFor(config, accountId); + rememberActiveCodexAccount(config, accountId); +} + +export function clearAllManualPreferences(): void { + manualPreference.clear(); +} + +export function forgetRuntimeActiveCodexAccount(): void { + runtimeActiveCodexAccountId = undefined; +} + +export function forgetRoutingPreferencesOutside(codexAccountIds: ReadonlySet): void { + for (const [poolKey, preferred] of manualPreference) { + if (codexAccountIds.has(preferred)) continue; + manualPreference.delete(poolKey); + } +} diff --git a/src/codex/routing/cooldown-math.ts b/src/codex/routing/cooldown-math.ts new file mode 100644 index 0000000000..123da5e3b1 --- /dev/null +++ b/src/codex/routing/cooldown-math.ts @@ -0,0 +1,275 @@ +import { + CODEX_EXHAUSTED_USAGE_PERCENT, + CODEX_UNKNOWN_USAGE_SCORE, + resetAtToMs, +} from "../quota"; +import { isThirtyDayOnlyCodexPlan } from "../plan"; +import type { CodexQuotaScope } from "./health-store"; + +export const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000; +export const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000; +/** + * A weekly/monthly quota `resetAt` announces when the window refreshes; it is not + * a "come back after this" directive like Retry-After. Plan quota routinely frees + * up long before the advertised reset, so cap reset-derived cooldowns far below + * the Retry-After ceiling (#433). + */ +export const CODEX_MAX_RESET_DERIVED_COOLDOWN_MS = 15 * 60_000; +/** + * Ceiling on quota-refusal avoidance. Generous enough to cover a full five-hour burst window, + * tight enough that a weekly or monthly reset four days out cannot take an account out of + * rotation for the {@link CODEX_MAX_QUOTA_COOLDOWN_MS} day the Retry-After ceiling allows. + */ +export const CODEX_MAX_QUOTA_AVOID_MS = 6 * 60 * 60_000; +/** Minimum gap between probe leases for one cooled-down account. */ +export const CODEX_QUOTA_PROBE_INTERVAL_MS = 5 * 60_000; +export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000; +/** + * How recently a 100% burst reading must have been OBSERVED to exclude an account when it + * carries no reset timestamp (#3425). Deliberately far tighter than the 6h disk-hydration + * horizon in `quota.ts`: shorter than any plausible five-hour burst window, so a persisted + * reading can never strand a recovered account, and long enough that a snapshot taken at + * admission is still fresh when selection reads it. + */ +export const TERMINAL_SHORT_WINDOW_FRESHNESS_MS = 5 * 60_000; +/** How long a transient failure keeps the account out of pool selection. */ +export const CODEX_TRANSIENT_SOFT_AVOID_MS = 30_000; +export const CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS = [ + CODEX_TRANSIENT_SOFT_AVOID_MS, + 2 * 60_000, + 10 * 60_000, + 30 * 60_000, +] as const; + +export type CodexUpstreamOutcome = number | "connect_error" | "timeout" | "connect_neutral"; +export type CodexUpstreamOutcomeClass = "success" | "credential" + | "workspace" | "quota" | "transient" | "caller" | "neutral" | "unknown"; +export type CodexCooldownSource = "retry-after" | "reset-derived" | "default"; + +export type CodexUpstreamOutcomeMeta = { + retryAfter?: string | null; + resetAt?: unknown | unknown[]; + now?: number; + /** (provider, host) ledger key for account-neutral reachability failures (#914). */ + hostKey?: string; + /** + * Upstream denial evidence for a 403. A workspace/entitlement denial means the CREDENTIAL + * is fine and the account simply cannot reach this workspace, so it must not be quarantined + * for reauthentication (#1789). Absent evidence keeps the historical credential handling. + */ + denial?: "workspace" | "entitlement"; + /** Stable transport code recorded alongside a neutral host failure. */ + lastFailureCode?: string; + /** Native model selected for this request; used only for confirmed scoped quotas. */ + modelId?: string; + /** When set, clears affinity for this thread immediately on transient failure. */ + threadId?: string | null; + /** + * Suppress Pool rotation and quota/transient affinity mutations for an account-qualified + * request. Credential failures still sweep stale affinities because reauthentication is + * account-wide. + */ + fixedAccount?: boolean; + /** + * Probe lease held by this request, when it was admitted through an active + * quota cooldown. Only the outcome carrying the current lease may clear the + * cooldown (#433). + */ + probeLeaseId?: string; + /** Scope of `probeLeaseId` when it was granted against a model-scoped cooldown. */ + probeQuotaScope?: CodexQuotaScope; + /** + * Already-chosen alternate for same-request 429 retry. When set, promotion + * reuses this account instead of calling {@link pickAlternateCodexAccount} + * again (which would advance a round-robin ring twice). + */ + promoteAccountId?: string; + /** Generation captured when this routed account was selected. */ + writerGeneration?: number; + /** + * Credential generation this request's bearer was read at. Distinct from + * `writerGeneration`, which tracks the config store. + * + * A 401 that arrives after the credential was already replaced is evidence about a + * token nobody is using any more, so it must not quarantine the replacement. Absent + * means the caller cannot supply lineage and the historical unfenced handling stands. + */ + credentialGeneration?: number; +}; + +export function computeCodexUsageScore(quota: { + weeklyPercent?: number; + monthlyPercent?: number; + shortPercent?: number; + shortResetAt?: number; + shortObservedAt?: number; +} | null, plan?: unknown, now: number = Date.now()): number { + if (!quota) return CODEX_UNKNOWN_USAGE_SCORE; + const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); + const longWindows = isThirtyDayOnlyCodexPlan(plan) + ? [quota.monthlyPercent] + : [quota.weeklyPercent, quota.monthlyPercent]; + const knownLong = longWindows.filter(finite); + // The short burst window only REFINES a known long-window position; it cannot stand in for + // one. A snapshot carrying just `shortPercent: 0` would otherwise score a flat 0 and make an + // account whose weekly/monthly usage is entirely unverified look like the emptiest in the + // pool, so `pickLowestUsageAmong` would send every request to it. Unknown has to stay + // unknown until a governing window is actually observed. + // + // A FULL burst window is the exception (#3029). It is not an optimistic guess about an + // unobserved window — it is a direct observation that the account cannot serve a request + // right now, whatever its monthly position turns out to be. Unknown-means-selectable is + // correct for uncertainty and wrong for a measured refusal: the account stays selected, + // `applyQuotaAutoSwitch` never fires, and the pool wedges on an exhausted credential. + if (knownLong.length === 0) { + return isTerminalShortWindow(quota, now) ? CODEX_EXHAUSTED_USAGE_PERCENT : CODEX_UNKNOWN_USAGE_SCORE; + } + const values = finite(quota.shortPercent) ? [...knownLong, quota.shortPercent] : knownLong; + return Math.max(...values); +} + +/** + * A short-only reading that proves the account is blocked NOW. + * + * Freshness is not optional. `getAccountQuota` performs no expiry check, partial updates + * carry a still-open short tuple forward, and disk hydration accepts a persisted reading for + * hours — so scoring 100 from `shortPercent` alone would keep excluding an account whose + * five-hour window has since reset. Merge no longer carries an elapsed shortResetAt, but an + * explicit incoming elapsed tuple is still stored, and a missing reset cannot be aged there. + * That is #3029 pointed the other way: the issue is that + * an exhausted account stays selected, and "a recovered account stays excluded" trades one + * unusable pool for another. + * + * A reading with no `shortResetAt` cannot be aged, so it stays unknown. The conservative + * direction here is the one that keeps an account selectable: a wrongly-selected account + * fails one request, while a wrongly-excluded one is invisible until someone reads the pool + * by hand. + * + * A missing reset can instead be aged by shortObservedAt (#3425). General updatedAt is not + * sufficient: credit-only updates preserve the old short tuple but advance that timestamp. + * Old disk snapshots without short-window provenance remain unknown. + */ +function isTerminalShortWindow( + quota: { shortPercent?: number; shortResetAt?: number; shortObservedAt?: number }, + now: number, +): boolean { + if (typeof quota.shortPercent !== "number" || !Number.isFinite(quota.shortPercent)) return false; + if (quota.shortPercent < CODEX_EXHAUSTED_USAGE_PERCENT) return false; + const resetAt = quota.shortResetAt; + if (typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt <= 0) { + const observedAt = quota.shortObservedAt; + if (typeof observedAt !== "number" || !Number.isFinite(observedAt)) return false; + const age = now - observedAt; + return age >= 0 && age <= TERMINAL_SHORT_WINDOW_FRESHNESS_MS; + } + // Seconds and milliseconds both reach storage, so the split lives in one place next to the + // merge that also ages a stored reset instant (`resetAtToMs`, src/codex/quota.ts). + return resetAtToMs(resetAt) > now; +} + +export function classifyCodexUpstreamOutcome( + outcome: CodexUpstreamOutcome, + denial?: "workspace" | "entitlement", +): CodexUpstreamOutcomeClass { + if (outcome === "connect_neutral") return "neutral"; + if (outcome === "connect_error" || outcome === "timeout") return "transient"; + if (!Number.isFinite(outcome)) return "unknown"; + if (outcome >= 200 && outcome < 300) return "success"; + // Explicit 3xx policy (#914): a redirect response is relayed as-is and is + // never account or host health evidence — it proves the host is reachable + // and says nothing about the credential. Relayed as the neutral class so a + // stray 3xx cannot increment an account's transient streak. + if (outcome >= 300 && outcome < 400) return "neutral"; + // 401 is always a credential problem. A 403 is only a credential problem when nothing + // tells us otherwise: a workspace/entitlement denial (#1789) means the credential is valid + // and the account simply lacks access here, so quarantining it for reauth is wrong advice. + // Absent denial evidence the historical mapping stands, so the change fails safe. + if (outcome === 403 && denial !== undefined) return "workspace"; + if (outcome === 401 || outcome === 403) return "credential"; + // 402 Payment Required is treated as quota exhaustion for pool cooldown/failover + // (same-request alternate retry records this outcome for the depleted account). + if (outcome === 429 || outcome === 402) return "quota"; + if (outcome >= 400 && outcome < 500) return "caller"; + if (outcome >= 500 && outcome < 600) return "transient"; + return "unknown"; +} + +function clampCooldownMs(ms: number): number { + return Math.min(Math.max(ms, 1), CODEX_MAX_QUOTA_COOLDOWN_MS); +} + +export function parseRetryAfterMs(value: string | null | undefined, now = Date.now()): number | undefined { + const text = value?.trim(); + if (!text) return undefined; + if (/^\d+(?:\.\d+)?$/.test(text)) { + const seconds = Number(text); + if (Number.isFinite(seconds) && seconds > 0) return clampCooldownMs(Math.ceil(seconds * 1000)); + } + const timestamp = Date.parse(text); + if (!Number.isFinite(timestamp)) return undefined; + const delay = timestamp - now; + return delay > 0 ? clampCooldownMs(delay) : undefined; +} + +function resetTimestampMs(value: unknown): number | undefined { + const numeric = typeof value === "number" + ? value + : typeof value === "string" && value.trim() !== "" + ? Number(value) + : undefined; + if (typeof numeric !== "number" || !Number.isFinite(numeric) || numeric <= 0) return undefined; + return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric; +} + +export function parseResetCooldownMs(resetAt: unknown | unknown[] | undefined, now = Date.now()): number | undefined { + const values = Array.isArray(resetAt) ? resetAt : [resetAt]; + let best: number | undefined; + for (const value of values) { + const timestamp = resetTimestampMs(value); + if (timestamp === undefined) continue; + const delay = timestamp - now; + if (delay <= 0) continue; + // A far-future reset must not pin the account for the full Retry-After + // ceiling: quota usually frees up well before the advertised window (#433). + const clamped = Math.min(clampCooldownMs(delay), CODEX_MAX_RESET_DERIVED_COOLDOWN_MS); + if (best === undefined || clamped < best) best = clamped; + } + return best; +} + +export function computeQuotaCooldown(meta: CodexUpstreamOutcomeMeta = {}): { + until: number; + source: CodexCooldownSource; +} { + const now = meta.now ?? Date.now(); + const retryAfterMs = parseRetryAfterMs(meta.retryAfter, now); + if (retryAfterMs !== undefined) return { until: now + retryAfterMs, source: "retry-after" }; + const resetCooldownMs = parseResetCooldownMs(meta.resetAt, now); + if (resetCooldownMs !== undefined) return { until: now + resetCooldownMs, source: "reset-derived" }; + return { until: now + CODEX_DEFAULT_QUOTA_COOLDOWN_MS, source: "default" }; +} + +/** + * When the pool should stop preferring an account after it refused on quota. + * + * The earliest window the refusal actually announced, bounded by {@link CODEX_MAX_QUOTA_AVOID_MS}, + * and never shorter than the cooldown the same refusal produced — a Retry-After directive that + * outlasts every announcement still governs. + */ +export function quotaAvoidUntilFor(meta: CodexUpstreamOutcomeMeta, now: number, cooldownUntil: number): number { + const values = Array.isArray(meta.resetAt) ? meta.resetAt : [meta.resetAt]; + let announced: number | undefined; + for (const value of values) { + const timestamp = resetTimestampMs(value); + if (timestamp === undefined) continue; + const delay = timestamp - now; + if (delay <= 0) continue; + const until = now + Math.min(delay, CODEX_MAX_QUOTA_AVOID_MS); + if (announced === undefined || until < announced) announced = until; + } + return Math.max(cooldownUntil, announced ?? 0); +} + +export function computeQuotaCooldownUntil(meta: CodexUpstreamOutcomeMeta = {}): number { + return computeQuotaCooldown(meta).until; +} diff --git a/src/codex/routing/health-store.ts b/src/codex/routing/health-store.ts new file mode 100644 index 0000000000..9c0d922b97 --- /dev/null +++ b/src/codex/routing/health-store.ts @@ -0,0 +1,402 @@ +import { isCodexAccountGenerationLive } from "../account-store"; +import { NATIVE_RESERVE_MODEL } from "../catalog/native-models"; +import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; +import { POOL_KEY_CODEX } from "../pool-rotation"; +import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import type { OcxConfig } from "../../types"; +import type { CodexCooldownSource } from "./cooldown-math"; + +export type CodexUpstreamHealth = { + consecutiveFailures: number; + /** Consecutive healthy terminals observed while recovering from escalation level 2+. */ + consecutiveSuccesses?: number; + lastFailureStatus?: number; + lastFailureAt?: number; + /** Hard cooldown (quota 429). Survives a later 2xx; blocks auth + selection. */ + cooldownUntil?: number; + /** + * How long a quota refusal keeps selection away from this account (or this native quota + * group), as opposed to how long it is hard-blocked. + * + * The two are deliberately different lengths. {@link CODEX_MAX_RESET_DERIVED_COOLDOWN_MS} + * caps the hard cooldown at 15 minutes because a reset announcement is advisory and plan + * quota usually frees up before it — an account must stay reachable so the pool can find + * that out (#433). The window the refusal announced is not 15 minutes, though, so once the + * cooldown lapses the account is selectable again while its burst window is still spent, + * and the strategy picks it straight back: this proxy reads a weekly bar a burst limit never + * touches, so a refused account still scores as the coolest in the pool. Every request then + * earns the same 429 until the process restarts, which is the only thing that drops this map. + * + * So the announcement governs avoidance and the cap still governs blocking. Avoidance is soft + * in the {@link softAvoidUntil} sense: it reorders the pool and releases a bound thread, and + * the last-resort paths still reach the account when nothing else can serve, so one pessimistic + * announcement cannot stall routing. + */ + quotaAvoidUntil?: number; + /** When the current cooldown was recorded; origin of the probe interval clock. */ + cooldownSince?: number; + /** + * What produced the cooldown. An explicit Retry-After is a literal retry + * directive and is never probed; a quota resetAt only announces a window + * refresh, so it may be probed early (#433). + */ + cooldownSource?: CodexCooldownSource; + /** + * Bumped on every cooldown write. A probe lease records the generation it was + * issued for so a lease cannot clear a cooldown that a later 429 replaced. + */ + cooldownGeneration?: number; + /** + * Identity of the in-flight probe. A cooled-down account sends no traffic, so + * no organic 2xx can prove recovery; only the outcome carrying this id may + * clear the cooldown. + */ + probeLeaseId?: string; + /** Cooldown generation at the moment the lease was granted. */ + probeLeaseGeneration?: number; + /** Last probe grant or conclusion; paces the probe interval. */ + lastProbeAt?: number; + /** + * Soft avoid after connect_error / timeout / transient 5xx. Cleared on 2xx. + * Blocks pool selection + thread affinity reuse so a sticky session can leave a + * flaky account without throwing CodexAccountCooldownError (hard-only). + */ + softAvoidUntil?: number; + /** + * Credential generation a 401/403 quarantine was derived from (#2892 gap 4). + * + * Provenance lives ON the entry rather than in a side map keyed by account id. A side map spends + * "whatever health is current when the old credential is found dead", which deletes a later + * unrelated entry: a G1 401, then a G2 save, then a genuine G2 503 would lose the 503. Only the + * entry that carries this field can be spent, and any later write simply replaces it. + */ + credentialFailureGeneration?: number; +}; + +const upstreamHealth = new Map(); +/** + * Reset-derived 429s can describe a quota owned by one native model family, + * rather than the whole ChatGPT account. Keep those advisory cooldowns apart + * from account-wide Retry-After/default throttles and transient health. + */ +const quotaScopedHealth = new Map>(); +/** + * Spend a credential-failure health entry whose credential no longer exists (#2892 gap 4). + * + * A 401/403 describes one CREDENTIAL, not an account, and a replacement can land at any point after + * the outcome is recorded — so re-reading the store inside `recordCodexUpstreamOutcome` narrows the + * window without closing it. The reader decides instead, and it may only spend an entry that + * actually carries credential provenance: a later transient or quota write replaces the entry and + * with it the tag, so this can never delete evidence that belongs to a different failure. + */ +export function dropSpentCredentialFailure(accountId: string): void { + const health = upstreamHealth.get(accountId); + const generation = health?.credentialFailureGeneration; + if (health === undefined || generation === undefined) return; + if (isCodexAccountGenerationLive(accountId, generation)) return; + upstreamHealth.delete(accountId); +} +let lastReconciledGeneration = 0; +let liveHealthAccountIds = new Set(); + +/** + * Native Codex quota groups known to be independent upstream. Keep the mapping + * deliberately conservative: unlisted models share the normal native group. + * Add a new explicit group here only when its independent upstream quota is + * confirmed, so shared limits never receive cross-model bypasses. + */ +export type CodexQuotaScope = "shared" | "reserve"; + + +export const NATIVE_MODEL_QUOTA_SCOPES: Readonly> = { + [NATIVE_RESERVE_MODEL]: "reserve", +}; + +export function codexQuotaScopeForModel(modelId: string | undefined): CodexQuotaScope | undefined { + if (!modelId?.trim()) return undefined; + return NATIVE_MODEL_QUOTA_SCOPES[modelId.trim().toLowerCase()] ?? "shared"; +} + +/** Independent quota groups must not mutate the shared active-account cursor. */ +export function isIndependentCodexQuotaScope(quotaScope?: CodexQuotaScope): boolean { + return quotaScope !== undefined && quotaScope !== "shared"; +} + +export function codexPoolKeyForScope(quotaScope?: CodexQuotaScope): string { + return isIndependentCodexQuotaScope(quotaScope) ? `${POOL_KEY_CODEX}:${quotaScope}` : POOL_KEY_CODEX; +} + +export function listLiveCodexAccountIds(config: OcxConfig): ReadonlySet { + const ids = new Set((config.codexAccounts ?? []).map(account => account.id)); + const openai = config.providers.openai; + if (openai && openai.disabled !== true && isCanonicalOpenAiForwardProvider(openai)) { + ids.add(MAIN_CODEX_ACCOUNT_ID); + } + return ids; +} + +export function getCodexUpstreamHealth( + accountId: string, +): CodexUpstreamHealth | null { + dropSpentCredentialFailure(accountId); + return upstreamHealth.get(accountId) ?? null; +} + +export function scopedHealthFor(accountId: string, scope: CodexQuotaScope): CodexUpstreamHealth | undefined { + return quotaScopedHealth.get(accountId)?.get(scope); +} + +export function setScopedHealth(accountId: string, scope: CodexQuotaScope, health: CodexUpstreamHealth): void { + let scopes = quotaScopedHealth.get(accountId); + if (!scopes) { + scopes = new Map(); + quotaScopedHealth.set(accountId, scopes); + } + scopes.set(scope, health); +} + +export function deleteScopedHealth(accountId: string, scope: CodexQuotaScope): void { + const scopes = quotaScopedHealth.get(accountId); + if (!scopes) return; + scopes.delete(scope); + if (scopes.size === 0) quotaScopedHealth.delete(accountId); +} + +/** Live quota-refusal avoidance for an account, including the lane the request belongs to. */ +function codexQuotaAvoidUntil( + accountId: string, + quotaScope: CodexQuotaScope | undefined, + now: number, +): number | null { + const live = (value: number | undefined): number | null => + typeof value === "number" && Number.isFinite(value) && value > now ? value : null; + const account = live(upstreamHealth.get(accountId)?.quotaAvoidUntil); + const scoped = quotaScope === undefined + ? null + : live(scopedHealthFor(accountId, quotaScope)?.quotaAvoidUntil); + if (account === null) return scoped; + return scoped === null ? account : Math.max(account, scoped); +} + +export function isCodexQuotaAvoided( + accountId: string, + quotaScope: CodexQuotaScope | undefined, + now: number, +): boolean { + return codexQuotaAvoidUntil(accountId, quotaScope, now) !== null; +} + +/** + * Hard-cooldown bookkeeping that ordinary success/transient transitions rebuild + * their health object from. Dropping these would let one late unrelated response + * erase a Retry-After source, a cooldown generation, or someone else's live probe. + */ +export function preservedCooldownFields(health: CodexUpstreamHealth | undefined): Partial { + if (!health) return {}; + // `credentialFailureGeneration` is provenance for ONE credential failure, so it must not survive + // into a later transient or quota entry — otherwise that entry inherits the tag and gets spent + // when the old credential dies, deleting evidence that was never about it (#2892 gap 4 review). + const { + consecutiveFailures: _f, consecutiveSuccesses: _s, lastFailureStatus: _st, lastFailureAt: _at, + softAvoidUntil: _sa, credentialFailureGeneration: _cg, ...cooldownFields + } = health; + return cooldownFields; +} + +export function getCodexAccountCooldownUntil(accountId: string, now = Date.now()): number | null { + const cooldownUntil = upstreamHealth.get(accountId)?.cooldownUntil; + return typeof cooldownUntil === "number" && Number.isFinite(cooldownUntil) && cooldownUntil > now ? cooldownUntil : null; +} + +/** Read-only cooldown snapshot for shared OAuth health projection (no write side effects). */ +export function getCodexAccountHealthSnapshot(accountId: string, now = Date.now()): { + cooldownUntil?: number; + cooldownSource?: CodexCooldownSource; +} | null { + const cooldownUntil = getCodexAccountCooldownUntil(accountId, now); + if (cooldownUntil === null) return null; + const source = upstreamHealth.get(accountId)?.cooldownSource; + return { + cooldownUntil, + ...(source ? { cooldownSource: source } : {}), + }; +} + +/** + * Read the cooldown relevant to a routed native model. Account-wide cooldowns + * (Retry-After/default) always win; reset-derived scoped state applies only to + * its confirmed quota group. + */ +export function getCodexQuotaHealthSnapshot( + accountId: string, + quotaScope: CodexQuotaScope | undefined, + now = Date.now(), +): { + cooldownUntil?: number; + cooldownSource?: CodexCooldownSource; + quotaScope?: CodexQuotaScope; +} | null { + const account = getCodexAccountHealthSnapshot(accountId, now); + if (account) return account; + if (!quotaScope) return null; + const scoped = scopedHealthFor(accountId, quotaScope); + const cooldownUntil = scoped?.cooldownUntil; + if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return null; + return { + cooldownUntil, + ...(scoped?.cooldownSource ? { cooldownSource: scoped.cooldownSource } : {}), + quotaScope, + }; +} + +export function isCodexAccountInCooldown(accountId: string, now = Date.now()): boolean { + return getCodexAccountCooldownUntil(accountId, now) !== null; +} + +/** + * Manually lift a hard quota cooldown without touching failure history. + * + * Injected Codex routing makes this proxy the ONLY model path for Codex Desktop, so a + * cooldown that outlives the real upstream limit reads to the user as "the whole app is + * broken" with no escape but editing config.toml. This is that escape hatch. + * + * Deliberately narrow: + * - Failure counters and softAvoid survive. Clearing a cooldown says "the quota window + * moved", not "this account is healthy"; failover must keep its knowledge. + * - Dropping `probeLeaseId` is what stops a stale in-flight probe from later "proving" + * recovery against a NEWER cooldown: {@link ownsProbeLease} needs the id to match. + * `cooldownGeneration` is preserved and bumped as redundancy only — a fresh 429 already + * bumps it in {@link recordCodexUpstreamOutcome}, so the bump here is not load-bearing + * today and is kept so the invariant survives a future change that retains the lease. + * + * Returns false when the account carried neither a live cooldown nor a live avoidance window. + * The window outlives the cooldown by design — the cooldown caps at fifteen minutes and the + * window runs up to six hours — so the moment an operator actually reaches for this escape + * hatch is usually after the cooldown lapsed and only the window is still keeping the account + * out of rotation. Refusing to look at the window then would leave the hatch shut in the one + * case it exists for. + */ +export function clearCodexAccountCooldown(accountId: string, now = Date.now()): boolean { + const clear = (health: CodexUpstreamHealth): CodexUpstreamHealth | null => { + const cooldownUntil = health.cooldownUntil; + const liveCooldown = typeof cooldownUntil === "number" && Number.isFinite(cooldownUntil) && cooldownUntil > now; + const avoidUntil = health.quotaAvoidUntil; + const liveAvoidance = typeof avoidUntil === "number" && Number.isFinite(avoidUntil) && avoidUntil > now; + if (!liveCooldown && !liveAvoidance) return null; + const { + cooldownUntil: _until, + cooldownSince: _since, + cooldownSource: _source, + probeLeaseId: _leaseId, + probeLeaseGeneration: _leaseGeneration, + // Same reasoning as the probe recovery above: "the quota window moved" is a statement + // about the whole refusal, so the avoidance it announced goes with the block it + // produced. Keeping it would leave this escape hatch not escaping, because selection + // would still pass over the account for as long as the announced window runs. + quotaAvoidUntil: _avoid, + ...rest + } = health; + return { + ...rest, + cooldownGeneration: (health.cooldownGeneration ?? 0) + 1, + lastProbeAt: now, + }; + }; + + let cleared = false; + const accountHealth = upstreamHealth.get(accountId); + if (accountHealth) { + const next = clear(accountHealth); + if (next) { + upstreamHealth.set(accountId, next); + cleared = true; + } + } + for (const [scope, health] of quotaScopedHealth.get(accountId) ?? []) { + const next = clear(health); + if (next) { + setScopedHealth(accountId, scope, next); + cleared = true; + } + } + return cleared; +} + +export function getCodexAccountSoftAvoidUntil(accountId: string, now = Date.now()): number | null { + const softAvoidUntil = upstreamHealth.get(accountId)?.softAvoidUntil; + return typeof softAvoidUntil === "number" && Number.isFinite(softAvoidUntil) && softAvoidUntil > now + ? softAvoidUntil + : null; +} + +export function isCodexAccountSoftAvoided(accountId: string, now = Date.now()): boolean { + return getCodexAccountSoftAvoidUntil(accountId, now) !== null; +} + +/** + * Closed package-internal accessors for the account-wide health maps. Selection, + * the probe lease, and the active cursor mutate health only through these; the + * Map bindings themselves never leave this module. + */ +export function getAccountHealth(accountId: string): CodexUpstreamHealth | undefined { + return upstreamHealth.get(accountId); +} + +export function setAccountHealth(accountId: string, health: CodexUpstreamHealth): void { + upstreamHealth.set(accountId, health); +} + +export function deleteAccountHealth(accountId: string): void { + upstreamHealth.delete(accountId); +} + +export function listScopedHealthEntries(accountId: string): Array<[CodexQuotaScope, CodexUpstreamHealth]> { + return [...(quotaScopedHealth.get(accountId) ?? [])]; +} + +export function deleteAllScopedHealth(accountId: string): void { + quotaScopedHealth.delete(accountId); +} + +export function isHealthAccountAdmissible(accountId: string, writerGeneration: number): boolean { + return writerGeneration >= lastReconciledGeneration || liveHealthAccountIds.has(accountId); +} + +export function isHealthGenerationReconciled(generation: number): boolean { + return generation <= lastReconciledGeneration; +} + +export function pruneHealthAccountsForContext(codexAccountIds: ReadonlySet): number { + let removed = 0; + for (const accountId of upstreamHealth.keys()) { + if (codexAccountIds.has(accountId)) continue; + upstreamHealth.delete(accountId); + removed += 1; + } + for (const accountId of quotaScopedHealth.keys()) { + if (codexAccountIds.has(accountId)) continue; + quotaScopedHealth.delete(accountId); + removed += 1; + } + return removed; +} + +export function commitHealthReconcile(generation: number, codexAccountIds: ReadonlySet): void { + liveHealthAccountIds = new Set(codexAccountIds); + lastReconciledGeneration = generation; +} + +export function clearUpstreamHealthState(): void { + upstreamHealth.clear(); + quotaScopedHealth.clear(); +} + +export function resetHealthReconcileState(): void { + lastReconciledGeneration = 0; + liveHealthAccountIds = new Set(); +} + +export function deleteAllHealthForAccount(accountId: string): void { + upstreamHealth.delete(accountId); + quotaScopedHealth.delete(accountId); +} diff --git a/src/codex/routing/probe-lease.ts b/src/codex/routing/probe-lease.ts new file mode 100644 index 0000000000..0ae865ac47 --- /dev/null +++ b/src/codex/routing/probe-lease.ts @@ -0,0 +1,358 @@ +import { randomUUID } from "node:crypto"; +import { isCodexAccountGenerationLive, readCodexAccountRecord, type CodexRefreshProvenance } from "../account-store"; +import { isCodexAccountPaused } from "../account-pause"; +import { isSelectableCodexPoolAccount } from "../account-id"; +import { isAccountNeedsReauth } from "../account-runtime-state"; +import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; +import type { OcxConfig } from "../../types"; +import { CODEX_QUOTA_PROBE_INTERVAL_MS, type CodexUpstreamOutcomeMeta } from "./cooldown-math"; +import { + deleteScopedHealth, + getAccountHealth, + listScopedHealthEntries, + scopedHealthFor, + setAccountHealth, + setScopedHealth, + type CodexQuotaScope, + type CodexUpstreamHealth, +} from "./health-store"; + +export type CodexQuotaRecoveryProbeClaim = { + accountId: string; + scope?: CodexQuotaScope; + leaseId: string; + cooldownGeneration: number; + credentialGeneration: number; + /** Claim-time `replacedAt`; unchanged after a probe-owned refresh, stamped on external replacement. */ + credentialReplacedAt?: number; +}; + +export type CodexQuotaRecoveryProbeProof = { + credentialGeneration?: number; +}; + +/** + * Grant at most one probe lease per interval for a cooled-down account. + * + * A cooled-down account is short-circuited locally, so it never sends traffic and + * no organic 2xx can prove that upstream quota recovered — the cooldown can only + * end by expiry or a proxy restart (#433). Releasing a single probe breaks that + * deadlock. Explicit Retry-After cooldowns are excluded: those are literal retry + * directives, not window announcements. + * + * Returns the lease id, or null when no probe may go out right now. + */ +export function tryAcquireCodexQuotaProbeLease(accountId: string, now = Date.now()): string | null { + if (!canAcquireCodexQuotaProbeLease(accountId, now)) return null; + const health = getAccountHealth(accountId)!; + const probeLeaseId = randomUUID(); + setAccountHealth(accountId, { + ...health, + probeLeaseId, + probeLeaseGeneration: health.cooldownGeneration ?? 0, + lastProbeAt: now, + }); + return probeLeaseId; +} + +/** Side-effect-free check mirroring {@link tryAcquireCodexQuotaProbeLease} eligibility. */ +export function canAcquireCodexQuotaProbeLease(accountId: string, now = Date.now()): boolean { + return canAcquireQuotaProbeLease(getAccountHealth(accountId), now); +} + +function canAcquireQuotaProbeLease(health: CodexUpstreamHealth | undefined, now: number): boolean { + if (!health) return false; + const cooldownUntil = health.cooldownUntil; + if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return false; + if (health.cooldownSource === "retry-after") return false; + if (health.probeLeaseId !== undefined) return false; + const origin = health.lastProbeAt ?? health.cooldownSince ?? cooldownUntil; + return now - origin >= CODEX_QUOTA_PROBE_INTERVAL_MS; +} + +/** + * Claim due reset-derived cooldown probes without consulting account selection. + * Added Pool credentials only; owned main usage recovery is handled separately. + */ +export function claimDueCodexQuotaRecoveryProbes( + config: OcxConfig, + limit: number, + now = Date.now(), +): CodexQuotaRecoveryProbeClaim[] { + const boundedLimit = Math.max(0, Math.floor(limit)); + if (boundedLimit === 0) return []; + const candidates: Array<{ + accountId: string; + scope?: CodexQuotaScope; + health: CodexUpstreamHealth; + credentialGeneration: number; + credentialReplacedAt?: number; + order: number; + }> = []; + for (const [order, account] of (config.codexAccounts ?? []).entries()) { + if (!isSelectableCodexPoolAccount(account) + || isCodexAccountPaused(config, account.id) + || isAccountNeedsReauth(account.id)) continue; + const record = readCodexAccountRecord(account.id); + if (!record?.credential || record.deletedAt != null) continue; + const due = [ + { scope: undefined, health: getAccountHealth(account.id) }, + ...[...(listScopedHealthEntries(account.id))].map(([scope, health]) => ({ scope, health })), + ].filter((entry): entry is { scope?: CodexQuotaScope; health: CodexUpstreamHealth } => + // Generic WHAM evidence can recover only ordinary quota, never Reserve. + // Do not spend this account's one claim per pass on an independent scope and + // delay the shared scope that the response can actually recover. + (entry.scope === undefined || entry.scope === "shared") + && entry.health?.cooldownSource === "reset-derived" + && canAcquireQuotaProbeLease(entry.health, now)) + .sort((a, b) => + (a.health.lastProbeAt ?? a.health.cooldownSince ?? 0) + - (b.health.lastProbeAt ?? b.health.cooldownSince ?? 0)); + const candidate = due[0]; + if (candidate) candidates.push({ + accountId: account.id, + ...(candidate.scope ? { scope: candidate.scope } : {}), + health: candidate.health, + credentialGeneration: record.generation, + ...(record.replacedAt !== undefined ? { credentialReplacedAt: record.replacedAt } : {}), + order, + }); + } + candidates.sort((a, b) => { + const age = (a.health.lastProbeAt ?? a.health.cooldownSince ?? 0) + - (b.health.lastProbeAt ?? b.health.cooldownSince ?? 0); + return age || a.order - b.order; + }); + return candidates.slice(0, boundedLimit).map(candidate => { + const leaseId = randomUUID(); + const next = { + ...candidate.health, + probeLeaseId: leaseId, + probeLeaseGeneration: candidate.health.cooldownGeneration ?? 0, + lastProbeAt: now, + }; + if (candidate.scope) setScopedHealth(candidate.accountId, candidate.scope, next); + else setAccountHealth(candidate.accountId, next); + return { + accountId: candidate.accountId, + ...(candidate.scope ? { scope: candidate.scope } : {}), + leaseId, + cooldownGeneration: candidate.health.cooldownGeneration ?? 0, + credentialGeneration: candidate.credentialGeneration, + ...(candidate.credentialReplacedAt !== undefined + ? { credentialReplacedAt: candidate.credentialReplacedAt } + : {}), + }; + }); +} + +type CooldownRecoveryLease = Pick; + +export type ManualResetCooldownClaim = + | { kind: "pool"; probe: CodexQuotaRecoveryProbeClaim } + | { kind: "main"; probe: CooldownRecoveryLease }; + +function manualResetAccountEligible(config: OcxConfig, accountId: string): boolean { + return !isCodexAccountPaused(config, accountId) && !isAccountNeedsReauth(accountId) + && (accountId === MAIN_CODEX_ACCOUNT_ID + || (config.codexAccounts ?? []).some(account => account.id === accountId && isSelectableCodexPoolAccount(account))); +} + +/** Explicit reset bypasses probe pacing, never another owner's lease or quota scope. */ +export function claimManualResetCooldowns( + config: OcxConfig, + accountId: string, + now = Date.now(), + expectedPoolGeneration?: number, +): ManualResetCooldownClaim[] { + if (!manualResetAccountEligible(config, accountId)) return []; + const record = accountId === MAIN_CODEX_ACCOUNT_ID ? undefined : readCodexAccountRecord(accountId); + if (accountId !== MAIN_CODEX_ACCOUNT_ID && (!record?.credential || record.deletedAt != null)) return []; + if (record && expectedPoolGeneration !== undefined && record.generation !== expectedPoolGeneration) return []; + const claims: ManualResetCooldownClaim[] = []; + for (const scope of [undefined, "shared"] as const) { + const health = scope ? scopedHealthFor(accountId, scope) : getAccountHealth(accountId); + if (!health || health.cooldownSource !== "reset-derived" || health.probeLeaseId !== undefined + || !Number.isFinite(health.cooldownUntil) || !(health.cooldownUntil! > now)) continue; + const leaseId = randomUUID(); + const cooldownGeneration = health.cooldownGeneration ?? 0; + const next = { ...health, probeLeaseId: leaseId, probeLeaseGeneration: cooldownGeneration, lastProbeAt: now }; + if (scope) setScopedHealth(accountId, scope, next); + else setAccountHealth(accountId, next); + const probe = { accountId, scope, leaseId, cooldownGeneration }; + claims.push(record ? { kind: "pool", probe: { + ...probe, credentialGeneration: record.generation, credentialReplacedAt: record.replacedAt, + } } : { kind: "main", probe }); + } + return claims; +} + +export type ManualResetRefreshLineage = Readonly<{ + fromGeneration: number; + toGeneration: number; + provenance: CodexRefreshProvenance; +}>; + +type ManualResetQuotaProof = CodexQuotaRecoveryProbeProof & { + refreshLineage?: ManualResetRefreshLineage; +}; + +/** Main proof is checked by the already-owned auth operation, never by a Pool record. */ +export function settleManualResetCooldown( + config: OcxConfig, + claim: ManualResetCooldownClaim, + recovered: boolean, + proof: ManualResetQuotaProof = {}, + now = Date.now(), +): boolean { + if (!recovered) return settleCooldownRecoveryLease(claim.probe, false, now); + const eligible = manualResetAccountEligible(config, claim.probe.accountId); + if (claim.kind === "main") return settleCooldownRecoveryLease(claim.probe, eligible, now); + const lineage = proof.refreshLineage; + // Equal wall-clock replacement stamps do not establish ancestry. Manual +1 + // recovery additionally needs the actual forced-refresh result for this edge. + const ownedGeneration = proof.credentialGeneration === claim.probe.credentialGeneration + || (proof.credentialGeneration === claim.probe.credentialGeneration + 1 + && lineage?.fromGeneration === claim.probe.credentialGeneration + && lineage.toGeneration === proof.credentialGeneration + && (lineage.provenance === "self-refresh" || lineage.provenance === "joined-lineage")); + return settleCodexQuotaRecoveryProbe(claim.probe, eligible && ownedGeneration, proof, now); +} + +/** Settle one background recovery claim without mutating account-wide outcome state. */ +export function settleCodexQuotaRecoveryProbe( + claim: CodexQuotaRecoveryProbeClaim, + recovered: boolean, + proof: CodexQuotaRecoveryProbeProof, + now = Date.now(), +): boolean { + const health = claim.scope + ? scopedHealthFor(claim.accountId, claim.scope) + : getAccountHealth(claim.accountId); + if (!health || health.probeLeaseId !== claim.leaseId) return false; + const currentRecord = readCodexAccountRecord(claim.accountId); + const proofGeneration = proof.credentialGeneration; + // A probe-owned token refresh (getValidCodexToken) advances the credential generation by + // exactly one while preserving `replacedAt`; an external credential replacement bumps the + // generation too but stamps a fresh `replacedAt`. Accept the +1 transition only when the + // claim-time lineage is intact AND the generation the fresh quota was proven under is live. + const generationFenced = proofGeneration !== undefined + && (proofGeneration === claim.credentialGeneration + ? isCodexAccountGenerationLive(claim.accountId, proofGeneration) + : proofGeneration === claim.credentialGeneration + 1 + && currentRecord?.replacedAt === claim.credentialReplacedAt + && isCodexAccountGenerationLive(claim.accountId, proofGeneration)); + return settleCooldownRecoveryLease(claim, recovered && generationFenced, now); +} + +function settleCooldownRecoveryLease(claim: CooldownRecoveryLease, recovered: boolean, now: number): boolean { + const health = claim.scope ? scopedHealthFor(claim.accountId, claim.scope) : getAccountHealth(claim.accountId); + if (!health || health.probeLeaseId !== claim.leaseId) return false; + const fenced = (claim.scope === undefined || claim.scope === "shared") + && health.cooldownSource === "reset-derived" + && (health.cooldownGeneration ?? 0) === claim.cooldownGeneration + && (health.probeLeaseGeneration ?? 0) === claim.cooldownGeneration; + if (!recovered || !fenced) { + const released = withProbeLeaseReleased(health, now); + if (claim.scope) setScopedHealth(claim.accountId, claim.scope, released); + else setAccountHealth(claim.accountId, released); + return false; + } + if (claim.scope) { + deleteScopedHealth(claim.accountId, claim.scope); + } else { + const { + cooldownUntil: _until, + cooldownSince: _since, + cooldownSource: _source, + probeLeaseId: _leaseId, + probeLeaseGeneration: _leaseGeneration, + // "The quota window moved" is a statement about the whole refusal, so the avoidance it + // announced goes with the block it produced. Leaving it would make this escape hatch stop + // escaping: the account would still be passed over by every selection it is meant to win. + quotaAvoidUntil: _avoid, + ...rest + } = health; + setAccountHealth(claim.accountId, { + ...rest, + cooldownGeneration: claim.cooldownGeneration + 1, + lastProbeAt: now, + }); + } + return true; +} + +/** Acquire the recovery probe for one confirmed model-specific quota group. */ +export function tryAcquireCodexQuotaScopeProbeLease( + accountId: string, + scope: CodexQuotaScope, + now = Date.now(), +): string | null { + const health = scopedHealthFor(accountId, scope); + if (!canAcquireQuotaProbeLease(health, now)) return null; + const probeLeaseId = randomUUID(); + setScopedHealth(accountId, scope, { + ...health!, + probeLeaseId, + probeLeaseGeneration: health!.cooldownGeneration ?? 0, + lastProbeAt: now, + }); + return probeLeaseId; +} + +/** Side-effect-free check for a confirmed model-specific quota probe. */ +export function canAcquireCodexQuotaScopeProbeLease( + accountId: string, + scope: CodexQuotaScope, + now = Date.now(), +): boolean { + return canAcquireQuotaProbeLease(scopedHealthFor(accountId, scope), now); +} + +/** + * Hand a probe lease back without recording an upstream outcome. Used by paths + * that take a lease and then fail before any request reaches upstream. + */ +export function releaseCodexQuotaProbeLease(accountId: string, leaseId: string, now = Date.now()): void { + const health = getAccountHealth(accountId); + if (!health || health.probeLeaseId !== leaseId) return; + setAccountHealth(accountId, withProbeLeaseReleased(health, now)); +} + +/** Release a model-specific quota probe when the request never reaches upstream. */ +export function releaseCodexQuotaScopeProbeLease( + accountId: string, + scope: CodexQuotaScope, + leaseId: string, + now = Date.now(), +): void { + const health = scopedHealthFor(accountId, scope); + if (!health || health.probeLeaseId !== leaseId) return; + setScopedHealth(accountId, scope, withProbeLeaseReleased(health, now)); +} + +/** + * True when this outcome belongs to the account's in-flight probe. The + * undefined-id guard matters: without it an outcome carrying no lease would match + * an account holding no lease and be mistaken for the probe owner. + */ +export function ownsProbeLease(health: CodexUpstreamHealth | undefined, meta: CodexUpstreamOutcomeMeta): boolean { + return meta.probeLeaseId !== undefined && meta.probeLeaseId === health?.probeLeaseId; +} + +/** + * True when the owning probe may still clear the cooldown. A later 429 bumps the + * generation, so a probe that started under an older cooldown must not erase the + * newer restriction (which may carry an explicit Retry-After). + */ +export function probeMayClearCooldown(health: CodexUpstreamHealth | undefined, meta: CodexUpstreamOutcomeMeta): boolean { + return ownsProbeLease(health, meta) + && (health!.probeLeaseGeneration ?? 0) === (health!.cooldownGeneration ?? 0); +} + +/** Strip the in-flight lease while preserving every hard-cooldown field. */ +export function withProbeLeaseReleased(health: CodexUpstreamHealth, now: number): CodexUpstreamHealth { + const { probeLeaseId: _id, probeLeaseGeneration: _gen, ...rest } = health; + return { ...rest, lastProbeAt: now }; +} diff --git a/src/codex/routing/selection.ts b/src/codex/routing/selection.ts new file mode 100644 index 0000000000..03f562a56c --- /dev/null +++ b/src/codex/routing/selection.ts @@ -0,0 +1,698 @@ +import { isCodexAccountPaused } from "../account-pause"; +import { codexAccountPriorityLookup, pinnedCodexAccountId } from "../account-priority"; +import { isSelectableCodexPoolAccount } from "../account-id"; +import { isAccountNeedsReauth } from "../account-runtime-state"; +import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "../account-usability"; +import { + normalizeAccountPoolStickyLimit, + normalizeCodexAccountPoolStrategy, + notePoolRotationSuccess, + peekRoundRobinAccount, + pickRoundRobinAccount, + selectPriorityTier, +} from "../pool-rotation"; +import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, resetAtToMs } from "../quota"; +import { codexPlanKey } from "../plan"; +import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan, hasMainAccountRefreshGrant } from "../main-account"; +import type { OcxConfig } from "../../types"; +import { CODEX_FAILURE_WINDOW_MS, computeCodexUsageScore } from "./cooldown-math"; +import { + codexPoolKeyForScope, + dropSpentCredentialFailure, + getAccountHealth, + getCodexQuotaHealthSnapshot, + isCodexAccountSoftAvoided, + isCodexQuotaAvoided, + isIndependentCodexQuotaScope, + type CodexQuotaScope, +} from "./health-store"; +import { bindThreadAffinity, type CodexAffinityReason } from "./thread-affinity"; +import { + getEffectiveActiveCodexAccountId, + manualPreferenceBlocks, + promoteActiveCodexAccount, + rememberActiveCodexAccount, + setActiveCodexAccount, +} from "./active-account"; + +/** + * Plan keys the operator excluded from automatic rotation. Absent or empty means no policy, so an + * existing install rotates exactly as before. Compared with `codexPlanKey` because the stored plan + * is an unrestricted provider string whose casing this repository does not control. + */ +function excludedCodexPoolPlanKeys(config: OcxConfig): ReadonlySet | undefined { + const configured = config.codexPool?.excludedPlans; + if (!configured?.length) return undefined; + const keys = configured + .map(plan => codexPlanKey(plan)) + .filter((key): key is string => key !== undefined); + return keys.length > 0 ? new Set(keys) : undefined; +} + +/** + * Whether the operator's plan policy removes this account from automatic selection. + * + * Modelled on pause rather than usability: an excluded account keeps its credential, quota history, + * and affinity, stays visible on the account surface, and is still reachable by explicit account + * selection. Only automatic rotation skips it, which is the distinction #4211 asked for. + * + * It is checked in the same two places pause is checked, and that is not redundancy. The eligible + * list is consulted only when routing picks a NEW account; an already-active or already-affined + * account is served straight from {@link isCodexAccountSelectable}. A lapsed subscription leaves + * behind exactly that account, so a policy that filtered only the eligible list would miss the case + * it exists for. + * + * `__main__` is exempt. {@link getPoolAccountPlanForSelection} withholds the main plan during a + * selection-only drain so routing never reads the fenced native credential for it, so a rule that + * covered main would disagree with itself between drain and ordinary routing. + */ +export function isCodexAccountPlanExcluded( + config: OcxConfig, + accountId: string, + precomputed?: ReadonlySet, +): boolean { + if (accountId === MAIN_CODEX_ACCOUNT_ID) return false; + // Callers that test a whole list pass the set once rather than rebuilding it per row. + const excluded = precomputed ?? excludedCodexPoolPlanKeys(config); + if (!excluded) return false; + const plan = codexPlanKey(getPoolAccountPlan(config, accountId)); + return plan !== undefined && excluded.has(plan); +} + +export function isCodexAccountSelectable( + config: OcxConfig, + accountId: string, + now: number, + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, +): boolean { + return !isCodexAccountPaused(config, accountId) + && !isCodexAccountPlanExcluded(config, accountId) + && getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null + && !isCodexQuotaAvoided(accountId, quotaScope, now) + && !isCodexAccountSoftAvoided(accountId, now) + && isCodexAccountUsable(config, accountId, selectionOptions); +} + +/** + * Which guard in {@link isCodexAccountSelectable} refused this account, if any. + * + * Deliberately the same predicates in the same order as that function, because the point is to + * REPORT the guard that actually fired rather than to re-derive a plausible-looking cause. An + * earlier version of the release reason checked only a subset and let a paused, plan-excluded, + * cooled-down or quota-avoided release fall through to a quota fallback, which named something + * routing never used -- a diagnostic that is confidently wrong in exactly the cases an operator + * would consult it for (#4598). + */ +export function codexAccountBlockReason( + config: OcxConfig, + accountId: string, + now: number, + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, +): CodexAffinityReason | undefined { + if (isCodexAccountPaused(config, accountId)) return "paused"; + if (isCodexAccountPlanExcluded(config, accountId)) return "plan_excluded"; + if (getCodexQuotaHealthSnapshot(accountId, quotaScope, now) !== null) return "cooldown"; + if (isCodexQuotaAvoided(accountId, quotaScope, now)) return "quota_avoided"; + if (isCodexAccountSoftAvoided(accountId, now)) return "transient"; + if (!isCodexAccountUsable(config, accountId, selectionOptions)) return "unusable"; + return undefined; +} + +export function getEligiblePoolAccounts( + config: OcxConfig, + excludeId?: string, + now = Date.now(), + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, + skipFailoverReadyCandidates = false, +): readonly string[] { + const excludedPlans = excludedCodexPoolPlanKeys(config); + const ids = (config.codexAccounts ?? []) + .filter(account => isSelectableCodexPoolAccount(account) + && account.id !== excludeId + && !isCodexAccountPaused(config, account.id) + && !isCodexAccountPlanExcluded(config, account.id, excludedPlans) + && !isAccountNeedsReauth(account.id) + && (!skipFailoverReadyCandidates || !shouldFailover(config, account.id, now))) + .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null) + .filter(account => !isCodexAccountSoftAvoided(account.id, now)) + .filter(account => !isCodexQuotaAvoided(account.id, quotaScope, now)) + .filter(account => isCodexAccountUsable(config, account.id, selectionOptions)) + .map(account => account.id); + // The main Codex account is not stored in config.codexAccounts; include it as a + // first-class rotation candidate when its read-only token is usable (Option A). + if ( + excludeId !== MAIN_CODEX_ACCOUNT_ID + && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) + && (!isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) || hasMainAccountRefreshGrant()) + && getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null + && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now) + // The main login is not in `config.codexAccounts`, so it never passes through the + // filters above and this is the only place an avoidance window can exclude it. Without + // this the window a refusal announced applies to the pool but not to the account that + // earned it: the cooldown caps at fifteen minutes, the window runs up to six hours, and + // in between the main account returns as a first-class candidate. + && !isCodexQuotaAvoided(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) + && (!skipFailoverReadyCandidates || !shouldFailover(config, MAIN_CODEX_ACCOUNT_ID, now)) + && isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions) + ) { + ids.unshift(MAIN_CODEX_ACCOUNT_ID); + } + // Single choke point for selection order: every strategy, failover, and preview + // reaches the pool through here, so tiering applies once rather than per picker. + // Eligibility above is unchanged — this only narrows an already-eligible list. + return selectPriorityTier( + ids, + codexAccountPriorityLookup(config), + id => hasCodexQuotaHeadroom(config, id, selectionOptions, now), + pinnedCodexAccountId(config), + ); +} + +function listEligibleCodexAccountIds( + config: OcxConfig, + now: number, + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, +): readonly string[] { + return getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions); +} + +/** Shared reset timestamps are not evidence for independent model-quota groups. */ +export function accountPoolStrategyForScope(config: OcxConfig, quotaScope?: CodexQuotaScope) { + const strategy = normalizeCodexAccountPoolStrategy(config.accountPoolStrategy); + return strategy === "reset-first" && isIndependentCodexQuotaScope(quotaScope) ? "quota" : strategy; +} + +function stickyLimitForConfig(config: OcxConfig): number { + return normalizeAccountPoolStickyLimit(config.accountPoolStickyLimit); +} + +/** + * Whether an account still has quota to give under the auto-switch threshold. + * + * Fill-first and the priority tier filter share this predicate, and share both of + * its escape hatches. A disabled threshold means only health, pause, and reauth + * may drain an account; unknown usage is a guess, so it must neither force + * fill-first off the active account nor drain a tier that was simply never + * primed. A genuinely exhausted account 429s into cooldown and leaves + * eligibility on its own. + */ +export function hasCodexQuotaHeadroom( + config: OcxConfig, + accountId: string, + selectionOptions?: CodexAccountUsabilityOptions, + now: number = Date.now(), +): boolean { + const threshold = config.autoSwitchThreshold ?? 80; + if (threshold <= 0) return true; + const usage = computeCodexUsageScore( + getAccountQuota(accountId), + getPoolAccountPlanForSelection(config, accountId, selectionOptions), + now, + ); + if (isUnknownUsage(usage)) return true; + return usage < threshold; +} + +/** + * Is a live binding held for its prompt cache? + * + * Unset means yes. Cache affinity shipped as an opt-in flag (#4292) and then #4546 measured + * what the default costs: a pool whose accounts all sit in the 80-99% band hands a bound + * conversation from account to account, and because provider prompt caches are account-isolated + * every hop re-sends the entire prefix. An install that has never heard of this flag is exactly + * the install that gets hurt by it, so the protection cannot be something you have to find. + * + * `false` restores capacity-first routing byte-for-byte. It is a real choice -- a pinned thread + * on a busy account pays latency -- and it stays available; it is just no longer the default. + */ +export function isCacheAffinityEnabled(config: OcxConfig): boolean { + return config.pool?.cacheAffinity !== false; +} + +/** Earliest future shared short/weekly reset; missing evidence and ties use usage order. */ +export function pickResetFirstCodexAccount( + config: OcxConfig, + ids: readonly string[], + now: number, + selectionOptions?: CodexAccountUsabilityOptions, +): string | null { + const available = ids.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now)); + if (available.length === 0) return pickLowestUsageAmong(config, ids, selectionOptions, now); + let earliest = Number.POSITIVE_INFINITY; + let candidates: string[] = []; + for (const id of available) { + const quota = getAccountQuota(id); + const resets = [quota?.shortResetAt, quota?.weeklyResetAt] + .filter((reset): reset is number => typeof reset === "number" && Number.isFinite(reset)) + .map(resetAtToMs) + .filter(reset => reset > now); + const next = Math.min(...resets); + if (next < earliest) { + earliest = next; + candidates = [id]; + } else if (next === earliest) candidates.push(id); + } + return pickLowestUsageAmong(config, candidates, selectionOptions, now); +} + +/** + * Fill-first: keep selectable active under threshold; otherwise advance to the next + * eligible id in stable sorted order after the current active (wrapping). + */ +function pickFillFirstCodexAccount( + config: OcxConfig, + now: number, + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, +): string | null { + const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions); + if (eligible.length === 0) return null; + + const active = getEffectiveActiveCodexAccountId(config); + if (active && eligible.includes(active) && hasCodexQuotaHeadroom(config, active, selectionOptions, now)) { + return active; + } + + return pickNextFillFirstCodexAccount(config, active ?? null, eligible, now, selectionOptions); +} + +/** Next eligible account in stable order after `afterId` (wrapping). */ +function pickNextFillFirstCodexAccount( + config: OcxConfig, + afterId: string | null, + eligible: readonly string[] = listEligibleCodexAccountIds(config, Date.now()), + now = Date.now(), + selectionOptions?: CodexAccountUsabilityOptions, +): string | null { + if (eligible.length === 0) return null; + const ordered = [...eligible].sort((a, b) => a.localeCompare(b)); + if (!afterId) { + // Prefer an under-threshold account when starting with no active cursor. + for (const id of ordered) { + if (hasCodexQuotaHeadroom(config, id, selectionOptions, now)) return id; + } + return ordered[0] ?? null; + } + + const allConfigured = [ + ...(isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions) || afterId === MAIN_CODEX_ACCOUNT_ID + ? [MAIN_CODEX_ACCOUNT_ID] + : []), + ...(config.codexAccounts ?? []).filter(account => !account.isMain).map(account => account.id), + ]; + const stableAll = [...new Set(allConfigured)].sort((a, b) => a.localeCompare(b)); + const startIdx = stableAll.indexOf(afterId); + if (startIdx < 0) { + for (const id of ordered) { + if (hasCodexQuotaHeadroom(config, id, selectionOptions, now)) return id; + } + return ordered[0] ?? null; + } + + // Skip successors that are also at/above threshold (known drained usage). + let fallback: string | null = null; + for (let step = 1; step <= stableAll.length; step++) { + const candidate = stableAll[(startIdx + step) % stableAll.length]!; + if (!eligible.includes(candidate)) continue; + if (!fallback) fallback = candidate; + if (hasCodexQuotaHeadroom(config, candidate, selectionOptions, now)) return candidate; + } + return fallback ?? ordered[0] ?? null; +} + +/** + * Unbound new-session pick for round-robin / fill-first. Returns null to fall through + * to the legacy quota path (or when the strategy is quota). + * + * When `commit` is true (resolve path), advances RR state. `commitSharedActive` + * and `commitAffinity` independently control the two cross-request side effects: + * model-scoped entitlement selection can bind a new task without replacing an + * existing task binding or global active choice. Preview remains a dry-run peek. + * + * Automatic strategy picks never sync-write config; only manual selection persists active. + * + * Known limitation (follow-up): when a subagent preview peeks an RR account and the request + * then falls back to a non-Codex provider, the ring is not reserved/committed. Prefer seeding + * the peeked account if that path becomes load-bearing. + */ +export function pickUnboundStrategyAccount( + config: OcxConfig, + threadId: string | null, + now: number, + commit: boolean, + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, + commitSharedActive = commit, + commitAffinity = commit, +): string | null { + const strategy = accountPoolStrategyForScope(config, quotaScope); + if (strategy === "quota") return null; + const poolKey = codexPoolKeyForScope(quotaScope); + + let picked: string | null = null; + if (strategy === "round-robin") { + const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions); + const limit = stickyLimitForConfig(config); + if (!commit) { + return peekRoundRobinAccount(poolKey, eligible, limit); + } + picked = pickRoundRobinAccount(poolKey, eligible, limit); + if (!picked) return null; + if (commitSharedActive) { + if (!isIndependentCodexQuotaScope(quotaScope) + && !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) { + rememberActiveCodexAccount(config, picked); + } + } + if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope); + notePoolRotationSuccess(poolKey, picked, limit); + return picked; + } + + if (strategy === "fill-first" || strategy === "reset-first") { + picked = strategy === "reset-first" + ? pickResetFirstCodexAccount(config, listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions), now, selectionOptions) + : pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions); + if (!picked) return null; + if (commitSharedActive) { + if (!isIndependentCodexQuotaScope(quotaScope) + && !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) { + rememberActiveCodexAccount(config, picked); + } + } + if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope); + return picked; + } + + return null; +} + +export function getPoolAccountPlan(config: OcxConfig, accountId: string): string | undefined { + if (accountId === MAIN_CODEX_ACCOUNT_ID) return getMainAccountPlan(); + return (config.codexAccounts ?? []) + .find(account => isSelectableCodexPoolAccount(account) && account.id === accountId)?.plan; +} + +/** Selection-only main routing must not lazily read the fenced native credential for its plan. */ +export function getPoolAccountPlanForSelection( + config: OcxConfig, + accountId: string, + selectionOptions?: CodexAccountUsabilityOptions, +): string | undefined { + if (accountId === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) { + return undefined; + } + return getPoolAccountPlan(config, accountId); +} + +/** Shared routing state must ignore a request-scoped entitlement roster. */ +export function sharedStateSelectionOptions( + selectionOptions?: CodexAccountUsabilityOptions, +): Pick< + CodexAccountUsabilityOptions, + "nativeMainSelectionOnly" | "isMainAccountTokenLive" +> | undefined { + if (!selectionOptions) return undefined; + return { + ...(selectionOptions.nativeMainSelectionOnly !== undefined + ? { nativeMainSelectionOnly: selectionOptions.nativeMainSelectionOnly } + : {}), + ...(selectionOptions.isMainAccountTokenLive + ? { isMainAccountTokenLive: selectionOptions.isMainAccountTokenLive } + : {}), + }; +} + +export function pickLowerUsageAccount( + config: OcxConfig, + active: string, + activeUsage: number, + now: number, + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, + skipFailoverReadyCandidates = false, +): string { + let best = active; + let bestUsage = activeUsage; + for (const id of getEligiblePoolAccounts( + config, + active, + now, + quotaScope, + selectionOptions, + skipFailoverReadyCandidates, + )) { + const usage = computeCodexUsageScore( + getAccountQuota(id), + getPoolAccountPlanForSelection(config, id, selectionOptions), + now, + ); + if (usage < bestUsage) { + best = id; + bestUsage = usage; + } + } + return best; +} + +/** Coolest account in an already-selected candidate list; first index wins ties. */ +export function pickLowestUsageAmong( + config: OcxConfig, + ids: readonly string[], + selectionOptions?: CodexAccountUsabilityOptions, + now: number = Date.now(), +): string | null { + let best: string | null = null; + let bestUsage = Number.POSITIVE_INFINITY; + for (const id of ids) { + const usage = computeCodexUsageScore( + getAccountQuota(id), + getPoolAccountPlanForSelection(config, id, selectionOptions), + now, + ); + if (usage < bestUsage) { + best = id; + bestUsage = usage; + } + } + return best; +} + +export function pickLowestUsageCodexAccount( + config: OcxConfig, + excludeId?: string, + now = Date.now(), + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, +): string | null { + return pickLowestUsageAmong( + config, + getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions), + selectionOptions, + now, + ); +} + +/** + * Strategy-aware alternate after a cooled/excluded account (same-request 429 retry + * and active promotion). Quota keeps lowest-usage; fill-first advances stable order; + * round-robin takes the next ring pick (caller should have noted the failure). + */ +export function pickAlternateCodexAccount( + config: OcxConfig, + excludeId: string, + now = Date.now(), + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, +): string | null { + const strategy = accountPoolStrategyForScope(config, quotaScope); + // The exclusion is passed into eligibility rather than post-filtered off its + // result: when the excluded account is the only healthy member of the top + // tier, the tier walk must be free to descend instead of selecting that tier + // and then handing back an empty list. + if (strategy === "round-robin") { + const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions); + return pickRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config)); + } + if (strategy === "fill-first") { + const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions); + return pickNextFillFirstCodexAccount(config, excludeId, eligible, now, selectionOptions); + } + if (strategy === "reset-first") { + return pickResetFirstCodexAccount(config, getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions), now, selectionOptions); + } + return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope, selectionOptions); +} + +/** + * The account {@link pickAlternateCodexAccount} WOULD return, without returning it. + * + * Only the round-robin branch has a side effect -- `pickRoundRobinAccount` commits the pick and + * advances the ring -- so every other strategy delegates rather than growing a second copy of + * the selection rule that could drift from it. + * + * This exists because preview and resolve have to agree on the FIRST transient detour, not just + * on later ones. Preview feeds subagent model-availability scoring, so a preview that reported + * the bound account while resolve was about to serve from a cool sibling could retire a model + * over usage the request would never have touched. + */ +export function peekAlternateCodexAccount( + config: OcxConfig, + excludeId: string, + now: number, + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, +): string | null { + if (accountPoolStrategyForScope(config, quotaScope) === "round-robin") { + const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions); + return peekRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config)); + } + return pickAlternateCodexAccount(config, excludeId, now, quotaScope, selectionOptions); +} + +export function isUnknownUsage(usage: number): boolean { + return usage >= CODEX_UNKNOWN_USAGE_SCORE; +} + +/** + * Move an unbound request back up when a higher tier regains headroom — the + * weekly-reset case. Returns null when nothing should change. + * + * Downward moves are deliberately left to {@link applyQuotaAutoSwitch}: this only + * fires when the tier filter has already excluded `active`, and only toward a + * tier that strictly outranks it. Threads bound by affinity never reach here. + */ +export function pickPriorityPreemption( + config: OcxConfig, + active: string, + now: number, + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, +): string | null { + const eligible = getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions); + if (eligible.length === 0 || eligible.includes(active)) return null; + const pinned = pinnedCodexAccountId(config); + // A live pin already lowered the tier ceiling; never preempt past an explicit + // operator choice. Same liveness test the tier filter applies, so preview and + // resolve agree even before the pin is garbage-collected. + if ( + pinned !== undefined + && eligible.includes(pinned) + && hasCodexQuotaHeadroom(config, pinned, selectionOptions, now) + ) return null; + const priorityOf = codexAccountPriorityLookup(config); + if (priorityOf(eligible[0]!) <= priorityOf(active)) return null; + // Members without headroom are in the tier only because a sibling has some; + // picking one would hand the request straight back to a drained account. + return pickLowestUsageAmong( + config, + eligible.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now)), + selectionOptions, + now, + ); +} + +export function applyQuotaAutoSwitch( + config: OcxConfig, + active: string, + now: number, + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, + commitSharedSelection = true, +): string { + const threshold = config.autoSwitchThreshold ?? 80; + if (threshold <= 0) return active; + const quota = getAccountQuota(active); + const activeUsage = computeCodexUsageScore( + quota, + getPoolAccountPlanForSelection(config, active, selectionOptions), + now, + ); + // Unknown usage is not evidence that a user's explicit selection crossed the + // threshold. Wait for quota priming instead of rotating among guesses. + if (isUnknownUsage(activeUsage)) return active; + if (activeUsage < threshold) return active; + const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope, selectionOptions); + if (best !== active) { + if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)) { + setActiveCodexAccount(config, best); + } + return best; + } + + return active; +} + +export function shouldFailover(config: OcxConfig, accountId: string, now: number): boolean { + const threshold = config.upstreamFailoverThreshold ?? 3; + if (threshold <= 0) return false; + dropSpentCredentialFailure(accountId); + const health = getAccountHealth(accountId); + if (health?.lastFailureAt && now - health.lastFailureAt > CODEX_FAILURE_WINDOW_MS) return false; + return !!health && health.consecutiveFailures >= threshold; +} + +export function isHealthySharedCodexSelection( + config: OcxConfig, + accountId: string, + now: number, + quotaScope: CodexQuotaScope | undefined, + selectionOptions: CodexAccountUsabilityOptions | undefined, +): boolean { + return isCodexAccountSelectable(config, accountId, now, quotaScope, selectionOptions) + && hasCodexQuotaHeadroom(config, accountId, selectionOptions, now) + && !shouldFailover(config, accountId, now); +} + +export function strategySelectionOptionsForModelDetour( + config: OcxConfig, + now: number, + quotaScope: CodexQuotaScope | undefined, + selectionOptions: CodexAccountUsabilityOptions | undefined, +): CodexAccountUsabilityOptions | undefined { + if (selectionOptions?.modelEligibleAccountIds === undefined) return selectionOptions; + const sharedSelectionOptions = sharedStateSelectionOptions(selectionOptions) ?? {}; + return { + ...selectionOptions, + modelEligibleAccountIds: new Set( + [...selectionOptions.modelEligibleAccountIds].filter(accountId => + isHealthySharedCodexSelection( + config, + accountId, + now, + quotaScope, + sharedSelectionOptions, + ) + ), + ), + }; +} + +export function applyFailureFailover( + config: OcxConfig, + active: string, + now: number, + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, + commitSharedSelection = true, +): string { + if (!shouldFailover(config, active, now)) return active; + const best = pickAlternateCodexAccount(config, active, now, quotaScope, selectionOptions); + if (best) { + // The scope still routes away from the failing account — that is this request's + // own decision — but an independent one must not persist a new shared active + // account. recordCodexUpstreamOutcome only suppresses the promotion it makes at + // the moment of the failure; the streak outlives the soft avoid, so a later + // scoped resolve reaches here with the streak still tripped and would otherwise + // move the shared cursor after all. + if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)) { + promoteActiveCodexAccount(config, best); + } + return best; + } + return active; +} diff --git a/src/codex/routing/thread-affinity.ts b/src/codex/routing/thread-affinity.ts new file mode 100644 index 0000000000..cd493d20f4 --- /dev/null +++ b/src/codex/routing/thread-affinity.ts @@ -0,0 +1,419 @@ +import { isCodexAccountGenerationLive, readCodexAccountRecord } from "../account-store"; +import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; +import { retainedUtf8Bytes } from "../../lib/admission"; +import type { CodexQuotaScope } from "./health-store"; + +export type ThreadAffinityEntry = { + accountId: string; + generation: number; + createdAt: number; + lastUsedAt: number; + // Last time the bound account's quota threshold was re-evaluated for this + // thread (interval-gated to avoid per-request flapping). See REEVAL_INTERVAL_MS. + lastReevalAt: number; + // When a transient failure streak first forced this thread onto another account + // while the binding was HELD (#4546). Cleared the moment the bound account serves + // again; once it ages past CODEX_TRANSIENT_AFFINITY_HOLD_MS the binding is + // released through the ordinary path instead of detouring forever. + transientHoldSince?: number; + // Which account is serving this thread while its own is held under a transient hold. + // Remembered rather than re-picked per request: under round-robin a fresh pick each turn + // would walk the ring and start cold on every hop, which is the behaviour the hold exists + // to prevent. Cleared with transientHoldSince when the bound account serves again. + transientDetourAccountId?: string; +}; + +export type CodexThreadResolution = + | { status: "selected"; accountId: string; affinity?: CodexAffinityDecision } + | { status: "none"; affinity?: CodexAffinityDecision } + | { status: "expired"; accountId: string; affinity?: CodexAffinityDecision }; + +/** What happened to this thread's binding on this request (#4546). */ +export type CodexAffinityMove = + /** Served by its own bound account, which was healthy. */ + | "reused" + /** Served by its own bound account while something transient was wrong with it. */ + | "held" + /** Served by another account while the binding stayed put. */ + | "detour" + /** The binding was released and a different account took the thread. */ + | "rebound" + /** There was no live binding; this request established one. */ + | "new_bind" + /** The binding was released without a replacement on this request. */ + | "cleared"; + +/** + * Why. A move is the expensive event -- it discards the prompt-cache prefix warmed on the old + * account -- so the operator should not have to infer it from account labels across log lines, + * which is how #4546 had to be diagnosed. + */ +export type CodexAffinityReason = + | "healthy" + | "quota_headroom" + | "quota_refusal" + | "transient" + | "transient_hold_expired" + | "unusable" + | "paused" + | "plan_excluded" + | "cooldown" + | "quota_avoided" + | "generation" + | "expired" + | "model_lane"; + +export interface CodexAffinityDecision { + move: CodexAffinityMove; + reason: CodexAffinityReason; +} + +/** The decision to report once a binding has been released and selection starts over. */ +export function affinityAfterRelease( + threadId: string | null, + releaseReason: CodexAffinityReason | undefined, +): CodexAffinityDecision { + // Reported now, so it must not be reported again by the next request. + clearPendingReleaseReason(threadId); + return releaseReason === undefined + ? { move: "new_bind", reason: "healthy" } + : { move: "rebound", reason: releaseReason }; +} + +/** + * What to report when selection produced no account at all. The binding is gone and nothing took + * it, which is a `cleared`, and the pending reason is deliberately NOT consumed: a no-account + * result reaches no auth context and therefore no usage entry, so the next resolve that does + * produce one is the first place this release can actually be seen. + */ +export function affinityOnNoAccount( + threadId: string | null, + releaseReason: CodexAffinityReason | undefined, +): CodexAffinityDecision | undefined { + if (releaseReason === undefined) return undefined; + // Hand it forward as well as reporting it. A reason derived from the entry this request just + // released lives only in a local, so without this the next resolve finds no entry and no + // pending reason and calls the rebind a fresh healthy bind. + notePendingReleaseReason(threadId, releaseReason); + return { move: "cleared", reason: releaseReason }; +} + +export const CODEX_THREAD_AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000; +export const CODEX_THREAD_AFFINITY_MAX_ENTRIES = 2048; +const MAX_AFFINITY_COMPONENT_BYTES = 512; +// Min interval between quota threshold re-evaluations for a single bound thread. +// Well under the 5h/weekly quota windows, but enough to stop per-request flapping. +export const CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS = 60_000; + +/** + * How long a live binding outlives a TRANSIENT failure streak on its own account (#4546). + * + * Being unable to send right now is not the same as losing ownership of the conversation. + * A 5xx streak is frequently provider-wide rather than account-specific, and deleting the + * binding for it discards a prompt-cache prefix that the next turn then pays for again -- + * the same cost the quota threshold used to impose, arriving through a different door. + * So the request detours to another account while the binding is held here. + * + * Bounded, because an unbounded hold is its own defect: an account that never recovers + * would keep a thread detouring indefinitely while the conversation's real warm prefix + * accumulates somewhere else. Ten minutes is longer than the whole soft-avoid escalation + * ladder up to its final step, so an ordinary outage resolves inside the hold and a + * genuine one converts to a real rebind instead of a permanent detour. + */ +export const CODEX_TRANSIENT_AFFINITY_HOLD_MS = 10 * 60_000; + +/** + * Requests without a resolved native model retain the historic one-account-per- + * thread behavior. Requests with a known quota scope get an independent + * affinity so a Reserve failover cannot displace the same thread's Terra/Luna + * account (and vice versa). + */ +type BaseThreadAffinityScope = CodexQuotaScope | "legacy"; +type ModelDetourAffinityScope = `model-detour:${BaseThreadAffinityScope}:${string}`; +type ThreadAffinityScope = BaseThreadAffinityScope | ModelDetourAffinityScope; + +function isModelDetourAffinityScope(scope: ThreadAffinityScope): scope is ModelDetourAffinityScope { + return scope.startsWith("model-detour:"); +} +const LEGACY_THREAD_AFFINITY_SCOPE = "legacy" as const; +const threadAccountMap = new Map>(); +let threadAffinityEntryTotal = 0; + +export function clearThreadAccountMap(): void { + threadAccountMap.clear(); + threadAffinityEntryTotal = 0; +} + +export function clearThreadAccountMapForAccount( + accountId: string, + reason: CodexAffinityReason = "unusable", +): void { + for (const [threadId, affinities] of threadAccountMap) { + for (const [scope, entry] of affinities) { + if (entry.accountId === accountId && affinities.delete(scope)) { + threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1); + notePendingReleaseReason(threadId, reason); + } + } + if (affinities.size === 0) threadAccountMap.delete(threadId); + } +} + +/** + * Why a binding was released, held until that thread's next resolve can report it (#4546). + * + * A release and the request that pays for it are two different moments: a 429 clears the pin + * inside the outcome recorder, and the next request arrives with nothing left to explain why it + * is starting cold. Bounded, because it is a diagnostic and must not become a leak. + */ +const pendingReleaseReasons = new Map(); +const MAX_PENDING_RELEASE_REASONS = 4096; + +function notePendingReleaseReason(threadId: string | null, reason: CodexAffinityReason): void { + if (threadId === null) return; + if (!pendingReleaseReasons.has(threadId) && pendingReleaseReasons.size >= MAX_PENDING_RELEASE_REASONS) { + const oldest = pendingReleaseReasons.keys().next(); + if (!oldest.done) pendingReleaseReasons.delete(oldest.value); + } + pendingReleaseReasons.set(threadId, reason); +} + +export function peekPendingReleaseReason(threadId: string | null): CodexAffinityReason | undefined { + if (threadId === null) return undefined; + return pendingReleaseReasons.get(threadId); +} + +/** + * Forget a release only once it has actually been reported. + * + * Consuming it at derivation time lost it whenever selection then failed to produce an account: + * a no-account return carries no payload, so the release went unrecorded and the next successful + * resolve claimed a fresh healthy bind (#4598). A release survives until some resolve reports it. + */ +function clearPendingReleaseReason(threadId: string | null): void { + if (threadId !== null) pendingReleaseReasons.delete(threadId); +} + +function threadAffinityScope(quotaScope?: CodexQuotaScope): BaseThreadAffinityScope { + return quotaScope ?? LEGACY_THREAD_AFFINITY_SCOPE; +} + +function admissibleAffinityComponent(value: string): boolean { + return retainedUtf8Bytes(value) <= MAX_AFFINITY_COMPONENT_BYTES; +} + +function modelDetourAffinityScope( + modelId: string | undefined, + quotaScope?: CodexQuotaScope, +): ModelDetourAffinityScope | undefined { + const canonicalModelId = modelId?.trim().toLowerCase(); + if (!canonicalModelId || !admissibleAffinityComponent(canonicalModelId)) return undefined; + return `model-detour:${threadAffinityScope(quotaScope)}:${canonicalModelId}`; +} + +function getThreadAffinityForScope( + threadId: string, + scope: ThreadAffinityScope, +): ThreadAffinityEntry | undefined { + if (!admissibleAffinityComponent(threadId)) return undefined; + return threadAccountMap.get(threadId)?.get(scope); +} + +export function getThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): ThreadAffinityEntry | undefined { + return getThreadAffinityForScope(threadId, threadAffinityScope(quotaScope)); +} + +export function getModelDetourAffinity( + threadId: string, + modelId: string | undefined, + quotaScope?: CodexQuotaScope, +): ThreadAffinityEntry | undefined { + const scope = modelDetourAffinityScope(modelId, quotaScope); + return scope ? getThreadAffinityForScope(threadId, scope) : undefined; +} + +function deleteThreadAffinityForScope(threadId: string, scope: ThreadAffinityScope): void { + if (!admissibleAffinityComponent(threadId)) return; + const affinities = threadAccountMap.get(threadId); + if (!affinities) return; + if (affinities.delete(scope)) { + threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1); + } + if (affinities.size === 0) threadAccountMap.delete(threadId); +} + +export function deleteThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): void { + deleteThreadAffinityForScope(threadId, threadAffinityScope(quotaScope)); +} + +export function deleteModelDetourAffinity( + threadId: string, + modelId: string | undefined, + quotaScope?: CodexQuotaScope, +): void { + const scope = modelDetourAffinityScope(modelId, quotaScope); + if (scope) deleteThreadAffinityForScope(threadId, scope); +} + +/** Remove only the matching failed account's affinities for one thread. */ +export function deleteThreadAffinitiesForAccount(threadId: string, accountId: string): void { + if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return; + const affinities = threadAccountMap.get(threadId); + if (!affinities) return; + for (const [scope, entry] of affinities) { + if (entry.accountId === accountId && affinities.delete(scope)) { + threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1); + } + } + if (affinities.size === 0) threadAccountMap.delete(threadId); +} + +function threadAffinityEntryCount(): number { + return threadAffinityEntryTotal; +} + +export function isThreadAffinityExpired(entry: ThreadAffinityEntry, now: number): boolean { + return now - entry.lastUsedAt > CODEX_THREAD_AFFINITY_IDLE_TTL_MS; +} + +export function isThreadAffinityGenerationLive(entry: ThreadAffinityEntry): boolean { + if (entry.accountId === MAIN_CODEX_ACCOUNT_ID) return entry.generation === 0; + return isCodexAccountGenerationLive(entry.accountId, entry.generation); +} + +/** Generations this account's affinity entries are bound at. Test observability only. */ +export function debugCodexAffinityGenerations(accountId: string): number[] { + const generations: number[] = []; + for (const affinities of threadAccountMap.values()) { + for (const entry of affinities.values()) { + if (entry.accountId === accountId) generations.push(entry.generation); + } + } + return generations; +} + +/** + * Advance this account's affinity entries from the generation a rejected credential + * was bound under to the generation its own refresh produced. + * + * A 401 refresh-and-replay keeps the request on the same account, but the CAS write + * moves the credential from G to G+1, and {@link isThreadAffinityGenerationLive} + * demands exact equality — so without this the entry the replay just preserved is + * dead on the next request. Not quarantining an account is not the same as keeping + * its affinity. + * + * Lineage is proven by the CALLER, which must pass only a generation its own refresh + * produced. Re-deriving it here from `replacedAt` cannot work: the caller reads that + * field after the refresh and this function would re-read the same record, so the + * comparison is tautological and an external replacement passes it. An external + * replacement must retire the affinity, because that credential may belong to a + * different upstream identity. + */ +export function handOffThreadAffinityGeneration( + accountId: string, + fromGeneration: number, + toGeneration: number, +): boolean { + if (accountId === MAIN_CODEX_ACCOUNT_ID) return false; + if (toGeneration !== fromGeneration + 1) return false; + const record = readCodexAccountRecord(accountId); + if (!record?.credential || record.deletedAt != null) return false; + if (record.generation !== toGeneration) return false; + let handedOff = false; + for (const affinities of threadAccountMap.values()) { + for (const entry of affinities.values()) { + if (entry.accountId !== accountId || entry.generation !== fromGeneration) continue; + entry.generation = toGeneration; + handedOff = true; + } + } + return handedOff; +} + +function pruneExpiredThreadAffinities(now: number): void { + for (const [threadId, affinities] of threadAccountMap) { + for (const [scope, entry] of affinities) { + if (isThreadAffinityExpired(entry, now) && affinities.delete(scope)) { + threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1); + } + } + if (affinities.size === 0) threadAccountMap.delete(threadId); + } +} + +function pruneLruThreadAffinities(): void { + if (threadAffinityEntryCount() <= CODEX_THREAD_AFFINITY_MAX_ENTRIES) return; + while (threadAffinityEntryCount() > CODEX_THREAD_AFFINITY_MAX_ENTRIES) { + let oldestThreadId: string | null = null; + let oldestScope: ThreadAffinityScope | null = null; + let oldestLastUsedAt = Number.POSITIVE_INFINITY; + let oldestIsDetour = false; + for (const [threadId, affinities] of threadAccountMap) { + for (const [scope, entry] of affinities) { + const candidateIsDetour = isModelDetourAffinityScope(scope); + if ( + (candidateIsDetour && !oldestIsDetour) + || (candidateIsDetour === oldestIsDetour && entry.lastUsedAt < oldestLastUsedAt) + ) { + oldestThreadId = threadId; + oldestScope = scope; + oldestLastUsedAt = entry.lastUsedAt; + oldestIsDetour = candidateIsDetour; + } + } + } + if (!oldestThreadId || !oldestScope) return; + deleteThreadAffinityForScope(oldestThreadId, oldestScope); + } +} + +function bindThreadAffinityForScope( + threadId: string, + accountId: string, + now: number, + scope: ThreadAffinityScope, +): void { + if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return; + const record = accountId === MAIN_CODEX_ACCOUNT_ID ? undefined : readCodexAccountRecord(accountId); + if (accountId !== MAIN_CODEX_ACCOUNT_ID && (!record?.credential || record.deletedAt != null)) return; + pruneExpiredThreadAffinities(now); + const affinities = threadAccountMap.get(threadId) ?? new Map(); + const previous = affinities.get(scope); + affinities.set(scope, { + accountId, + generation: accountId === MAIN_CODEX_ACCOUNT_ID ? 0 : record!.generation, + createdAt: previous?.createdAt ?? now, + lastUsedAt: now, + lastReevalAt: now, + }); + if (!previous) threadAffinityEntryTotal += 1; + threadAccountMap.set(threadId, affinities); + pruneLruThreadAffinities(); +} + +export function bindThreadAffinity( + threadId: string, + accountId: string, + now: number, + quotaScope?: CodexQuotaScope, +): void { + bindThreadAffinityForScope(threadId, accountId, now, threadAffinityScope(quotaScope)); +} + +export function bindModelDetourAffinity( + threadId: string, + accountId: string, + now: number, + modelId: string | undefined, + quotaScope?: CodexQuotaScope, +): void { + const scope = modelDetourAffinityScope(modelId, quotaScope); + if (scope) bindThreadAffinityForScope(threadId, accountId, now, scope); +} + +/** Read-only view of one thread's scope-keyed affinity entries. */ +export function getThreadAffinityScopes( + threadId: string, +): ReadonlyMap | undefined { + return threadAccountMap.get(threadId); +} diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 386e921391..e02447ffb0 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -1,3078 +1,102 @@ -import { createHash } from "node:crypto"; -import { - effectiveCodexAuthAccountId, - fetchMainAccountInfoSnapshot, - listCodexAuthAccountsSnapshot, -} from "../codex/auth-api"; -import { withoutRetiredCodexQuota, type StoredAccountQuota } from "../codex/quota"; -import { isMainAccountIdentityGenerationLive } from "../codex/main-account-cache"; -import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; -import { codexPlanKey } from "../codex/plan"; -import { resolveEnvValue } from "../config"; -import { resolveProviderApiKey } from "./key-store"; -import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth"; -import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store"; -import { antigravityUserAgent } from "../adapters/client-fingerprint"; -import { isCanonicalOllamaCloudUrl } from "../adapters/ollama-native-url"; -import { DestinationDnsResolutionError } from "../lib/destination-policy"; -import { PinnedHttpError } from "../lib/pinned-http"; -import { ProviderOutboundPolicyError, providerOutboundPost, providerRedirectError, type ProviderOutboundDependencies } from "../lib/provider-outbound"; -import { apiKeyPoolEntryId } from "./api-keys"; -import { fetchMuseKeyQuotaSnapshot } from "./muse-key-quota"; -import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "./xai-transport"; -import { getProviderRegistryEntry, providerCodexAccountMode, registryEntryForProviderDestination } from "./registry"; -import type { OcxConfig, OcxProviderConfig } from "../types"; -import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./openai-tiers"; -import { - captureConfigGeneration, - sweepExpiredOnWrite, - type GenerationContext, -} from "../lib/state-store-sweeper"; -import { - ACCOUNT_QUOTA_TTL_MS, - asRecord, - CACHE_TTL_MS, - normalizePercent, - normalizeResetAt, - QUOTA_JSON_READ_FAILURE, - readQuotaJson, - REQUEST_TIMEOUT_MS, - toFiniteNumber, -} from "./quota-wire"; -import { - clearCachedProviderQuotas, - providerQuotaRoutingBinding, - replaceCachedProviderQuotas, - type ProviderQuotaRoutingEvidence, -} from "./quota-routing-cache"; -import { - aggregateCodexPoolCapacity, - CODEX_CAPACITY_MAX_QUOTA_AGE_MS, - type CodexCapacityAggregation, - type CodexCapacityQuota, -} from "./codex-capacity"; -import type { - AccountQuotaMode, - QuotaFailureCode, - ProviderQuota, - ProviderQuotaCreditsUsd, - ProviderQuotaWindow, - ProviderRoutingQuota, -} from "./quota-types"; -import { - clearKiroAccountUsageState, - commitKiroAccountUsageState, - fetchKiroUsageSnapshot, - type KiroUsageSnapshot, - kiroUsageContextForAccount, - reconcileKiroAccountUsageState, -} from "./kiro-usage"; -import { - cancelPendingAccountQuotaPersist, - readPersistedAccountQuotas, - schedulePersistAccountQuotas, -} from "./account-quota-disk"; -import { clearProviderApiKeyQuotaCache, mapQuotaRoster, readProviderApiKeyQuotas, type ProviderApiKeyQuota } from "./quota-key-accounts"; - -export type { ProviderQuota, ProviderQuotaCreditsUsd, ProviderQuotaWindow } from "./quota-types"; - -/** Match oauth/index REFRESH_SKEW_MS — use stored access without refresh when still fresh. */ -const ACCOUNT_TOKEN_SKEW_MS = 60_000; -/** Successful provider quota payloads are small; reject oversized or stalled JSON before parsing. */ -export { QUOTA_RESPONSE_MAX_BYTES } from "./quota-wire"; -const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1"; -const KIMI_CODE_USAGE_URL = `${KIMI_CODE_BASE_URL}/usages`; -const COMMAND_CODE_BASE_URL = "https://api.commandcode.ai"; -const COMMAND_CODE_WHOAMI_URL = `${COMMAND_CODE_BASE_URL}/alpha/whoami`; -const COMMAND_CODE_CREDITS_URL = `${COMMAND_CODE_BASE_URL}/alpha/billing/credits`; -const COMMAND_CODE_SUBSCRIPTIONS_URL = `${COMMAND_CODE_BASE_URL}/alpha/billing/subscriptions`; -const COMMAND_CODE_USAGE_URL = `${COMMAND_CODE_BASE_URL}/alpha/usage/summary`; -const A6API_BASE_URL = "https://api.a6api.com"; -const OPENCODE_GO_BASE_URL = "https://opencode.ai/zen/go/v1"; -const OPENCODE_GO_USAGE_URL = `${OPENCODE_GO_BASE_URL}/usage`; -const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; -const DEEPSEEK_BASE_URL = "https://api.deepseek.com"; -const CLINE_BASE_URL = "https://api.cline.bot"; -const OLLAMA_CLOUD_BASE_URL = "https://ollama.com"; -const OLLAMA_CLOUD_USAGE_URL = `${OLLAMA_CLOUD_BASE_URL}/api/usage`; -const ZAI_BASE_URL = "https://api.z.ai"; -const ZAI_CN_BASE_URL = "https://open.bigmodel.cn"; -const MINIMAX_REMAINS_URL = "https://www.minimax.io/v1/token_plan/remains"; -const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; -const VENICE_BASE_URL = "https://api.venice.ai/api/v1"; -const SYNTHETIC_BASE_URL = "https://api.synthetic.new/v2"; -const DEEPINFRA_BASE_URL = "https://api.deepinfra.com"; -const NEURALWATT_BASE_URL = "https://api.neuralwatt.com/v1"; -const XAI_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing"; -const XAI_CREDITS_URL = `${XAI_BILLING_URL}?format=credits`; -/** Keep a failed probe's previous row at most this long before dropping it. */ -const LAST_GOOD_MAX_AGE_MS = CODEX_CAPACITY_MAX_QUOTA_AGE_MS; -const nativeMainReportGenerations = new WeakMap(); -const accountReportCurrent = new WeakMap boolean>(); -const routingEvidence = new WeakMap(); -let providerQuotaBeforePublishForTests: (() => void | Promise) | null = null; - -/** Test-only seam for identity/config invalidation after probes but before publication. */ -export function setProviderQuotaBeforePublishForTests( - hook: (() => void | Promise) | null, -): void { - providerQuotaBeforePublishForTests = hook; -} -const TERMINAL_QUOTA_FAILURE = Symbol("terminal-quota-failure"); -/** - * The probe succeeded and the upstream authoritatively reported NO model-quota windows. - * - * Distinct from `null`, which means "this probe told us nothing" and deliberately preserves - * the last-good row for up to 30 minutes. Collapsing the two would let a stale report outlive - * the authoritative answer that replaced it: a GLM plan whose payload carries only MCP - * `TIME_LIMIT` rows has no model windows, and the dashboard and quota-aware routing must stop - * showing the previous token windows rather than keep them for another half hour. - * - * Suppression is shared with `TERMINAL_QUOTA_FAILURE`; only the reason differs. - */ -const AUTHORITATIVE_EMPTY_QUOTA = Symbol("authoritative-empty-quota"); -type ProviderQuotaProbeResult = - | ProviderQuotaReport - | null - | typeof TERMINAL_QUOTA_FAILURE - | typeof AUTHORITATIVE_EMPTY_QUOTA; - -export interface ProviderQuotaReport { - provider: string; - label: string; - source: string; - quota: ProviderQuota; - updatedAt: number; - /** Added by the management response projection, never stored on a cached report. */ - routingQuota?: ProviderRoutingQuota; - reverseEngineered?: boolean; - /** - * The row was OBSERVED in-band on a streaming turn rather than probed. - * - * Age means something different for these. A probed provider re-reads on its own TTL, - * so a row older than the last-good bound means the probe is failing and showing it - * would misrepresent a live number. A passive provider publishes no endpoint at all - * (`hasPassiveAccountQuota`), so its last observation is not a stale reading of - * something fresher — it is the only measurement that exists, and dropping it leaves - * the operator with nothing. Consumers that enforce a freshness bound must exempt - * these and state the observation age instead. - */ - observed?: boolean; - aggregation?: CodexCapacityAggregation; -} - -export interface ProviderQuotaResponse { - generatedAt: number; - reports: ProviderQuotaReport[]; -} - -let cache: { key: string; ts: number; response: ProviderQuotaResponse } | null = null; -const inflight = new Map }>(); -/** Bumped on cache clear and on force-refresh start; stale-epoch probes lose commit authority. */ -let invalidationEpoch = 0; - -/** Invalidate the report cache (e.g. after switching a provider's active account). */ -export function clearProviderQuotaCache(): void { - cache = null; - clearCachedProviderQuotas(); - clearProviderApiKeyQuotaCache(); - invalidationEpoch += 1; -} - -function cacheKey(config: OcxConfig): string { - const providers = Object.entries(config.providers) - .map(([name, provider]) => { - const resolvedKey = typeof provider.apiKey === "string" - ? resolveProviderApiKey(provider.apiKey)?.trim() - : undefined; - const activeKeyId = resolvedKey ? apiKeyPoolEntryId(resolvedKey) : "none"; - return `${name}:${provider.adapter}:${provider.authMode ?? "key"}:${providerCodexAccountMode(name, provider) ?? "none"}:${provider.disabled === true ? "off" : "on"}:${provider.baseUrl}:${activeKeyId}`; - }) - .sort() - .join("|"); - return `${config.defaultProvider}|${providers}`; -} - -type CodexAuthAccountsSnapshotPromise = ReturnType; - -function hasCodexPoolProvider(config: OcxConfig): boolean { - return Object.entries(config.providers).some(([name, provider]) => ( - provider.disabled !== true - && isBuiltInChatGptForwardProvider(name, provider) - && providerCodexAccountMode(name, provider) !== "direct" - )); -} - -function quotaSignatureValue(quota: CodexCapacityQuota | null): unknown { - if (!quota) return null; - return { - fiveHourPercent: quota.fiveHourPercent, - fiveHourResetAt: quota.fiveHourResetAt, - weeklyPercent: quota.weeklyPercent, - weeklyResetAt: quota.weeklyResetAt, - monthlyPercent: quota.monthlyPercent, - monthlyResetAt: quota.monthlyResetAt, - updatedAt: quota.updatedAt, - customWindows: [...(quota.customWindows ?? [])] - .map(window => ({ label: window.label, percent: window.percent, resetAt: window.resetAt })) - .sort((a, b) => a.label.localeCompare(b.label)), - }; -} - -function providerQuotaFromCodexQuota( - quota: StoredAccountQuota | Omit | null | undefined, -): CodexCapacityQuota | null { - if (!quota) return null; - // Direct snapshots bypass account DTOs; sanitize here as well as at ingestion. - quota = withoutRetiredCodexQuota(quota); - if (!quota) return null; - const projected: CodexCapacityQuota = { - ...(quota.shortPercent !== undefined ? { fiveHourPercent: quota.shortPercent } : {}), - ...(quota.shortResetAt !== undefined ? { fiveHourResetAt: quota.shortResetAt } : {}), - ...(quota.weeklyPercent !== undefined ? { weeklyPercent: quota.weeklyPercent } : {}), - ...(quota.weeklyResetAt !== undefined ? { weeklyResetAt: quota.weeklyResetAt } : {}), - ...(quota.monthlyPercent !== undefined ? { monthlyPercent: quota.monthlyPercent } : {}), - ...(quota.monthlyResetAt !== undefined ? { monthlyResetAt: quota.monthlyResetAt } : {}), - ...(quota.customWindows !== undefined ? { customWindows: quota.customWindows } : {}), - updatedAt: "updatedAt" in quota ? quota.updatedAt : Date.now(), - }; - return hasQuotaRows(projected) ? projected : null; -} - -/** Hash only presentation-relevant state; account ids and email addresses never enter the key. */ -function cacheKeyWithAggregationState( - config: OcxConfig, - prefetchedSnapshot?: CodexAuthAccountsSnapshotPromise, -): string | Promise { - const base = cacheKey(config); - if (!hasCodexPoolProvider(config)) return base; - return (async () => { - try { - const activeId = effectiveCodexAuthAccountId(config); - const snapshot = await (prefetchedSnapshot ?? listCodexAuthAccountsSnapshot(config, false)); - const rows = snapshot.accounts.map(account => ({ - isMain: account.isMain, - active: account.id === activeId, - plan: codexPlanKey(account.plan) ?? null, - paused: account.paused, - needsReauth: account.needsReauth === true, - quota: quotaSignatureValue(providerQuotaFromCodexQuota(account.quota)), - })); - const canonicalRows = rows.map(row => JSON.stringify(row)).sort(); - const digest = createHash("sha256").update(JSON.stringify(canonicalRows)).digest("hex").slice(0, 24); - return `${base}|codex-pool:${digest}`; - } catch { - return `${base}|codex-pool:unavailable`; - } - })(); -} - -function publicCapacityWindow(window: import("./codex-capacity").CodexCapacityWindowAggregation) { - const { totalWeight: _totalWeight, consumedWeight: _consumedWeight, remainingWeight: _remainingWeight, ...safe } = window; - return safe; -} - -/** Management API metadata intentionally omits configured/weighted unit counts. */ -function publicCapacityAggregation( - aggregation: CodexCapacityAggregation, - presentation: NonNullable, -): CodexCapacityAggregation { - const safeCurrentAccount = presentation === "coverage-only" && aggregation.currentAccount - ? { ...aggregation.currentAccount, quota: null } - : aggregation.currentAccount; - return { - ...aggregation, - presentation, - ...(safeCurrentAccount ? { currentAccount: safeCurrentAccount } : {}), - ...(aggregation.fiveHour ? { fiveHour: publicCapacityWindow(aggregation.fiveHour) } : {}), - ...(aggregation.weekly ? { weekly: publicCapacityWindow(aggregation.weekly) } : {}), - ...(aggregation.monthly ? { monthly: publicCapacityWindow(aggregation.monthly) } : {}), - ...(aggregation.customWindows ? { - customWindows: aggregation.customWindows.map(window => ({ - label: window.label, - ...publicCapacityWindow(window), - })), - } : {}), - }; -} - -function hasQuotaRows(quota: ProviderQuota | null | undefined): quota is ProviderQuota { - if (!quota) return false; - return typeof quota.fiveHourPercent === "number" - || typeof quota.weeklyPercent === "number" - || typeof quota.monthlyPercent === "number" - || quota.creditsUsd?.unlimited === true - || typeof quota.creditsUsd?.percent === "number" - || !!quota.customWindows?.some(window => typeof window.percent === "number"); -} - -function providerLabel(providerId: string): string { - return getProviderRegistryEntry(providerId)?.label ?? providerId; -} - -/** Test-only access to the quota reader's deadline and cancellation contract. */ -export async function readProviderQuotaJsonForTests(response: Response, timeoutMs: number): Promise { - const result = await readQuotaJson(response, timeoutMs); - return result === QUOTA_JSON_READ_FAILURE ? null : result; -} - -function isBuiltInChatGptForwardProvider(name: string, provider: OcxProviderConfig): boolean { - return name === OPENAI_CODEX_PROVIDER_ID && isCanonicalOpenAiForwardProvider(provider); -} - -function isCanonicalA6apiBaseUrl(baseUrl: string): boolean { - const normalized = normalizedBaseUrl(baseUrl); - return normalized === A6API_BASE_URL || normalized === `${A6API_BASE_URL}/v1`; -} - -function isCanonicalOpenCodeGoBaseUrl(baseUrl: string): boolean { - return normalizedBaseUrl(baseUrl) === OPENCODE_GO_BASE_URL; -} - -function isCanonicalOpenRouterBaseUrl(baseUrl: string): boolean { - const normalized = normalizedBaseUrl(baseUrl); - return normalized === OPENROUTER_BASE_URL; -} - -function isCanonicalDeepSeekBaseUrl(baseUrl: string): boolean { - const normalized = normalizedBaseUrl(baseUrl); - return normalized === DEEPSEEK_BASE_URL || normalized === `${DEEPSEEK_BASE_URL}/v1`; -} - -function isCanonicalClineBaseUrl(baseUrl: string): boolean { - const normalized = normalizedBaseUrl(baseUrl); - return normalized === CLINE_BASE_URL || normalized === `${CLINE_BASE_URL}/api/v1`; -} - -function isCanonicalOllamaCloudBaseUrl(baseUrl?: string): boolean { - if (!baseUrl) return false; - try { - return isCanonicalOllamaCloudUrl(baseUrl); - } catch { - return false; - } -} - -function zaiQuotaMonitorHost(baseUrl: string): string | null { - // Admission and destination selection must share one mapping: admitting a new - // international wire must never fall through to the CN host/authentication scheme. - switch (normalizedBaseUrl(baseUrl)) { - case ZAI_BASE_URL: - case `${ZAI_BASE_URL}/api/coding/paas/v4`: - case `${ZAI_BASE_URL}/api/anthropic`: - case `${ZAI_BASE_URL}/api/v1`: - return ZAI_BASE_URL; - case ZAI_CN_BASE_URL: - case `${ZAI_CN_BASE_URL}/api/coding/paas/v4`: - case `${ZAI_CN_BASE_URL}/api/v1`: - return ZAI_CN_BASE_URL; - default: - return null; - } -} - -function isCanonicalZaiBaseUrl(baseUrl: string): boolean { - return zaiQuotaMonitorHost(baseUrl) !== null; -} - -function isCanonicalMinimaxBaseUrl(baseUrl: string): boolean { - const normalized = normalizedBaseUrl(baseUrl); - return normalized === "https://api.minimax.io/v1" || normalized === "https://api.minimaxi.com/v1"; -} - -function isCanonicalMoonshotBaseUrl(baseUrl: string): boolean { - const normalized = normalizedBaseUrl(baseUrl); - return normalized === MOONSHOT_BASE_URL || normalized === "https://api.moonshot.cn/v1"; -} - -function isCanonicalVeniceBaseUrl(baseUrl: string): boolean { - return normalizedBaseUrl(baseUrl) === VENICE_BASE_URL; -} - -function isCanonicalSyntheticBaseUrl(baseUrl: string): boolean { - const normalized = normalizedBaseUrl(baseUrl); - return normalized === SYNTHETIC_BASE_URL || normalized === "https://api.synthetic.new/openai/v1"; -} - -function isCanonicalDeepInfraBaseUrl(baseUrl: string): boolean { - const normalized = normalizedBaseUrl(baseUrl); - return normalized === DEEPINFRA_BASE_URL || normalized === `${DEEPINFRA_BASE_URL}/v1/openai`; -} - -function isCanonicalNeuralwattBaseUrl(baseUrl: string): boolean { - return normalizedBaseUrl(baseUrl) === NEURALWATT_BASE_URL; -} - -function a6apiPayload(value: unknown): Record | null { - const body = asRecord(value); - return asRecord(body?.data) ?? body; -} - -function firstFinite(record: Record | null, names: string[]): number | undefined { - if (!record) return undefined; - for (const name of names) { - const value = toFiniteNumber(record[name]); - if (value !== undefined) return value; - } - return undefined; -} - -async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Promise { - // Never send a configured API key to a lookalike host or through a redirect. - if (!isCanonicalA6apiBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); - if (!apiKey) return null; - const headers = { Accept: "application/json", Authorization: `Bearer ${apiKey}` } as const; - const [subscriptionResponse, tokenResponse] = await Promise.all([ - fetch(`${A6API_BASE_URL}/dashboard/billing/subscription`, { - headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }), - fetch(`${A6API_BASE_URL}/api/usage/token/`, { - headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }), - ]); - if (!subscriptionResponse.ok || !tokenResponse.ok) { - const statuses = [subscriptionResponse.status, tokenResponse.status]; - // 408/429 are transient (timeout/throttle), not invalid-account signals: keep the - // last-good row like 5xx/network failures. 401/403 (bad key) and 404 (contract change) - // stay terminal. - return statuses.some(status => status >= 400 && status < 500 && status !== 429 && status !== 408) - ? TERMINAL_QUOTA_FAILURE - : null; - } - const [subscriptionBody, tokenBody] = await Promise.all([ - readQuotaJson(subscriptionResponse), - readQuotaJson(tokenResponse), - ]); - if (subscriptionBody === QUOTA_JSON_READ_FAILURE || tokenBody === QUOTA_JSON_READ_FAILURE) return null; - const subscription = a6apiPayload(subscriptionBody); - const token = a6apiPayload(tokenBody); - const unlimited = token?.unlimited_quota === true - || token?.unlimited_quota === 1 - || token?.unlimited_quota === "true"; - const normalizedExpiry = normalizeResetAt(token?.expires_at); - const expiry = normalizedExpiry && normalizedExpiry > 0 - ? { expiresAt: normalizedExpiry } - : {}; - if (unlimited) { - // Every row is an API-credit constraint on inference, so the display quota is also - // the routing projection. Passing it explicitly is the opt-in. - const quota: ProviderQuota = { - creditsUsd: { - used: 0, - limit: 0, - remaining: 0, - percent: 0, - unlimited: true, - ...expiry, - }, - customWindows: [{ label: "Unlimited API credits", percent: 0 }], - updatedAt: Date.now(), - }; - return keyReport(provider, "a6api:billing", quota, config, apiKey, quota); - } - const limitUsd = firstFinite(subscription, ["hard_limit_usd"]); - const grantedUnits = firstFinite(token, ["total_granted"]); - const usedUnits = firstFinite(token, ["total_used"]); - const availableUnits = firstFinite(token, ["total_available"]); - const reconciledUnits = usedUnits !== undefined && availableUnits !== undefined - ? usedUnits + availableUnits - : undefined; - const reconciliationTolerance = grantedUnits !== undefined - ? Math.abs(grantedUnits) * 1e-9 - : 0; - if (limitUsd === undefined || grantedUnits === undefined || usedUnits === undefined - || availableUnits === undefined || limitUsd <= 0 || grantedUnits <= 0 - || usedUnits < 0 || availableUnits < 0 - || reconciledUnits === undefined - || Math.abs(reconciledUnits - grantedUnits) > reconciliationTolerance) return TERMINAL_QUOTA_FAILURE; - const usdPerUnit = limitUsd / grantedUnits; - const usedUsd = usedUnits * usdPerUnit; - const remainingUsd = Math.max(0, availableUnits * usdPerUnit); - const percent = normalizePercent((usedUsd / limitUsd) * 100); - if (percent === undefined) return TERMINAL_QUOTA_FAILURE; - const label = `API credits ($${remainingUsd.toFixed(2)} of $${limitUsd.toFixed(2)} remaining)`; - const quota: ProviderQuota = { - creditsUsd: { - used: usedUsd, - limit: limitUsd, - remaining: remainingUsd, - percent, - ...expiry, - }, - customWindows: [{ label, percent }], - updatedAt: Date.now(), - }; - // The credit balance funds inference itself, so display and routing scope agree. - return keyReport(provider, "a6api:billing", quota, config, apiKey, quota); -} - -function parseOpenCodeGoUsageWindow(value: unknown): { percent: number; resetAt?: number } | null { - const row = asRecord(value); - if (!row) return null; - const percent = normalizePercent(row.percent); - if (percent === undefined) return null; - const resetAt = normalizeResetAt(row.resetsAt); - return { percent, ...(resetAt !== undefined ? { resetAt } : {}) }; -} - -async function fetchOpenCodeGoQuota(provider: string, config: OcxProviderConfig): Promise { - // Never send a configured API key when the provider destination is not the built-in Go endpoint. - if (!isCanonicalOpenCodeGoBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); - if (!apiKey) return null; - const response = await fetch(OPENCODE_GO_USAGE_URL, { - headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, - redirect: "error", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) { - return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 - ? TERMINAL_QUOTA_FAILURE - : null; - } - const body = asRecord(await readQuotaJson(response)); - const usage = asRecord(body?.usage); - if (!usage) return null; - const rolling = parseOpenCodeGoUsageWindow(usage.rolling); - const weekly = parseOpenCodeGoUsageWindow(usage.weekly); - const monthly = parseOpenCodeGoUsageWindow(usage.monthly); - const quota: ProviderQuota = { - ...(rolling ? { - fiveHourPercent: rolling.percent, - ...(rolling.resetAt !== undefined ? { fiveHourResetAt: rolling.resetAt } : {}), - } : {}), - ...(weekly ? { - weeklyPercent: weekly.percent, - ...(weekly.resetAt !== undefined ? { weeklyResetAt: weekly.resetAt } : {}), - } : {}), - ...(monthly ? { - monthlyPercent: monthly.percent, - ...(monthly.resetAt !== undefined ? { monthlyResetAt: monthly.resetAt } : {}), - } : {}), - updatedAt: Date.now(), - }; - return keyReport(provider, "opencode-go:usage", quota, config, apiKey, quota); -} - -/** - * OpenRouter `GET /api/v1/key` — the key's own credit balance and optional - * per-key spending cap. `limit` is the configured cap (absent = uncapped); - * `usage` is lifetime spend; `limit_remaining` is what is left of the cap. - * When no cap is set there is no hard limit to meter against, so no bar is - * produced — the provider falls back to its documented reference. - */ -async function fetchOpenRouterQuota(provider: string, config: OcxProviderConfig): Promise { - // Never send a configured API key to a lookalike host or through a redirect. - if (!isCanonicalOpenRouterBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); - if (!apiKey) return null; - const response = await fetch(`${OPENROUTER_BASE_URL}/key`, { - headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, - redirect: "error", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) { - return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 - ? TERMINAL_QUOTA_FAILURE - : null; - } - const body = asRecord(await readQuotaJson(response)); - const data = asRecord(body?.data) ?? body; - if (!data) return null; - const limit = toFiniteNumber(data.limit); - const limitRemaining = toFiniteNumber(data.limit_remaining); - const usage = toFiniteNumber(data.usage); - // A successful no-cap response is a DELIBERATE change, not a transient - // failure: the old capped row must be dropped, not preserved as last-good. - if (limit === undefined || limit <= 0) return TERMINAL_QUOTA_FAILURE; - // Prefer the authoritative remaining-cap value when present: `usage` is - // lifetime accumulated spend and overstates a reset or re-capped key. - const used = limitRemaining !== undefined - ? Math.max(0, limit - limitRemaining) - : usage !== undefined && usage >= 0 ? usage : undefined; - if (used === undefined) return null; - const percent = normalizePercent((used / limit) * 100); - if (percent === undefined) return null; - const remaining = Math.max(0, limit - used); - const label = `API credits ($${remaining.toFixed(2)} of $${limit.toFixed(2)} remaining)`; - // The per-key spending cap stops every request this credential can make, so the - // whole report is inference-wide routing evidence. - const quota: ProviderQuota = { - customWindows: [{ label, percent }], - updatedAt: Date.now(), - }; - return keyReport(provider, "openrouter:key-info", quota, config, apiKey, quota); -} - -/** - * DeepSeek `GET /user/balance` — the account's granted + topped-up credit - * balance. The payload places `total_balance` / `granted_balance` inside - * entries of `balance_infos` (one row per currency); the row for the account's - * currency is selected by preference. `granted_balance` is a CURRENT balance - * component, not the original grant ceiling, so no consumed percentage is - * fabricated — the balance is reported as a balance-only window. - */ -async function fetchDeepSeekQuota(provider: string, config: OcxProviderConfig): Promise { - if (!isCanonicalDeepSeekBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); - if (!apiKey) return null; - const response = await fetch(`${DEEPSEEK_BASE_URL}/user/balance`, { - headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, - redirect: "error", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) { - return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 - ? TERMINAL_QUOTA_FAILURE - : null; - } - const body = asRecord(await readQuotaJson(response)); - // The payload nests balances under `balance_infos` rows keyed by currency; - // prefer a USD row, then CNY, then the first row that parses. - const infos = Array.isArray(body?.balance_infos) ? body.balance_infos as unknown[] : null; - const rows = infos - ? infos.map((raw): Record | null => asRecord(raw)).filter((r): r is Record => r !== null) - : []; - const pick = (currency: string): Record | null => - rows.find(row => String(row.currency ?? "").toUpperCase() === currency) ?? null; - const preferred = pick("USD") ?? pick("CNY") ?? rows[0] ?? null; - if (!preferred) return null; - const totalBalance = toFiniteNumber(preferred.total_balance); - const grantedBalance = toFiniteNumber(preferred.granted_balance); - const toppedUp = toFiniteNumber(preferred.topped_up_balance); - const balance = totalBalance ?? grantedBalance ?? toppedUp; - if (balance === undefined || balance < 0) return null; - const label = grantedBalance !== undefined && grantedBalance > 0 - ? `API balance ($${balance.toFixed(2)} total, $${grantedBalance.toFixed(2)} granted)` - : `API balance ($${balance.toFixed(2)})`; - return report(provider, "deepseek:balance", { - customWindows: [{ label, percent: 0 }], - updatedAt: Date.now(), - }); -} - -/** - * ClinePass `GET /api/v1/users/me/plan/usage-limits` — the subscription's - * rolling five-hour, weekly, and monthly utilization, matching the existing - * ProviderQuota windows directly. The endpoint 404s (or returns a null plan) - * for accounts without an active ClinePass, which is a no-report, not an error. - */ -async function fetchClineQuota(provider: string, config: OcxProviderConfig): Promise { - if (!isCanonicalClineBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); - if (!apiKey) return null; - const response = await fetch(`${CLINE_BASE_URL}/api/v1/users/me/plan/usage-limits`, { - headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, - redirect: "error", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) { - // 404 = no active plan; a plain "no plan" is a no-report, everything else - // 4xx (except 408/429) is a credential/contract problem. - if (response.status === 404) return null; - return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 - ? TERMINAL_QUOTA_FAILURE - : null; - } - const body = asRecord(await readQuotaJson(response)); - const data = asRecord(body?.data) ?? body; - const limits = Array.isArray(data?.limits) ? data.limits : null; - if (!limits) return null; - const quota: ProviderQuota = { updatedAt: Date.now() }; - let windows = 0; - for (const raw of limits) { - const row = asRecord(raw); - if (!row) continue; - const percent = normalizePercent(row.percentUsed); - if (percent === undefined) continue; - const resetAt = normalizeResetAt(row.resetsAt); - if (row.type === "five_hour") { - quota.fiveHourPercent = percent; - if (resetAt !== undefined) quota.fiveHourResetAt = resetAt; - windows += 1; - } else if (row.type === "weekly") { - quota.weeklyPercent = percent; - if (resetAt !== undefined) quota.weeklyResetAt = resetAt; - windows += 1; - } else if (row.type === "monthly") { - quota.monthlyPercent = percent; - if (resetAt !== undefined) quota.monthlyResetAt = resetAt; - windows += 1; - } - } - return windows > 0 ? keyReport(provider, "cline:plan-usage-limits", quota, config, apiKey, quota) : null; -} - -/** - * Ollama Cloud `GET https://ollama.com/api/usage` — returns account usage. - * Legacy plans report rolling 5-hour `limits.session.usage` and 7-day - * `limits.weekly.usage`. Migrated monthly-credit plans report - * `limits.monthly.usage`. `usage` values are normalized fractions (0..1). - */ -function parseOllamaPercent(usageValue: unknown): number | undefined { - const usage = toFiniteNumber(usageValue); - if (usage === undefined || usage < 0) return undefined; - const percent = Math.round(usage * 10000) / 100; - return normalizePercent(percent); -} - -export function parseOllamaCloudQuota(body: Record | null): ProviderQuota | null { - if (!body) return null; - const limits = asRecord(body.limits); - if (!limits) return null; - - const quota: ProviderQuota = { updatedAt: Date.now() }; - let windows = 0; - - const session = asRecord(limits.session); - if (session) { - const percent = parseOllamaPercent(session.usage); - if (percent !== undefined) { - quota.fiveHourPercent = percent; - windows += 1; - } - } - - const weekly = asRecord(limits.weekly); - if (weekly) { - const percent = parseOllamaPercent(weekly.usage); - if (percent !== undefined) { - quota.weeklyPercent = percent; - windows += 1; - } - } - - const monthly = asRecord(limits.monthly); - if (monthly) { - const percent = parseOllamaPercent(monthly.usage); - if (percent !== undefined) { - quota.monthlyPercent = percent; - windows += 1; - } - } - - return windows > 0 ? quota : null; -} - -async function fetchOllamaCloudQuota(provider: string, config: OcxProviderConfig): Promise { - const effectiveBaseUrl = config.baseUrl ?? getProviderRegistryEntry(provider)?.baseUrl ?? ""; - if (!isCanonicalOllamaCloudBaseUrl(effectiveBaseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); - if (!apiKey) return null; - const response = await fetch(OLLAMA_CLOUD_USAGE_URL, { - headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, - redirect: "error", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) { - if (response.status === 404) return null; - return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 - ? TERMINAL_QUOTA_FAILURE - : null; - } - const body = asRecord(await readQuotaJson(response)); - const quota = parseOllamaCloudQuota(body); - return quota ? keyReport(provider, "ollama-cloud:usage", quota, config, apiKey, quota) : null; -} - -/** - * Z.AI GLM Coding Plan `GET /api/monitor/usage/quota/limit` — the coding-plan - * limits arrive as a `limits` array of `TOKENS_LIMIT` (newer plans call the - * same rows `CREDIT_LIMIT`) and `TIME_LIMIT` rows. `TOKENS_LIMIT`/`CREDIT_LIMIT` - * rows carry the window length as `unit`/`number`: unit 3 is hours (number 5 → - * the rolling five-hour window), unit 6 is weeks (number 1 → the weekly - * window). Every row's `percentage` is the consumed share (falling - * back to `currentValue`/`usage` when absent) and `nextResetTime` (unix ms) - * the window reset. - * - * `TIME_LIMIT` rows are deliberately ignored (issue #1168). They are the shared - * monthly MCP *call* allowance for Web Search / Web Reader / Zread — not a - * model-token budget — and `ProviderQuota.monthlyPercent` is consumed as a - * model-capacity signal: `headroomOf()` in `src/oauth/account-quota-rank.ts` - * takes the MAX across every window, so a user who spent their MCP search - * allowance would be ranked as having no model capacity left, and the dashboard - * would draw a full monthly bar for a plan whose model tokens are untouched. - * A payload carrying only `TIME_LIMIT` rows therefore reports no quota at all, - * which is the honest answer rather than a fabricated one. - */ -export function parseZaiQuotaLimits(data: Record | null): ProviderQuota | null { - const limits = Array.isArray(data?.limits) ? data.limits as unknown[] : null; - if (!limits) return null; - const quota: ProviderQuota = { updatedAt: Date.now() }; - let windows = 0; - for (const raw of limits) { - const row = asRecord(raw); - if (!row) continue; - // Gate on row type before deriving a percentage: an MCP row must not even - // contribute a parsed value to a model-quota report. - if (row.type !== "TOKENS_LIMIT" && row.type !== "CREDIT_LIMIT") continue; - const resetAt = normalizeResetAt(row.nextResetTime); - let percent = normalizePercent(row.percentage); - if (percent === undefined) { - const used = toFiniteNumber(row.currentValue); - const total = toFiniteNumber(row.usage); - if (used !== undefined && total !== undefined && total > 0) { - percent = normalizePercent((used / total) * 100); - } - } - if (percent === undefined) continue; - const unit = toFiniteNumber(row.unit); - const number = toFiniteNumber(row.number); - if (unit === 3 && number === 5) { - quota.fiveHourPercent = percent; - if (resetAt !== undefined) quota.fiveHourResetAt = resetAt; - windows += 1; - } else if (unit === 6 && number === 1) { - quota.weeklyPercent = percent; - if (resetAt !== undefined) quota.weeklyResetAt = resetAt; - windows += 1; - } - } - return windows > 0 ? quota : null; -} - -/** - * Legacy Z.AI payload shape: percent fields with window identifiers directly on - * the data object (optionally nested under `quota`). Kept as a fallback so - * older responses keep rendering when the `limits` array is absent. - */ -function parseZaiQuotaLegacyFields(data: Record | null): ProviderQuota | null { - if (!data) return null; - const quota: ProviderQuota = { updatedAt: Date.now() }; - let windows = 0; - const percentAt = (key: string): number | undefined => { - const value = normalizePercent(data[key]); - if (value !== undefined) return value; - const nested = asRecord(data.quota); - return nested ? normalizePercent(nested[key]) : undefined; - }; - const fiveHour = percentAt("fiveHourPercent") ?? percentAt("fiveHourUsage") ?? percentAt("fiveHourUsed"); - const weekly = percentAt("weeklyPercent") ?? percentAt("weeklyUsage") ?? percentAt("weeklyUsed"); - const monthly = percentAt("monthlyPercent") ?? percentAt("mcpPercent") ?? percentAt("monthlyMCPUsage"); - if (fiveHour !== undefined) { - quota.fiveHourPercent = fiveHour; - windows += 1; - } - if (weekly !== undefined) { - quota.weeklyPercent = weekly; - windows += 1; - } - if (monthly !== undefined) { - quota.monthlyPercent = monthly; - windows += 1; - } - return windows > 0 ? quota : null; -} - -/** - * Fetches the Z.AI GLM Coding Plan quota — on whichever region the provider - * points at (api.z.ai or open.bigmodel.cn). The `limits` array shape is - * preferred; older field-name payloads fall back to the legacy parser. - * - * Authentication differs by host (issue #1168). `api.z.ai` takes the API key as - * a Bearer token per Z.AI's API reference; `open.bigmodel.cn` expects the key - * directly in `Authorization` with no scheme prefix and answers a Bearer header - * with an auth error, which is why BigModel Coding Plan quota never rendered. - * The host is already canonicalized by `isCanonicalZaiBaseUrl` above and - * `redirect: "error"` stays set, so the bare key cannot travel to a lookalike - * host or follow a redirect off-origin. - */ -async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promise { - const monitorHost = zaiQuotaMonitorHost(config.baseUrl); - if (!monitorHost) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); - if (!apiKey) return null; - const authorization = monitorHost === ZAI_CN_BASE_URL ? apiKey : `Bearer ${apiKey}`; - const response = await fetch(`${monitorHost}/api/monitor/usage/quota/limit`, { - headers: { Accept: "application/json", Authorization: authorization }, - redirect: "error", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) { - return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 - ? TERMINAL_QUOTA_FAILURE - : null; - } - const body = asRecord(await readQuotaJson(response)); - if (!body || body.success === false) return null; - const data = asRecord(body.data) ?? body; - if (Array.isArray(data?.limits)) { - const quota = parseZaiQuotaLimits(data); - // A well-formed `limits[]` we fully understood is authoritative even when it yields no - // model window — for example a plan reporting only the monthly MCP `TIME_LIMIT` row. - // Returning `null` here would preserve the previous token windows for up to 30 minutes - // and keep quota-aware routing acting on a report the provider has already superseded. - return quota - ? keyReport(provider, "zai:quota-limit", quota, config, apiKey, quota) - : AUTHORITATIVE_EMPTY_QUOTA; - } - const legacy = parseZaiQuotaLegacyFields(data); - if (!legacy) return null; - // The legacy monthly figure also carries MCP usage; it is display evidence, not - // proof that model inference is unavailable. Modern TOKEN_LIMIT rows above are scoped. - const inferenceQuota = { ...legacy }; - delete inferenceQuota.monthlyPercent; - delete inferenceQuota.monthlyResetAt; - return keyReport(provider, "zai:quota-limit", legacy, config, apiKey, inferenceQuota); -} - -/** - * MiniMax Token Plan `GET /v1/token_plan/remains` — the subscription's - * remaining quota as a countdown-time value (ms). The endpoint does not expose - * the plan's total duration, so no percentage is fabricated from a presumed - * window: the remaining time is reported as a duration-only window. When the - * API supplies a total (`total_time` / `plan_duration_ms`), a consumed share - * is derived from it. Region selects the host: `minimax` → www.minimax.io, - * `minimax-cn` → api.minimaxi.com. - */ -async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): Promise { - if (!isCanonicalMinimaxBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); - if (!apiKey) return null; - const cnHost = normalizedBaseUrl(config.baseUrl)?.startsWith("https://api.minimaxi.com"); - const remainsUrl = cnHost ? "https://api.minimaxi.com/v1/token_plan/remains" : MINIMAX_REMAINS_URL; - const response = await fetch(remainsUrl, { - headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, - redirect: "error", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) { - return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 - ? TERMINAL_QUOTA_FAILURE - : null; - } - const body = asRecord(await readQuotaJson(response)); - if (!body || body.success === false) return null; - const data = asRecord(body.data) ?? body; - const remainsMs = toFiniteNumber(data.remains_time ?? data.remainsTime); - if (remainsMs === undefined || remainsMs < 0) return null; - const hours = Math.floor(remainsMs / 3_600_000); - const label = `Token Plan remaining (${hours}h)`; - // Only derive a consumed share when the API actually reports the plan total; - // a presumed window (e.g. 30 days) would fabricate utilization. A valid - // response that omits the total after a prior refresh had it is a DELIBERATE - // contract change — the old row must be dropped (terminal), not preserved as - // a transient last-good. - const totalMs = toFiniteNumber(data.total_time ?? data.plan_duration_ms ?? data.total_duration_ms); - if (totalMs === undefined || totalMs <= 0) return TERMINAL_QUOTA_FAILURE; - const consumed = Math.max(0, totalMs - remainsMs); - const percent = normalizePercent((consumed / totalMs) * 100); - if (percent === undefined) return null; - return report(provider, "minimax:token-plan-remains", { - customWindows: [{ label, percent }], - updatedAt: Date.now(), - }); -} - -/** - * Moonshot/Kimi `GET /v1/users/me/balance` — the account's available balance - * (voucher + cash). Renders a single balance window against the sum of - * voucher + cash when positive (there is no per-window rate limit to meter). - */ -async function fetchMoonshotQuota(provider: string, config: OcxProviderConfig): Promise { - if (!isCanonicalMoonshotBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); - if (!apiKey) return null; - const host = normalizedBaseUrl(config.baseUrl)?.startsWith("https://api.moonshot.cn") ? "https://api.moonshot.cn/v1" : MOONSHOT_BASE_URL; - const response = await fetch(`${host}/users/me/balance`, { - headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, - redirect: "error", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) { - return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 - ? TERMINAL_QUOTA_FAILURE - : null; - } - const body = asRecord(await readQuotaJson(response)); - const data = asRecord(body?.data) ?? body; - if (!data) return null; - const available = toFiniteNumber(data.available_balance); - const voucher = toFiniteNumber(data.voucher_balance); - const cash = toFiniteNumber(data.cash_balance); - if (available === undefined || available < 0) return null; - // Moonshot exposes no per-window quota ceiling, only a balance — report it - // as a balance-only window (percent 0) rather than a fabricated utilization. - // Currency is host-scoped: China platform (api.moonshot.cn) bills in CNY; - // the international platform (api.moonshot.ai) bills in USD. Do not force - // either side into the other unit — the number is correct, only the unit - // must match the host. - const isChinaHost = host.startsWith("https://api.moonshot.cn"); - const money = (n: number) => isChinaHost ? `¥${n.toFixed(2)}` : `$${n.toFixed(2)}`; - const unit = isChinaHost ? "CNY" : "USD"; - const label = voucher !== undefined && cash !== undefined - ? `Balance (${money(available)} ${unit} available, ${money(voucher)} voucher)` - : `Balance (${money(available)} ${unit} available)`; - return report(provider, "moonshot:balance", { - customWindows: [{ label, percent: 0 }], - updatedAt: Date.now(), - }); -} - -/** - * Venice `GET /api/v1/billing/balance` — DIEM (native credits) or USD balance. - * Shows the remaining balance; epoch allocation progress when present. - */ -async function fetchVeniceQuota(provider: string, config: OcxProviderConfig): Promise { - if (!isCanonicalVeniceBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); - if (!apiKey) return null; - const response = await fetch(`${VENICE_BASE_URL}/billing/balance`, { - headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, - redirect: "error", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) { - return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 - ? TERMINAL_QUOTA_FAILURE - : null; - } - const body = asRecord(await readQuotaJson(response)); - const data = asRecord(body?.data) ?? body; - if (!data) return null; - const diemBalance = toFiniteNumber(data.balance); - const usdBalance = toFiniteNumber(data.balance_usd); - const epochUsed = toFiniteNumber(data.diem_epoch_used); - const epochAllocated = toFiniteNumber(data.diem_epoch_allocated); - if (diemBalance === undefined && usdBalance === undefined) return null; - const label = diemBalance !== undefined - ? `DIEM balance (${Math.round(diemBalance)})` - : `USD balance ($${usdBalance?.toFixed(2) ?? "?"})`; - if (epochAllocated !== undefined && epochAllocated > 0 && epochUsed !== undefined) { - const percent = normalizePercent((epochUsed / epochAllocated) * 100); - if (percent === undefined) return null; - return report(provider, "venice:billing-balance", { - customWindows: [{ label, percent }], - updatedAt: Date.now(), - }); - } - return report(provider, "venice:billing-balance", { - customWindows: [{ label, percent: 0 }], - updatedAt: Date.now(), - }); -} - -/** - * Synthetic `GET /v2/quotas` — the known quota lanes (rolling 5-hour, - * weekly token, search-hourly) mapped onto the quota windows. - */ -async function fetchSyntheticQuota(provider: string, config: OcxProviderConfig): Promise { - if (!isCanonicalSyntheticBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); - if (!apiKey) return null; - const response = await fetch(`${SYNTHETIC_BASE_URL}/quotas`, { - headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, - redirect: "error", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) { - return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 - ? TERMINAL_QUOTA_FAILURE - : null; - } - const body = asRecord(await readQuotaJson(response)); - const data = asRecord(body?.data) ?? body; - const quota: ProviderQuota = { updatedAt: Date.now() }; - let windows = 0; - const percentAt = (key: string): number | undefined => { - const value = normalizePercent(data?.[key]); - if (value !== undefined) return value; - const nested = asRecord(data?.quota) ?? asRecord(data?.quotas); - return nested ? normalizePercent(nested[key]) : undefined; - }; - const fiveHour = percentAt("rollingFiveHourLimit"); - const weekly = percentAt("weeklyTokenLimit"); - if (fiveHour !== undefined) { - quota.fiveHourPercent = fiveHour; - windows += 1; - } - if (weekly !== undefined) { - quota.weeklyPercent = weekly; - windows += 1; - } - const search = asRecord(data?.search); - const searchHourly = search ? normalizePercent(search.hourly) : undefined; - if (searchHourly !== undefined) { - quota.customWindows = [...(quota.customWindows ?? []), { label: "Search hourly", percent: searchHourly }]; - windows += 1; - } - const inferenceQuota = { ...quota }; - delete inferenceQuota.customWindows; // search.hourly does not constrain model inference. - return windows > 0 ? keyReport(provider, "synthetic:quotas", quota, config, apiKey, inferenceQuota) : null; -} - -/** - * DeepInfra `GET /payment/checklist?compute_owed=true` — prepaid balance, - * recent spend, spending limit, and suspension state. Renders a balance - * window (prepaid funds are a negative `stripe_balance` → positive available). - */ -async function fetchDeepInfraQuota(provider: string, config: OcxProviderConfig): Promise { - if (!isCanonicalDeepInfraBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); - if (!apiKey) return null; - const response = await fetch(`${DEEPINFRA_BASE_URL}/payment/checklist?compute_owed=true`, { - headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, - redirect: "error", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) { - return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 - ? TERMINAL_QUOTA_FAILURE - : null; - } - const body = asRecord(await readQuotaJson(response)); - const data = asRecord(body?.data) ?? body; - if (!data) return null; - const stripeBalance = toFiniteNumber(data.stripe_balance); - const spendLimit = toFiniteNumber(data.spending_limit); - const total = toFiniteNumber(data.total_amount_due); - if (stripeBalance === undefined) return null; - // Prepaid funds are negative; a positive value is money owed. - const available = stripeBalance < 0 ? -stripeBalance : 0; - if (spendLimit !== undefined && spendLimit > 0) { - const spent = total !== undefined && total > 0 ? total : Math.max(0, spendLimit - available); - const percent = normalizePercent((spent / spendLimit) * 100); - if (percent === undefined) return null; - return report(provider, "deepinfra:billing-checklist", { - customWindows: [{ label: `Billing cycle spend ($${spent.toFixed(2)} of $${spendLimit.toFixed(2)})`, percent }], - updatedAt: Date.now(), - }); - } - return report(provider, "deepinfra:billing-checklist", { - customWindows: [{ label: `Prepaid balance ($${available.toFixed(2)})`, percent: 0 }], - updatedAt: Date.now(), - }); -} - -/** - * Neuralwatt `GET /v1/quota` — subscription kWh usage (primary window) and - * prepaid USD credit balance (secondary). - */ -async function fetchNeuralwattQuota(provider: string, config: OcxProviderConfig): Promise { - if (!isCanonicalNeuralwattBaseUrl(config.baseUrl)) return null; - const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); - if (!apiKey) return null; - const response = await fetch(`${NEURALWATT_BASE_URL}/quota`, { - headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, - redirect: "error", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) { - return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 - ? TERMINAL_QUOTA_FAILURE - : null; - } - const body = asRecord(await readQuotaJson(response)); - const data = asRecord(body?.data) ?? body; - const quota: ProviderQuota = { updatedAt: Date.now() }; - let windows = 0; - const subscription = asRecord(data?.subscription); - const kwhUsed = subscription ? toFiniteNumber(subscription.kwh_used) : undefined; - const kwhIncluded = subscription ? toFiniteNumber(subscription.kwh_included) : undefined; - if (kwhUsed !== undefined && kwhIncluded !== undefined && kwhIncluded > 0) { - const percent = normalizePercent((kwhUsed / kwhIncluded) * 100); - if (percent !== undefined) { - quota.fiveHourPercent = percent; - const periodEnd = subscription ? normalizeResetAt(subscription.current_period_end) : undefined; - if (periodEnd !== undefined) quota.fiveHourResetAt = periodEnd; - windows += 1; - } - } - const balance = asRecord(data?.balance); - const totalCredits = balance ? toFiniteNumber(balance.total_credits_usd) : undefined; - const remainingCredits = balance ? toFiniteNumber(balance.credits_remaining_usd) : undefined; - if (totalCredits !== undefined && totalCredits > 0 && remainingCredits !== undefined) { - // Utilization is CONSUMED credits, not the remaining share. - const used = Math.max(0, totalCredits - remainingCredits); - const percent = normalizePercent((used / totalCredits) * 100); - if (percent !== undefined) { - quota.customWindows = [...(quota.customWindows ?? []), { label: "Prepaid credits", percent }]; - windows += 1; - } - } - return windows > 0 ? report(provider, "neuralwatt:quota", quota) : null; -} - -function report( - provider: string, - source: string, - quota: ProviderQuota, - aggregation?: CodexCapacityAggregation, -): ProviderQuotaReport | null { - if (!hasQuotaRows(quota)) return null; - return { - provider, - label: providerLabel(provider), - source, - quota, - updatedAt: quota.updatedAt, - ...(aggregation ? { aggregation } : {}), - }; -} - -/** - * Publish a credential-bound report, and routing evidence only when the producer - * hands over its inference-only projection. - * - * The projection is deliberately not defaulted to the display quota. A producer must - * decide that its rows really do constrain inference on the probed credential; omitting - * the argument leaves the report display-only, so a new producer cannot inherit - * provider-veto authority merely by calling this helper. Ownership alone is not the - * scope decision: providerQuotaRoutingBinding resolving is necessary, never sufficient. - */ -function keyReport( - provider: string, - source: string, - quota: ProviderQuota, - config: OcxProviderConfig, - probedCredential: string, - inferenceQuota?: ProviderQuota, -): ProviderQuotaReport | null { - const result = report(provider, source, quota); - if (!result || !inferenceQuota) return result; - const binding = providerQuotaRoutingBinding(provider, config, probedCredential); - if (binding) routingEvidence.set(result, { quota: inferenceQuota, binding }); - return result; -} - -function tagNativeMainReport( - value: ProviderQuotaReport | null, - generation: number, -): ProviderQuotaReport | null { - if (value) nativeMainReportGenerations.set(value, generation); - return value; -} - -/** - * Test-only seam: publish exactly as a credential-bound producer does, and hand back the - * routing evidence the publication actually attached. - * - * Live producers all pass a projection today, so no probe fixture can prove the OTHER half - * of the contract: that omitting it stays display-only. Routing an omitted argument through - * the real helper keeps that provable, and a re-introduced `= quota` default would be - * observed here (a defaulted parameter also fires for an explicitly undefined argument). - */ -export function publishKeyReportForTests( - provider: string, - source: string, - quota: ProviderQuota, - config: OcxProviderConfig, - probedCredential: string, - inferenceQuota?: ProviderQuota, -): { report: ProviderQuotaReport | null; routing: ProviderQuotaRoutingEvidence | undefined } { - const result = keyReport(provider, source, quota, config, probedCredential, inferenceQuota); - return { report: result, routing: result ? routingEvidence.get(result) : undefined }; -} - -function isProviderQuotaReportCurrent(value: ProviderQuotaReport): boolean { - const generation = nativeMainReportGenerations.get(value); - return (generation === undefined || isMainAccountIdentityGenerationLive(generation)) - && (accountReportCurrent.get(value)?.() ?? true); -} - -async function fetchChatGptForwardQuota( - config: OcxConfig, - provider: string, - providerConfig: OcxProviderConfig, - forceRefresh: boolean, - prefetchedSnapshot?: CodexAuthAccountsSnapshotPromise, -): Promise { - if (providerCodexAccountMode(provider, providerConfig) === "direct") { - const snapshot = await fetchMainAccountInfoSnapshot(forceRefresh); - const quota = providerQuotaFromCodexQuota(snapshot.info.quota); - if (quota) quota.updatedAt = Date.now(); - return quota - ? tagNativeMainReport(report(provider, "chatgpt:wham", quota), snapshot.mainIdentityGeneration) - : null; - } - const snapshot = await (prefetchedSnapshot ?? listCodexAuthAccountsSnapshot(config, forceRefresh)); - const accounts = snapshot.accounts; - const activeId = effectiveCodexAuthAccountId(config); - const capacityAccounts = accounts.map(account => ({ - ...account, - active: account.id === activeId, - quota: providerQuotaFromCodexQuota(account.quota), - })); - const active = capacityAccounts.find(account => account.active) - ?? capacityAccounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID) - ?? capacityAccounts[0]; - const now = Date.now(); - const capacity = aggregateCodexPoolCapacity(capacityAccounts, now); - if (capacity.aggregation && capacity.quota) { - return tagNativeMainReport( - report( - provider, - "chatgpt:wham", - capacity.quota as ProviderQuota, - publicCapacityAggregation(capacity.aggregation, "aggregate"), - ), - snapshot.mainIdentityGeneration, - ); - } - const activeUsable = !!active && !active.paused && active.needsReauth !== true; - const quota = activeUsable && active?.quota - ? { ...active.quota, updatedAt: active.quota.updatedAt ?? Date.now() } as CodexCapacityQuota - : null; - const quotaFresh = !!quota - && Number.isFinite(quota.updatedAt) - && now - quota.updatedAt < CODEX_CAPACITY_MAX_QUOTA_AGE_MS; - if (quota && quotaFresh) { - const fallback = report( - provider, - "chatgpt:wham", - quota as ProviderQuota, - capacity.aggregation - ? publicCapacityAggregation(capacity.aggregation, "effective-account-fallback") - : undefined, - ); - return tagNativeMainReport(fallback, snapshot.mainIdentityGeneration); - } - if (capacity.aggregation) { - const updatedAt = Date.now(); - return tagNativeMainReport( - { - provider, - label: providerLabel(provider), - source: "chatgpt:wham", - quota: { updatedAt }, - updatedAt, - aggregation: publicCapacityAggregation(capacity.aggregation, "coverage-only"), - }, - snapshot.mainIdentityGeneration, - ); - } - return null; -} - -function centsValue(value: unknown): number | undefined { - const rec = asRecord(value); - return rec ? toFiniteNumber(rec.val) : undefined; -} - -/** Decode JWT payload `sub` for xAI weekly credits when the stored credential lacks accountId. */ -function xaiUserIdFromAccessToken(accessToken: string): string | undefined { - const parts = accessToken.split("."); - if (parts.length < 2 || !parts[1]) return undefined; - try { - const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as { sub?: unknown }; - return typeof payload.sub === "string" && payload.sub.trim() ? payload.sub.trim() : undefined; - } catch { - return undefined; - } -} - -/** - * Grok Build weekly credits envelope: - * `{ config: { creditUsagePercent?, currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", end } } }`. - * Omitted percent is treated as 0 (proto3 default). - */ -export function parseXaiCreditsResponse(value: unknown): { percent: number; resetAt?: number } | null { - const body = asRecord(value); - const config = asRecord(body?.config); - if (!config) return null; - const period = asRecord(config.currentPeriod); - if (!period || period.type !== "USAGE_PERIOD_TYPE_WEEKLY") return null; - let percent = 0; - if (config.creditUsagePercent !== undefined) { - const normalized = normalizePercent(config.creditUsagePercent); - if (normalized === undefined) return null; - percent = normalized; - } - const resetAt = normalizeResetAt(period.end); - return { - percent, - ...(resetAt !== undefined ? { resetAt } : {}), - }; -} - -async function fetchXaiWeeklyCredits(accessToken: string, userId: string): Promise { - try { - const response = await fetch(XAI_CREDITS_URL, { - redirect: "error", - headers: { - Accept: "application/json", - Authorization: `Bearer ${accessToken}`, - [XAI_GROK_COMPATIBILITY.headers.tokenAuth]: "xai-grok-cli", - [XAI_GROK_COMPATIBILITY.headers.authenticateResponse]: "authenticate-response", - "x-userid": userId, - [XAI_GROK_COMPATIBILITY.headers.clientVersion]: XAI_GROK_CLIENT_VERSION, - }, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) return null; - const parsed = parseXaiCreditsResponse(await readQuotaJson(response)); - if (!parsed) return null; - return { - weeklyPercent: parsed.percent, - ...(parsed.resetAt !== undefined ? { weeklyResetAt: parsed.resetAt } : {}), - updatedAt: Date.now(), - }; - } catch { - return null; - } -} - -async function fetchXaiQuota(provider: string, context: { accessToken: string; upstreamAccountId?: string }): Promise { - const { accessToken } = context; - - // Prefer the SuperGrok weekly credits window that actually gates prompting (#1283). - const userId = context.upstreamAccountId?.trim() || xaiUserIdFromAccessToken(accessToken); - if (userId) { - const weekly = await fetchXaiWeeklyCredits(accessToken, userId); - if (weekly) return report(provider, "xai:grok-billing-credits", weekly); - } - - // Legacy monthly dollar pool — retained when weekly is unavailable. - try { - const response = await fetch(XAI_BILLING_URL, { - redirect: "error", - headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` }, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) return null; - const body = asRecord(await readQuotaJson(response)); - const config = asRecord(body?.config); - if (!config) return null; - const limitCents = centsValue(config.monthlyLimit); - const usedCents = centsValue(config.used); - if (limitCents === undefined || usedCents === undefined || limitCents <= 0) return null; - const percent = normalizePercent((usedCents / limitCents) * 100); - if (percent === undefined) return null; - return report(provider, "xai:grok-billing", { - monthlyPercent: percent, - monthlyResetAt: normalizeResetAt(config.billingPeriodEnd), - updatedAt: Date.now(), - }); - } catch { - return null; - } -} - -function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number } | null { - const rec = asRecord(value); - if (!rec) return null; - const percent = normalizePercent(rec.utilization); - const resetAt = normalizeResetAt(rec.resets_at); - if (percent === undefined && resetAt === undefined) return null; - return { percent, resetAt }; -} - -function parseClaudeLimit(value: unknown): { label: string; percent: number; resetAt?: number } | null { - const rec = asRecord(value); - if (!rec) return null; - const percent = normalizePercent(rec.percent); - if (percent === undefined) return null; - const scope = asRecord(rec.scope); - const model = asRecord(scope?.model); - const rawLabel = String(model?.display_name ?? "").trim(); - if (!rawLabel) return null; - const lowerLabel = rawLabel.toLowerCase(); - const label = lowerLabel.includes("fable") ? "Fable" - : lowerLabel.includes("opus") ? "Opus" - : lowerLabel.includes("sonnet") ? "Sonnet" - : rawLabel; - const resetAt = normalizeResetAt(rec.resets_at); - return { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) }; -} - -/** Claude's OAuth usage endpoint, probed with ONE account's own bearer token. */ -const anthropicUsageInflight = new Map>(); - -/** - * Anthropic per-credential usage. - * - * This endpoint reports quota only. Its body carries `five_hour`, `seven_day`, the - * model-scoped weekly buckets (`seven_day_fable`/`_opus`/`_sonnet`) and a `limits` array, - * and **no subscription or tier field** — nor does the OAuth token response, which yields only - * `account.uuid` and `account.email_address` (`src/oauth/anthropic.ts`). That is why - * `OAuthAccountSummary.plan` is `null` for Anthropic rather than populated here (#3777); it is - * a missing upstream field, not an unfinished mapping. - * - * A tier must not be inferred from what is here. Percentages are normalized per account, so a - * Max x5 seat at 50% is byte-identical to a Max x20 seat at 50%, and the presence of a - * model-scoped window tracks entitlement rather than seat size. Populate `plan` only when - * upstream returns the tier itself. - */ -async function fetchAnthropicUsageQuota(accessToken: string): Promise { - const joinable = anthropicUsageInflight.get(accessToken); - if (joinable) return joinable; - - const probe = (async (): Promise => { - const response = await fetch("https://api.anthropic.com/api/oauth/usage", { - headers: { - Accept: "application/json, text/plain, */*", - "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.63 (external, cli)", - "anthropic-beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05", - Authorization: `Bearer ${accessToken}`, - }, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) return null; - const body = asRecord(await readQuotaJson(response)); - if (!body) return null; - const fiveHour = parseClaudeBucket(body.five_hour); - const sevenDay = parseClaudeBucket(body.seven_day); - const fable = parseClaudeBucket(body.seven_day_fable); - const opus = parseClaudeBucket(body.seven_day_opus); - const sonnet = parseClaudeBucket(body.seven_day_sonnet); - const customWindows: ProviderQuotaWindow[] = []; - if (fable?.percent !== undefined) customWindows.push({ label: "Fable", percent: fable.percent, ...(fable.resetAt !== undefined ? { resetAt: fable.resetAt } : {}) }); - if (opus?.percent !== undefined) customWindows.push({ label: "Opus", percent: opus.percent, ...(opus.resetAt !== undefined ? { resetAt: opus.resetAt } : {}) }); - if (sonnet?.percent !== undefined) customWindows.push({ label: "Sonnet", percent: sonnet.percent, ...(sonnet.resetAt !== undefined ? { resetAt: sonnet.resetAt } : {}) }); - const knownLabels = new Set(customWindows.map(window => window.label.toLowerCase())); - const limits = Array.isArray(body.limits) ? body.limits : []; - for (const rawLimit of limits) { - const limitRecord = asRecord(rawLimit); - // `session` and `weekly_all` mirror the canonical five-hour and weekly - // buckets above; only model-scoped weekly limits add a third window. - if (String(limitRecord?.kind ?? "").trim().toLowerCase() !== "weekly_scoped") continue; - const limit = parseClaudeLimit(rawLimit); - if (!limit || knownLabels.has(limit.label.toLowerCase())) continue; - knownLabels.add(limit.label.toLowerCase()); - customWindows.push(limit); - } - const quota: ProviderQuota = { - // Claude's 5-hour window is a first-class rate limit, same as the Codex login 5h/weekly - // rows: report it in the canonical fields so the dashboard renders it with the standard - // "5-hour limit" label and ordering instead of as a generic extra window. - ...(fiveHour?.percent !== undefined ? { fiveHourPercent: fiveHour.percent } : {}), - ...(fiveHour?.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}), - ...(sevenDay?.percent !== undefined ? { weeklyPercent: sevenDay.percent } : {}), - ...(sevenDay?.resetAt !== undefined ? { weeklyResetAt: sevenDay.resetAt } : {}), - ...(customWindows.length > 0 ? { customWindows } : {}), - updatedAt: Date.now(), - }; - // Empty / schema-changed payloads must not cache as "success with no bars". - return hasQuotaRows(quota) ? quota : null; - })().finally(() => { - if (anthropicUsageInflight.get(accessToken) === probe) anthropicUsageInflight.delete(accessToken); - }); - anthropicUsageInflight.set(accessToken, probe); - return probe; -} - -async function fetchAnthropicQuota(provider: string): Promise { - // Capture the account we intend to probe before awaiting — a mid-flight active - // switch must not seed the wrong account's cache with this response. - const probedAccountId = getAccountSet("anthropic")?.activeAccountId; - const probedAccountKey = probedAccountId ? accountCacheKey("anthropic", probedAccountId) : null; - const writerGeneration = captureConfigGeneration(); - let accessToken: string; - try { - accessToken = await getValidAccessToken("anthropic"); - } catch { - return null; - } - const quota = await fetchAnthropicUsageQuota(accessToken); - if (!quota) return null; - // Share the active-account probe with the per-account cache so Providers-page - // loads do not double-hit Anthropic's rate-limited usage endpoint. - if (probedAccountId && probedAccountKey) { - const stillOwnsToken = getAccountCredential("anthropic", probedAccountId)?.access === accessToken; - if (stillOwnsToken && mayCommitAccountQuotaKey(probedAccountKey, writerGeneration)) { - accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota }); - } - } - return report(provider, "anthropic:oauth-usage", quota); -} - -/** - * Provider-level Kiro row: the active account's usage, shown on the Providers page. - * - * The per-account cache is seeded from the same probe so opening that page does not read - * the active account twice, and the account id is captured before the await so a - * concurrent account switch cannot file this answer under the wrong account. - */ -async function fetchKiroQuota(provider: string): Promise { - const probedAccountId = getAccountSet("kiro")?.activeAccountId; - if (!probedAccountId) return null; - const probedAccountKey = accountCacheKey("kiro", probedAccountId); - const writerGeneration = captureConfigGeneration(); - let snapshot: KiroUsageSnapshot | null; - try { - snapshot = await fetchKiroUsageSnapshot(await kiroUsageContextForAccount(probedAccountId)); - } catch { - return null; - } - if (!snapshot) return null; - if (mayCommitAccountQuotaKey(probedAccountKey, writerGeneration)) { - accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota: snapshot.quota }); - commitKiroAccountUsageState(probedAccountKey, snapshot); - } - return report(provider, "kiro:usage-limits", snapshot.quota); -} - -/** - * Provider-level row probed from the key endpoint, for an account that CAN be probed. - * - * Written through the same account cache the passive path reads, so the measurement - * survives a restart and the per-account rows at oauth-account-routes.ts:313 pick it up - * with no mode change. Deliberately does not flip providerOAuthAccountQuotaMode: that - * mode selects readPassiveProviderAccountQuotas, and the probed per-account path it would - * switch to is gated on supportsPerAccountQuota, which has no meta-muse reader, so the - * GUI account list would go from showing observations to showing nothing. - */ -async function fetchMuseKeyQuota(provider: string): Promise { - const probedAccountId = getAccountSet(provider)?.activeAccountId; - if (!probedAccountId) return null; - const oauthAccessToken = getAccountCredential(provider, probedAccountId)?.muse?.oauthAccessToken; - // An imported or pasted credential has no account token and never will: it is - // capability, not provider id, that decides whether a probe is possible. - if (!oauthAccessToken) return null; - const probedAccountKey = accountCacheKey(provider, probedAccountId); - const writerGeneration = captureConfigGeneration(); - const quota = await fetchMuseKeyQuotaSnapshot(probedAccountId, oauthAccessToken); - if (!quota) return null; - if (mayCommitAccountQuotaKey(probedAccountKey, writerGeneration)) { - // Hydrate before writing, for the same reason recordPassiveAccountQuota does: - // persistAccountQuotaCache serializes the whole in-memory map. - hydrateAccountQuotaCache(); - accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota }); - persistAccountQuotaCache(); - } - return report(provider, `${provider}:key-endpoint`, quota); -} -/** - * Provider-level row for a passive provider: the ACTIVE account's last observed - * subscription windows, the same shape `fetchAnthropicQuota` and `fetchKiroQuota` - * return. - * - * Cache-only. A dashboard load or `ocx account refresh` must never spend an inference - * turn, so `forceRefresh` does not exist on this path — there is nothing to refresh. - * `report.updatedAt` is the observation time, which is what both GUI surfaces render - * as the relative age of the row. - */ -async function fetchPassiveProviderQuota(provider: string): Promise { - const activeId = getAccountSet(provider)?.activeAccountId; - if (!activeId) return null; - // Idempotent; without it a proxy restart shows nothing until the next streaming turn - // even though the last observation is on disk. - hydrateAccountQuotaCache(); - const entry = accountQuotaCache.get(accountCacheKey(provider, activeId)); - if (!entry?.quota) return null; - const built = report(provider, `${provider}:subscription-observation`, entry.quota); - // Tagged here rather than inside report(), which every probed path shares. - return built ? { ...built, observed: true } : null; -} - -// --------------------------------------------------------------------------- -// Per-account quota (multiauth) -// --------------------------------------------------------------------------- - -/** - * Anthropic and Kiro both report usage per CREDENTIAL, so every logged-in account can be - * probed with its own bearer token — the active-account selection and the local usage log - * are irrelevant here. Mirrors the Codex pool behaviour - * (codex/auth-api.ts:fetchPoolAccountQuota), including a per-account TTL so N accounts cost - * at most N upstream calls per window. `ACCOUNT_QUOTA_TTL_MS` lives in `quota-wire.ts` - * because the Kiro exhaustion reader applies the same staleness bound. - */ -type AccountQuotaCacheEntry = { - ts: number; - quota: ProviderQuota | null; - /** Last probe failed (429 / network / expired login); still may hold last-good quota. */ - unavailable?: true; - quotaFailure?: QuotaFailureCode; - quotaFailureIsCurrent?: () => boolean; - /** Private new-reader identity; never persisted or serialized. */ - identity?: string; - isCurrent?: () => boolean; -}; -/** Expired measurements become unknown; missing reset evidence never implies a fresh allowance. */ -function normalizeAnthropicQuota(quota: ProviderQuota | null | undefined, now: number): ProviderQuota | null { - if (!quota) return null; - const validReset = (resetAt: unknown): resetAt is number => typeof resetAt === "number" - && Number.isFinite(resetAt) && resetAt > 0 && Number.isFinite(new Date(resetAt).getTime()); - let result = quota; - for (const [percent, reset] of [ - ["fiveHourPercent", "fiveHourResetAt"], - ["weeklyPercent", "weeklyResetAt"], - ["monthlyPercent", "monthlyResetAt"], - ] as const) { - const resetAt = quota[reset]; - if (resetAt === undefined) continue; - const valid = validReset(resetAt); - if (valid && resetAt > now) continue; - if (result === quota) result = { ...quota }; - if (valid) delete result[percent]; - delete result[reset]; - } - // Persisted rows validate only the outer quota object, so custom data may be malformed. - if (quota.customWindows !== undefined) { - const windows = Array.isArray(quota.customWindows) ? quota.customWindows : []; - const retained: ProviderQuotaWindow[] = []; - let changed = !Array.isArray(quota.customWindows); - for (const window of windows) { - if (!window || typeof window !== "object" || typeof window.label !== "string" || !window.label.trim() - || typeof window.percent !== "number" || !Number.isFinite(window.percent) - || window.percent < 0 || window.percent > 100) { - changed = true; - continue; - } - if (validReset(window.resetAt) && window.resetAt <= now) { - changed = true; - continue; - } - if (window.resetAt !== undefined && !validReset(window.resetAt)) { - const normalized = { ...window }; - delete normalized.resetAt; - retained.push(normalized); - changed = true; - } else { - retained.push(window); - } - } - if (changed) { - if (result === quota) result = { ...quota }; - if (retained.length) result.customWindows = retained; - else delete result.customWindows; - } - } - return hasQuotaRows(result) ? result : null; -} - -const accountQuotaCache = new Map(); -let explicitAccountEpoch = 0; - -/** - * Seed the cache from the last run, once. - * - * Without this a restart forgets every measurement, so the pool opens its next turn with - * no idea which account has room — the exact blindness pre-dispatch selection exists to - * remove. A hydrated row is still subject to the ordinary TTL, so it orders the first - * request and is replaced by a live probe immediately after. - */ -let diskHydrated = false; -function hydrateAccountQuotaCache(): void { - if (diskHydrated) return; - diskHydrated = true; - for (const [key, quota] of readPersistedAccountQuotas()) { - // Disk stores observation time, not the Anthropic usage probe's clock. - if (!accountQuotaCache.has(key)) { - const anthropic = key.startsWith("anthropic\u0000"); - accountQuotaCache.set(key, { - ts: anthropic ? 0 : quota.updatedAt, - quota: anthropic ? normalizeAnthropicQuota(quota, Date.now()) : quota, - }); - } - } -} - -function persistAccountQuotaCache(): void { - schedulePersistAccountQuotas(function* () { - const now = Date.now(); - for (const [key, entry] of accountQuotaCache) { - const quota = key.startsWith("anthropic\u0000") ? normalizeAnthropicQuota(entry.quota, now) : entry.quota; - if (quota) yield [key, quota] as [string, ProviderQuota]; - } - }); -} -const accountQuotaInflight = new Map>(); -let lastReconciledGeneration = 0; -let liveAccountQuotaKeys = new Set(); -let liveProviderQuotaKeys = new Set(); - -function mayCommitAccountQuotaKey(key: string, writerGeneration: number): boolean { - return writerGeneration >= lastReconciledGeneration || liveAccountQuotaKeys.has(key); -} - -function mayCommitProviderQuotaKey(key: string, writerGeneration: number): boolean { - return writerGeneration >= lastReconciledGeneration || liveProviderQuotaKeys.has(key); -} - -export interface ProviderAccountQuota { - accountId: string; - quota: ProviderQuota | null; - /** Set when the probe could not reach upstream (expired login, 429, network). */ - unavailable?: true; - quotaFailure?: QuotaFailureCode; - quotaFailureIsCurrent?: () => boolean; - isCurrent?: () => boolean; -} - -/** Providers whose per-account quota can be probed. Extend as other OAuth APIs are covered. */ -export function supportsPerAccountQuota(provider: string): boolean { - return provider === "anthropic" || provider === "kiro" || provider === "google-antigravity" - || explicitAccountReader(provider); -} - -function explicitAccountReader(provider: string): boolean { - return provider === "xai" || provider === "cursor" || provider === "kimi" || provider === "command-code"; -} - -export function providerOAuthAccountQuotaMode(provider: string): AccountQuotaMode { - return hasPassiveAccountQuota(provider) ? "passive" : supportsPerAccountQuota(provider) ? "probe" : "unsupported"; -} - -function accountCacheKey(provider: string, accountId: string): string { - return `${provider}\u0000${accountId}`; -} - -/** - * Synchronous last-good per-account quota read for routing. Never probes the network. - * Returns null when nothing is cached (or the cached row has no bars). - */ -export function getCachedProviderAccountQuota(provider: string, accountId: string): ProviderQuota | null { - const entry = accountQuotaCache.get(accountCacheKey(provider, accountId)); - if (entry?.isCurrent && !entry.isCurrent()) return null; - return provider === "anthropic" ? normalizeAnthropicQuota(entry?.quota, Date.now()) : entry?.quota ?? null; -} - -/** Test-only: seed or clear the per-account quota cache without probing upstream. */ -export function setCachedProviderAccountQuotaForTests( - provider: string, - accountId: string, - quota: ProviderQuota | null, -): void { - const key = accountCacheKey(provider, accountId); - if (quota === null) { - accountQuotaCache.delete(key); - return; - } - accountQuotaCache.set(key, { ts: Date.now(), quota }); -} - -/** Unified headers report utilization fractions and epoch-second reset times. */ -function anthropicHeaderResetAt(value: string | null): number | undefined { - const seconds = toFiniteNumber(value); - if (seconds === undefined || seconds <= 0) return undefined; - const timestamp = seconds * 1000; - return Number.isFinite(new Date(timestamp).getTime()) ? timestamp : undefined; -} - -export function parseAnthropicRateLimitHeaders(headers: Headers): ProviderQuota | null { - const fiveHourPercent = normalizeUtilizationFraction(headers.get("anthropic-ratelimit-unified-5h-utilization")); - const weeklyPercent = normalizeUtilizationFraction(headers.get("anthropic-ratelimit-unified-7d-utilization")); - if (fiveHourPercent === undefined && weeklyPercent === undefined) return null; - const fiveHourResetAt = anthropicHeaderResetAt(headers.get("anthropic-ratelimit-unified-5h-reset")); - const weeklyResetAt = anthropicHeaderResetAt(headers.get("anthropic-ratelimit-unified-7d-reset")); - return { - ...(fiveHourPercent !== undefined ? { fiveHourPercent } : {}), - ...(fiveHourPercent !== undefined && fiveHourResetAt !== undefined ? { fiveHourResetAt } : {}), - ...(weeklyPercent !== undefined ? { weeklyPercent } : {}), - ...(weeklyPercent !== undefined && weeklyResetAt !== undefined ? { weeklyResetAt } : {}), - updatedAt: Date.now(), - }; -} - -/** Reject unknown scales; round fraction conversion for persisted/displayed percentages. */ -function normalizeUtilizationFraction(value: string | null): number | undefined { - const numeric = toFiniteNumber(value); - if (numeric === undefined || numeric < 0 || numeric > 1) return undefined; - return Math.round(numeric * 10_000) / 100; -} - -/** - * Merge serving-account observations without advancing the usage probe's clock or - * erasing model-specific windows. The caller owns credential attribution; this guard - * prevents a retired account key from being revived by an older config generation. - */ -export function recordAnthropicAccountQuotaFromHeaders( - accountId: string, - headers: Headers, - writerGeneration: number, -): void { - if (!accountId) return; - const observed = parseAnthropicRateLimitHeaders(headers); - if (!observed) return; - const key = accountCacheKey("anthropic", accountId); - if (!mayCommitAccountQuotaKey(key, writerGeneration)) return; - // Hydrate before writing, for the same reason `recordPassiveAccountQuota` does: this write - // arrives unprompted from the request path, and `persistAccountQuotaCache` serializes the - // whole map. Landing before any reader has hydrated would persist this single row and erase - // every other provider's saved row. - hydrateAccountQuotaCache(); - const previous = accountQuotaCache.get(key); - accountQuotaCache.set(key, { - ...previous, - // Headers do not prove that the last usage probe succeeded. - ts: previous?.ts ?? 0, - quota: normalizeAnthropicQuota({ - ...normalizeAnthropicQuota(previous?.quota, observed.updatedAt), ...observed, - }, observed.updatedAt), - }); - persistAccountQuotaCache(); -} - -/** - * Providers whose per-account quota is OBSERVED in-band, never probed. - * - * Deliberately separate from `supportsPerAccountQuota` rather than folded into it. That - * predicate gates explicit upstream readers. Meta publishes no quota endpoint, so it - * remains a cache-only observation even when every probe reader is account-scoped. - */ -export function hasPassiveAccountQuota(provider: string): boolean { - return provider === "meta-muse"; -} - -/** - * Record a quota observed in-band on a streaming turn. - * - * The CALLER captures `writerGeneration` when it resolves the serving credential, not - * this function at write time. A streaming turn is a long await, and a generation - * captured immediately before the write cannot see a config or account change that - * happened EARLIER in the same turn — which is exactly the case the fence exists for. - */ -export function recordPassiveAccountQuota( - provider: string, - accountId: string, - quota: ProviderQuota, - writerGeneration: number, -): void { - if (!hasPassiveAccountQuota(provider) || !accountId) return; - const key = accountCacheKey(provider, accountId); - if (!mayCommitAccountQuotaKey(key, writerGeneration)) return; - // Hydrate BEFORE writing, not only on the read path. `persistAccountQuotaCache` - // serializes the whole in-memory map, so a passive write that lands before anything - // has read the cache would persist this one row and erase every other provider's - // saved row -- and `diskHydrated` would then stop any later reader from recovering - // them. A probe writer cannot hit this because its own read hydrates first; an - // observation arrives unprompted, so it must hydrate itself. - hydrateAccountQuotaCache(); - accountQuotaCache.set(key, { ts: Date.now(), quota }); - // Persisted so a restart keeps the last observation: with no probe to re-establish it, - // a forgotten row stays forgotten until the user happens to run another streaming turn. - persistAccountQuotaCache(); - // sweepExpiredOnWrite is deliberately NOT called. Existing probe writers call it - // because they run on a poll; this runs on the request path, where a state sweep does - // not belong. Passive rows are still reclaimed by generation reconciliation - // (reconcileProviderAccountQuotaRows) and by the disk reader's age bound. -} - -/** - * Cache-only per-account rows for a passive provider. Never probes, never refreshes. - * - * An account with no observation is OMITTED rather than returned with `quota: null` and - * `unavailable`: that pair means "a probe was attempted and failed", and no probe was - * ever attempted here. A user who has not yet run a streaming turn simply has no - * measurement, which is not an error state. - */ -export function readPassiveProviderAccountQuotas(provider: string): ProviderAccountQuota[] { - if (!hasPassiveAccountQuota(provider)) return []; - // Idempotent, and otherwise only reached from probe paths a passive provider never - // enters — without it a restart shows nothing until the next streaming turn, even - // though the row is sitting on disk. - hydrateAccountQuotaCache(); - const set = getAccountSet(provider); - if (!set) return []; - const rows: ProviderAccountQuota[] = []; - for (const account of set.accounts) { - const entry = accountQuotaCache.get(accountCacheKey(provider, account.id)); - if (entry?.quota) rows.push({ accountId: account.id, quota: entry.quota }); - } - return rows; -} - -export function sweepExpiredProviderAccountQuotaRows(now = Date.now()): number { - let removed = 0; - for (const [key, entry] of accountQuotaCache) { - // Anthropic observations extend retention, never the usage probe's eligibility clock. - const retainedAt = key.startsWith("anthropic\u0000") - ? Math.max(entry.ts, entry.quota?.updatedAt ?? 0) - : entry.ts; - if (retainedAt + ACCOUNT_QUOTA_TTL_MS > now) continue; - accountQuotaCache.delete(key); - removed += 1; - } - return removed; -} - -export function reconcileProviderAccountQuotaRows(context: GenerationContext): number { - if (context.generation <= lastReconciledGeneration) return 0; - let removed = 0; - for (const key of accountQuotaCache.keys()) { - if (context.oauthAccountKeys.has(key)) continue; - accountQuotaCache.delete(key); - removed += 1; - } - // Kiro exhaustion rows are keyed identically, so they retire with their quota row; a - // verdict outliving its account would hand the replacement a cooldown it never earned. - removed += reconcileKiroAccountUsageState(context.oauthAccountKeys); - if (cache) { - const reports = cache.response.reports.filter(report => context.providerNames.has(report.provider)); - removed += cache.response.reports.length - reports.length; - cache = { ...cache, response: { ...cache.response, reports } }; - replaceCachedProviderQuotas(reports, routingEvidence); - } - liveAccountQuotaKeys = new Set(context.oauthAccountKeys); - liveProviderQuotaKeys = new Set(context.providerNames); - lastReconciledGeneration = context.generation; - return removed; -} - -/** Test-only reset so a direct reconcile call in one file cannot leak across files. */ -export function resetProviderQuotaReconcileStateForTests(): void { - lastReconciledGeneration = 0; - liveAccountQuotaKeys = new Set(); - liveProviderQuotaKeys = new Set(); -} - -/** Drop cached per-account rows (all, or just one provider's). */ -export function clearAccountQuotaCache(provider?: string): void { - explicitAccountEpoch += 1; - if (!provider) { - accountQuotaCache.clear(); - accountQuotaInflight.clear(); - clearKiroAccountUsageState(); - // A cleared cache must not be re-seeded from the file it was just cleared of, and any - // pending write of the old rows is abandoned. - diskHydrated = false; - cancelPendingAccountQuotaPersist(); - return; - } - const prefix = `${provider}\u0000`; - for (const key of [...accountQuotaCache.keys()]) { - if (key.startsWith(prefix)) accountQuotaCache.delete(key); - } - clearKiroAccountUsageState(prefix); - // Drop in-flight probes too so a late resolve cannot repopulate after logout/remove. - for (const key of [...accountQuotaInflight.keys()]) { - if (key.startsWith(prefix)) accountQuotaInflight.delete(key); - } - persistAccountQuotaCache(); -} - -/** - * Resolve a bearer for quota probing without silently adopting a newer global - * Claude CLI credential into a background multiauth slot. - * - * - Fresh stored access → use as-is (no refresh). - * - Active account with expired access → normal refresh path. - * - Background `local-cli` with expired access → fail closed (unavailable): - * `getValidAccessTokenForAccount` can persist a mismatched Claude CLI identity. - * - Background ordinary OAuth (`source !== "local-cli"`) → safe to refresh; - * Anthropic's lock only adopts disk credentials for `local-cli` rows. - */ -async function getTokenForAccountQuotaProbe(provider: string, accountId: string): Promise { - const stored = getAccountCredential(provider, accountId); - if (!stored) throw new Error("account credential missing"); - if (stored.expires > Date.now() + ACCOUNT_TOKEN_SKEW_MS) return stored.access; - const activeId = getAccountSet(provider)?.activeAccountId; - if (activeId !== accountId && stored.source === "local-cli") { - throw new Error("background local-cli token expired; skip CLI-adopting refresh for quota probe"); - } - return getValidAccessTokenForAccount(provider, accountId); -} - -function explicitQuotaConfig(provider: string, configured?: OcxProviderConfig): OcxProviderConfig | undefined { - if (configured) return configured; - const entry = getProviderRegistryEntry(provider); - return entry ? { adapter: entry.adapter, baseUrl: entry.baseUrl, authMode: "oauth" } : undefined; -} - -function explicitQuotaIdentity(provider: string, accountId: string, configured?: OcxProviderConfig): string | undefined { - const credential = getAccountCredential(provider, accountId); - const target = explicitQuotaConfig(provider, configured); - if (!credential || !target) return undefined; - return quotaCredentialIdentity(provider, accountId, credential, target); -} - -function quotaCredentialIdentity(provider: string, accountId: string, credential: NonNullable>, target: OcxProviderConfig): string { - return createHash("sha256").update(JSON.stringify([ - provider, accountId, credential.access, credential.refresh, credential.expires, - credential.accountId, credential.projectId, credential.source, - target.adapter, target.baseUrl, target.authMode, target.disabled === true, - ])).digest("hex"); -} - -function explicitQuotaDestination(provider: string, config: OcxProviderConfig): boolean { - if (config.disabled === true || config.authMode !== "oauth") return false; - if (provider === "kimi") return isCanonicalKimiCodeBaseUrl(config.baseUrl); - if (provider === "command-code") return isCanonicalCommandCodeBaseUrl(config.baseUrl); - // These readers use fixed canonical billing origins, never config.baseUrl. - return provider === "xai" || provider === "cursor"; -} - -async function readExplicitAccountQuota(provider: string, accountId: string, configured?: OcxProviderConfig): Promise<{ - result: ProviderQuotaProbeResult; - identity: string | undefined; - isCurrent: () => boolean; -} | null> { - const target = explicitQuotaConfig(provider, configured); - if (!target || !explicitQuotaDestination(provider, target)) return null; - const config = { ...target }; - const epoch = explicitAccountEpoch; - const accessToken = await getTokenForAccountQuotaProbe(provider, accountId); - const credential = getAccountCredential(provider, accountId); - if (!credential || credential.access !== accessToken) return null; - // Pair the post-renewal credential with the destination captured before renewal. - const identity = explicitQuotaIdentity(provider, accountId, config); - const isCurrent = () => epoch === explicitAccountEpoch - && identity === explicitQuotaIdentity(provider, accountId, configured); - if (!isCurrent()) return null; - let result: ProviderQuotaProbeResult; - switch (provider) { - case "xai": result = await fetchXaiQuota(provider, { accessToken, upstreamAccountId: credential.accountId }); break; - case "cursor": result = await fetchCursorQuota(provider, accessToken); break; - case "kimi": result = await fetchKimiQuota(provider, config, accessToken); break; - case "command-code": result = await fetchCommandCodeQuota(provider, config, accessToken); break; - default: return null; - } - return { result, identity, isCurrent }; -} - -async function fetchExplicitAccountQuota(provider: string, accountId: string, force: boolean, configured?: OcxProviderConfig): Promise { - const key = accountCacheKey(provider, accountId); - const identity = explicitQuotaIdentity(provider, accountId, configured); - const previous = accountQuotaCache.get(key); - const cached = identity && previous?.identity === identity && previous.isCurrent?.() ? previous : undefined; - if (!force && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS - && (!cached.quota || Date.now() - cached.quota.updatedAt < LAST_GOOD_MAX_AGE_MS)) return cached; - const flightKey = `${key}\u0000${identity ?? "missing"}`; - const running = accountQuotaInflight.get(flightKey); - if (running) return running; - const epoch = explicitAccountEpoch; - const lastGood = cached?.quota && Date.now() - cached.quota.updatedAt < LAST_GOOD_MAX_AGE_MS ? cached.quota : null; - const flight = (async (): Promise => { - let read: Awaited> = null; - try { read = await readExplicitAccountQuota(provider, accountId, configured); } catch { /* unavailable */ } - const isCurrent = read?.isCurrent ?? (() => epoch === explicitAccountEpoch && !!identity - && identity === explicitQuotaIdentity(provider, accountId, configured)); - const result = read?.result; - const current = epoch === explicitAccountEpoch && isCurrent(); - const quota = current && result && typeof result !== "symbol" ? result.quota : null; - const empty = result === AUTHORITATIVE_EMPTY_QUOTA; - const entry: AccountQuotaCacheEntry = { - ts: Date.now(), - quota: quota ?? (current && result !== TERMINAL_QUOTA_FAILURE && !empty - && lastGood && Date.now() - lastGood.updatedAt < LAST_GOOD_MAX_AGE_MS ? lastGood : null), - ...(!current || (!quota && !empty) ? { unavailable: true as const } : {}), - identity: read?.identity ?? identity, - isCurrent: () => epoch === explicitAccountEpoch && isCurrent(), - }; - if (entry.isCurrent?.()) accountQuotaCache.set(key, entry); - return entry; - })().finally(() => { if (accountQuotaInflight.get(flightKey) === flight) accountQuotaInflight.delete(flightKey); }); - accountQuotaInflight.set(flightKey, flight); - return flight; -} - -async function fetchExplicitCurrentQuota(provider: string, config: OcxProviderConfig, liveConfig: OcxConfig): Promise { - const id = getAccountSet(provider)?.activeAccountId; - if (!id) return null; - const read = await readExplicitAccountQuota(provider, id, config); - if (!read) return null; - const isCurrent = () => liveConfig.providers[provider] === config - && read.isCurrent() && getAccountSet(provider)?.activeAccountId === id; - if (!isCurrent()) return TERMINAL_QUOTA_FAILURE; - if (read.result && typeof read.result !== "symbol") accountReportCurrent.set(read.result, isCurrent); - return read.result; -} - -function antigravityQuotaDiagnosticIdentity(accountId: string, credential = getAccountCredential("google-antigravity", accountId)): string | undefined { - return credential ? quotaCredentialIdentity("google-antigravity", accountId, credential, { - adapter: "google", baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE, authMode: "oauth", - }) : undefined; -} - -async function fetchAccountQuota( - provider: string, - accountId: string, - forceRefresh: boolean, - providerConfig?: OcxProviderConfig, -): Promise { - if (!supportsPerAccountQuota(provider)) return { ts: Date.now(), quota: null, unavailable: true }; - if (explicitAccountReader(provider)) return fetchExplicitAccountQuota(provider, accountId, forceRefresh, providerConfig); - if (provider === "anthropic") hydrateAccountQuotaCache(); - const key = accountCacheKey(provider, accountId); - const writerGeneration = captureConfigGeneration(); - const cached = accountQuotaCache.get(key); - if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) { - if (provider === "google-antigravity" && cached.quotaFailure && cached.quotaFailureIsCurrent?.() !== true) return { ...cached, quotaFailure: undefined }; - return provider === "anthropic" ? { ...cached, quota: normalizeAnthropicQuota(cached.quota, Date.now()) } : cached; - } - const joinable = accountQuotaInflight.get(key); - if (joinable) return joinable; - - const epoch = explicitAccountEpoch; - const probe = (async (): Promise => { - let diagnosticIdentity: string | undefined; - let quotaFailure: QuotaFailureCode | undefined; - const quotaFailureIsCurrent = () => { - try { return epoch === explicitAccountEpoch && diagnosticIdentity !== undefined && diagnosticIdentity === antigravityQuotaDiagnosticIdentity(accountId); } - catch { return false; } - }; - const diagnosticFields = () => quotaFailure && quotaFailureIsCurrent() ? { quotaFailure, quotaFailureIsCurrent } : {}; - try { - if (provider === "google-antigravity") diagnosticIdentity = antigravityQuotaDiagnosticIdentity(accountId); - let quota: ProviderQuota | null; - let kiroSnapshot: KiroUsageSnapshot | null = null; - if (provider === "kiro") { - // Kiro resolves the bearer and its routing metadata from ONE account-scoped - // snapshot. It deliberately does not use getTokenForAccountQuotaProbe: that - // helper refuses to refresh a background `local-cli` slot because Anthropic's - // lock can adopt a mismatched Claude CLI identity, but Kiro marks every - // CLI-imported credential `local-cli`, so the same rule would blank the quota of - // every inactive pool account the moment its token expired. - kiroSnapshot = await fetchKiroUsageSnapshot(await kiroUsageContextForAccount(accountId)); - quota = kiroSnapshot?.quota ?? null; - } else { - const token = await getTokenForAccountQuotaProbe(provider, accountId); - if (provider === "google-antigravity") { - // Per-account Gem/Cla windows (#1082). The project id is part of the stored - // credential; without it the probe cannot be made, and that is "unavailable", - // never 0%. - const credential = getAccountCredential(provider, accountId); - diagnosticIdentity = credential?.access === token ? antigravityQuotaDiagnosticIdentity(accountId, credential) : undefined; - if (!diagnosticIdentity || !credential?.projectId) throw new Error("antigravity account unavailable"); - const result = await probeAntigravityUsageQuota(token, credential.projectId); - quota = result.kind === "available" ? result.quota : null; - if (result.kind === "unavailable") quotaFailure = result.failure; - } else if (provider === "anthropic") { - quota = await fetchAnthropicUsageQuota(token); - } else { - return { ts: Date.now(), quota: null, unavailable: true }; - } - } - if (!quota) { - // Preserve last-good bars and mark unavailable; advance TTL so failures - // negative-cache instead of re-probing on every GUI poll. - const entry: AccountQuotaCacheEntry = { - ts: Date.now(), - // Settle once for all joiners against observations committed during the probe. - quota: provider === "anthropic" - ? normalizeAnthropicQuota(accountQuotaCache.get(key)?.quota, Date.now()) : cached?.quota ?? null, - unavailable: true, - ...diagnosticFields(), - }; - if (mayCommitAccountQuotaKey(key, writerGeneration)) { - accountQuotaCache.set(key, entry); - if (provider === "kiro") commitKiroAccountUsageState(key, null); - sweepExpiredOnWrite(entry.ts); - } - return entry; - } - const entry: AccountQuotaCacheEntry = { - ts: Date.now(), quota: provider === "anthropic" ? normalizeAnthropicQuota(quota, Date.now()) : quota, - }; - if (mayCommitAccountQuotaKey(key, writerGeneration)) { - accountQuotaCache.set(key, entry); - // Exhaustion state rides the SAME commit guard as the quota row: a probe from a - // superseded config generation must not publish either half. - if (provider === "kiro") commitKiroAccountUsageState(key, kiroSnapshot); - sweepExpiredOnWrite(entry.ts); - } - return entry; - } catch { - if (provider === "google-antigravity") quotaFailure = "account_unavailable"; - const entry: AccountQuotaCacheEntry = { - ts: Date.now(), - quota: provider === "anthropic" - ? normalizeAnthropicQuota(accountQuotaCache.get(key)?.quota, Date.now()) : cached?.quota ?? null, - unavailable: true, - ...diagnosticFields(), - }; - if (mayCommitAccountQuotaKey(key, writerGeneration)) { - accountQuotaCache.set(key, entry); - sweepExpiredOnWrite(entry.ts); - } - return entry; - } - })().finally(() => { - if (accountQuotaInflight.get(key) === probe) accountQuotaInflight.delete(key); - }); - accountQuotaInflight.set(key, probe); - return probe; -} - -/** - * Per-account quota rows for a provider's logged-in accounts. Probes run in parallel; a - * single failing account never blocks the others. - */ -export async function fetchProviderAccountQuotas( - provider: string, - forceRefresh = false, - providerConfig?: OcxProviderConfig, -): Promise { - if (!supportsPerAccountQuota(provider)) return []; - const set = getAccountSet(provider); - if (!set) return []; - return mapQuotaRoster(set.accounts, async account => { - const entry = await fetchAccountQuota(provider, account.id, forceRefresh, providerConfig); - const result: ProviderAccountQuota = { - accountId: account.id, - quota: provider === "anthropic" ? normalizeAnthropicQuota(entry.quota, Date.now()) : entry.quota, - ...(entry.unavailable ? { unavailable: true as const } : {}), - ...(entry.unavailable && entry.quotaFailure && entry.quotaFailureIsCurrent?.() === true ? { quotaFailure: entry.quotaFailure } : {}), - }; - if (entry.quotaFailureIsCurrent) Object.defineProperty(result, "quotaFailureIsCurrent", { value: entry.quotaFailureIsCurrent }); - if (!explicitAccountReader(provider)) return result; - const identity = entry.identity; - Object.defineProperty(result, "isCurrent", { value: () => { - if (entry.isCurrent) return entry.isCurrent(); - const credential = getAccountCredential(provider, account.id); - return !!credential && (!identity || explicitQuotaIdentity(provider, account.id, providerConfig) === identity); - } }); - return result; - }); -} - -function normalizedBaseUrl(value: string): string | null { - try { - const url = new URL(value); - if (url.username || url.password || url.search || url.hash) return null; - return `${url.origin.toLowerCase()}${url.pathname.replace(/\/+$/, "")}`; - } catch { - return null; - } -} - -function quotaResetAt(row: Record): number | undefined { - return normalizeResetAt(row.resetTime ?? row.resetAt ?? row.reset_time ?? row.reset_at); -} - -function isCanonicalKimiCodeBaseUrl(baseUrl: string): boolean { - return normalizedBaseUrl(baseUrl) === KIMI_CODE_BASE_URL; -} - -function isCanonicalCommandCodeBaseUrl(baseUrl: string): boolean { - const normalized = normalizedBaseUrl(baseUrl); - // OAuth preset points at the API root; the Provider-API preset at /provider/v1. - return normalized === COMMAND_CODE_BASE_URL || normalized === `${COMMAND_CODE_BASE_URL}/provider/v1`; -} - -/** Prefer the nested `data` shell when the outer object is only an envelope. */ -function unwrapKimiQuotaPayload(value: unknown): Record | null { - const body = asRecord(value); - if (!body) return null; - const nested = asRecord(body.data); - if (!nested) return body; - // A null/non-usable outer field is a placeholder, not data — an envelope like - // { usage: null, data: { usage: {...} } } must still unwrap to the nested payload. - const usable = (field: unknown): boolean => field !== undefined && field !== null; - const outerHasUsage = usable(body.usage) || usable(body.limits) || usable(body.totalQuota); - const nestedHasUsage = usable(nested.usage) || usable(nested.limits) || usable(nested.totalQuota); - return !outerHasUsage && nestedHasUsage ? nested : body; -} - -function kimiLimitLabel(item: Record, detail: Record): string { - return [item.name, item.title, item.scope, detail.name, detail.title] - .filter((value): value is string => typeof value === "string") - .join(" ") - .toLowerCase(); -} - -function parseKimiQuotaRow(value: unknown, resetFallback?: Record): { percent: number; resetAt?: number } | null { - const row = asRecord(value); - if (!row) return null; - const resetAt = quotaResetAt(row) ?? (resetFallback ? quotaResetAt(resetFallback) : undefined); - const limit = toFiniteNumber(row.limit); - if (limit !== undefined && limit > 0) { - let used = toFiniteNumber(row.used); - if (used === undefined) { - const remaining = toFiniteNumber(row.remaining); - if (remaining !== undefined) used = limit - remaining; - } - if (used !== undefined) { - const percent = normalizePercent((used / limit) * 100); - if (percent !== undefined) return { percent, ...(resetAt !== undefined ? { resetAt } : {}) }; - } - } - // Some payloads expose utilisation directly when limit/used arithmetic is absent. - const direct = normalizePercent(row.utilization ?? row.percent ?? row.usedPercent ?? row.used_percent); - return direct === undefined ? null : { percent: direct, ...(resetAt !== undefined ? { resetAt } : {}) }; -} - -function isKimiFiveHourLimit(item: Record, detail: Record, window: Record): boolean { - const duration = toFiniteNumber(window.duration ?? item.duration ?? detail.duration); - const unit = String(window.timeUnit ?? item.timeUnit ?? detail.timeUnit ?? "").toUpperCase(); - if ((unit.includes("MINUTE") && duration === 300) || (unit.includes("HOUR") && duration === 5)) return true; - return /(^|\b)5\s*(?:h|hour)/.test(kimiLimitLabel(item, detail)); -} - -function isKimiWeeklyLimit(item: Record, detail: Record, window: Record): boolean { - const duration = toFiniteNumber(window.duration ?? item.duration ?? detail.duration); - const unit = String(window.timeUnit ?? item.timeUnit ?? detail.timeUnit ?? "").toUpperCase(); - if ((unit.includes("DAY") && duration === 7) || (unit.includes("HOUR") && duration === 168)) return true; - return /weekly|7\s*(?:d|day)/.test(kimiLimitLabel(item, detail)); -} - -function parseKimiQuotaPayload(value: unknown): ProviderQuota | null { - const body = unwrapKimiQuotaPayload(value); - if (!body) return null; - let weekly = parseKimiQuotaRow(body.usage); - const total = parseKimiQuotaRow(body.totalQuota); - let fiveHour: { percent: number; resetAt?: number } | null = null; - if (Array.isArray(body.limits)) { - for (const rawItem of body.limits) { - const item = asRecord(rawItem); - if (!item) continue; - const detail = asRecord(item.detail) ?? item; - const window = asRecord(item.window) ?? {}; - if (!fiveHour && isKimiFiveHourLimit(item, detail, window)) { - fiveHour = parseKimiQuotaRow(detail, window); - } - if (!weekly && isKimiWeeklyLimit(item, detail, window)) { - weekly = parseKimiQuotaRow(detail, window); - } - if (fiveHour && weekly) break; - } - } - const quota: ProviderQuota = { - ...(fiveHour ? { - fiveHourPercent: fiveHour.percent, - ...(fiveHour.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}), - } : {}), - ...(weekly ? { - weeklyPercent: weekly.percent, - ...(weekly.resetAt !== undefined ? { weeklyResetAt: weekly.resetAt } : {}), - } : {}), - ...(total ? { customWindows: [{ label: "Total subscription credits", percent: total.percent, ...(total.resetAt !== undefined ? { resetAt: total.resetAt } : {}) }] } : {}), - updatedAt: Date.now(), - }; - return hasQuotaRows(quota) ? quota : null; -} - -async function resolveKimiQuotaBearer(config: OcxProviderConfig, accountId?: string): Promise { - if (config.authMode === "oauth") { - try { - return accountId ? await getTokenForAccountQuotaProbe("kimi", accountId) : null; - } catch { - return null; - } - } - // ACTIVE key only: silently walking apiKeyPool when the primary env reference is - // unresolved would render a quota bar for a DIFFERENT account than the one routing - // requests — a wrong meter is worse than no meter. - const primary = resolveProviderApiKey(config.apiKey)?.trim(); - return primary || null; -} - -async function fetchKimiQuota(provider: string, config: OcxProviderConfig, accessToken: string): Promise { - // Never release credentials to a user-edited or lookalike provider host. - if (!isCanonicalKimiCodeBaseUrl(config.baseUrl)) return null; - if (!accessToken) return null; - const response = await fetch(KIMI_CODE_USAGE_URL, { - headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` }, - redirect: "error", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) return null; - const quota = parseKimiQuotaPayload(await readQuotaJson(response)); - return quota ? keyReport(provider, "kimi:usages", quota, config, accessToken, quota) : null; -} - -/** - * Command Code rolling window: `{ cap, used, resetAt }` off /alpha/billing/credits, - * normalized to a percent with an optional reset timestamp. - */ -function parseCommandCodeWindow(value: unknown): { percent: number; resetAt?: number } | null { - const row = asRecord(value); - if (!row) return null; - const cap = toFiniteNumber(row.cap); - const used = toFiniteNumber(row.used); - if (cap === undefined || used === undefined || cap <= 0 || used < 0) return null; - const percent = normalizePercent((used / cap) * 100); - if (percent === undefined) return null; - const resetAt = quotaResetAt(row); - return { percent, ...(resetAt !== undefined ? { resetAt } : {}) }; -} - -/** Soft-fail GET returning a parsed record, or null when unavailable. */ -async function fetchCommandCodeJson(url: string, bearer: string): Promise | null> { - try { - const response = await fetch(url, { - headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` }, - redirect: "error", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) return null; - return asRecord(await readQuotaJson(response)); - } catch { - return null; - } -} - -/** - * Soft-fail period spend (used) against the remaining credit pools → creditsUsd. - * Period scoping: `since=` keeps spend aligned with the - * pools' billing cycle, and `currentPeriodEnd` becomes expiresAt. - */ -async function fetchCommandCodeSpend( - bearer: string, - credits: Record | null, - orgQuery: string, -): Promise { - if (!credits) return undefined; - const subscriptionBody = await fetchCommandCodeJson(`${COMMAND_CODE_SUBSCRIPTIONS_URL}${orgQuery}`, bearer); - const subscription = asRecord(subscriptionBody?.data) ?? subscriptionBody; - const periodStart = typeof subscription?.currentPeriodStart === "string" ? subscription.currentPeriodStart.trim() : ""; - // Unscoped /usage/summary is lifetime spend; mixing it with current-cycle - // remaining pools produces a wrong percent. Omit creditsUsd until a period exists. - if (!periodStart) return undefined; - const sinceQuery = `${orgQuery ? "&" : "?"}since=${encodeURIComponent(periodStart)}`; - const expiresAt = normalizeResetAt(subscription?.currentPeriodEnd); - const summaryBody = await fetchCommandCodeJson(`${COMMAND_CODE_USAGE_URL}${orgQuery}${sinceQuery}`, bearer); - const summary = asRecord(summaryBody?.data) ?? summaryBody; - const used = toFiniteNumber(summary?.totalCost) ?? toFiniteNumber(summary?.totalMonthlyCredits); - if (used === undefined || used < 0) return undefined; - const pools = [credits.monthlyCredits, credits.purchasedCredits, credits.freeCredits] - .map(value => toFiniteNumber(value)) - .filter((value): value is number => value !== undefined); - // Field presence is what separates a real balance from absent data: an exhausted - // all-zero account still reports remaining=0, while no remaining-credit field at - // all means there is nothing to meter. - if (pools.length === 0) return undefined; - const remaining = pools.reduce((sum, value) => sum + Math.max(0, value ?? 0), 0); - const limit = used + remaining; - const percent = normalizePercent(limit > 0 ? (used / limit) * 100 : 0); - // Purchased credits roll over past the subscription period end, so an expiry is - // only truthful when the aggregate contains no non-expiring purchased pool. - const purchased = toFiniteNumber(credits.purchasedCredits) ?? 0; - return percent === undefined - ? undefined - : { - used, - limit, - remaining, - percent, - ...(expiresAt !== undefined && purchased <= 0 ? { expiresAt } : {}), - }; -} - -/** OAuth access token or ACTIVE Provider-API key for the Command Code quota probe. */ -async function resolveCommandCodeQuotaBearer(config: OcxProviderConfig, accountId?: string): Promise { - if (config.authMode === "oauth") { - try { - return accountId ? await getTokenForAccountQuotaProbe("command-code", accountId) : null; - } catch { - return null; - } - } - // ACTIVE key only: a quota bar for a different account than the one routing - // requests is a wrong meter, not a helpful one. - return resolveProviderApiKey(config.apiKey)?.trim() || null; -} - -/** - * Command Code `GET /alpha/billing/credits` — the same Bearer surface the CLI's - * usage view uses (windowLimits.fiveHour / windowLimits.weekly), plus soft - * whoami (team orgId scoping) and subscription-scoped spend for creditsUsd. - */ -async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig, bearer: string): Promise { - // Never release credentials to a user-edited or lookalike provider host. - if (!isCanonicalCommandCodeBaseUrl(config.baseUrl)) return null; - if (!bearer) return null; - const whoamiBody = await fetchCommandCodeJson(COMMAND_CODE_WHOAMI_URL, bearer); - const whoami = asRecord(whoamiBody?.data) ?? whoamiBody; - const org = asRecord(whoami?.org); - const orgId = typeof org?.id === "string" && org.id.trim() ? org.id.trim() : null; - const orgQuery = orgId ? `?orgId=${encodeURIComponent(orgId)}` : ""; - const response = await fetch(`${COMMAND_CODE_CREDITS_URL}${orgQuery}`, { - headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` }, - redirect: "error", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) { - return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 - ? TERMINAL_QUOTA_FAILURE - : null; - } - const raw = asRecord(await readQuotaJson(response)); - const body = asRecord(raw?.data) ?? raw; - const credits = asRecord(body?.credits); - const limits = asRecord(body?.windowLimits); - if (!credits && !limits) return null; - const fiveHour = parseCommandCodeWindow(limits?.fiveHour); - const weekly = parseCommandCodeWindow(limits?.weekly); - const creditsUsd = await fetchCommandCodeSpend(bearer, credits, orgQuery); - const quota: ProviderQuota = { - ...(fiveHour ? { - fiveHourPercent: fiveHour.percent, - ...(fiveHour.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}), - } : {}), - ...(weekly ? { - weeklyPercent: weekly.percent, - ...(weekly.resetAt !== undefined ? { weeklyResetAt: weekly.resetAt } : {}), - } : {}), - ...(creditsUsd ? { creditsUsd } : {}), - updatedAt: Date.now(), - }; - // Rolling windows and the credit balance both gate inference on this bearer. - return keyReport(provider, "command-code:credits", quota, config, bearer, quota); -} - -/** Cursor included usage via api2.cursor.sh (Bearer from OAuth) — unofficial, may change. */ -async function fetchCursorQuota(provider: string, accessToken: string): Promise { - - const authHeaders = { - Accept: "application/json", - Authorization: `Bearer ${accessToken}`, - "User-Agent": "opencodex-quota", - } as const; - - // Prefer dashboard period usage (Pro/Team/Ultra spend allowance in USD cents). - // Field names follow Cursor's Connect RPC shape (limit/remaining/includedSpend), not usedCents. - try { - const periodRes = await fetch("https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", { - method: "POST", - redirect: "error", - headers: { - ...authHeaders, - "Content-Type": "application/json", - "Connect-Protocol-Version": "1", - }, - body: "{}", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (periodRes.ok) { - const body = asRecord(await readQuotaJson(periodRes)); - const planUsage = asRecord(body?.planUsage); - if (planUsage) { - const resetAt = normalizeResetAt(body?.billingCycleEnd ?? planUsage.billingCycleEnd ?? body?.periodEnd); - - // Primary meter: overall included allowance (Cursor Settings → Usage total %). - // autoPercentUsed / apiPercentUsed are secondary pools and must not replace the total. - const limit = toFiniteNumber(planUsage.limit ?? planUsage.limitCents ?? planUsage.totalLimitCents); - const remaining = toFiniteNumber(planUsage.remaining ?? planUsage.remainingCents); - const includedSpend = toFiniteNumber(planUsage.includedSpend ?? planUsage.usedCents ?? planUsage.used); - const totalSpend = toFiniteNumber(planUsage.totalSpend); - let used: number | undefined; - if (includedSpend !== undefined) used = includedSpend; - else if (limit !== undefined && remaining !== undefined) used = Math.max(0, limit - remaining); - else if (totalSpend !== undefined) used = totalSpend; - const totalPercent = normalizePercent(planUsage.totalPercentUsed ?? planUsage.percentUsed) - ?? (limit !== undefined && limit > 0 && used !== undefined - ? normalizePercent((used / limit) * 100) - : undefined); - - const autoPercent = normalizePercent(planUsage.autoPercentUsed); - const apiPercent = normalizePercent(planUsage.apiPercentUsed); - const customWindows: ProviderQuotaWindow[] = []; - if (autoPercent !== undefined) { - customWindows.push({ - label: "First-party models", - percent: autoPercent, - ...(resetAt !== undefined ? { resetAt } : {}), - }); - } - if (apiPercent !== undefined) { - customWindows.push({ - label: "API usage", - percent: apiPercent, - ...(resetAt !== undefined ? { resetAt } : {}), - }); - } - - if (totalPercent !== undefined || customWindows.length > 0) { - const built = report(provider, "cursor:period-usage", { - ...(totalPercent !== undefined ? { - monthlyPercent: totalPercent, - ...(resetAt !== undefined ? { monthlyResetAt: resetAt } : {}), - } : {}), - ...(customWindows.length > 0 ? { customWindows } : {}), - updatedAt: Date.now(), - }); - if (built) return { ...built, reverseEngineered: true }; - } - } - } - } catch { - /* fall through */ - } - - // /api/usage/summary — same host, sometimes richer than /auth/usage for Team plans. - try { - const summaryRes = await fetch("https://api2.cursor.sh/api/usage/summary", { - headers: authHeaders, - redirect: "error", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (summaryRes.ok) { - const body = asRecord(await readQuotaJson(summaryRes)); - const individual = asRecord(body?.individualUsage); - const plan = asRecord(individual?.plan); - if (plan) { - const used = toFiniteNumber(plan.used); - const limit = toFiniteNumber(plan.limit); - const percent = normalizePercent(plan.totalPercentUsed) - ?? (used !== undefined && limit !== undefined && limit > 0 - ? normalizePercent((used / limit) * 100) - : undefined); - if (percent !== undefined) { - const built = report(provider, "cursor:usage-summary", { - monthlyPercent: percent, - monthlyResetAt: normalizeResetAt(body?.billingCycleEnd), - updatedAt: Date.now(), - }); - if (built) return { ...built, reverseEngineered: true }; - } - } - } - } catch { - /* fall through to /auth/usage */ - } - - const response = await fetch("https://api2.cursor.sh/auth/usage", { - headers: authHeaders, - redirect: "error", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) return null; - const body = asRecord(await readQuotaJson(response)); - if (!body) return null; - - // Prefer the gpt-4 bucket (historical "fast requests"); else first model with used+limit. - let used: number | undefined; - let limit: number | undefined; - const gpt4 = asRecord(body["gpt-4"]); - if (gpt4) { - used = toFiniteNumber(gpt4.numRequests ?? gpt4.used); - limit = toFiniteNumber(gpt4.maxRequestUsage ?? gpt4.limit ?? gpt4.maxRequests); - } - if (used === undefined || limit === undefined || limit <= 0) { - for (const [key, value] of Object.entries(body)) { - if (key === "startOfMonth" || key === "billingCycleStart") continue; - const bucket = asRecord(value); - if (!bucket) continue; - const bucketUsed = toFiniteNumber(bucket.numRequests ?? bucket.used); - const bucketLimit = toFiniteNumber(bucket.maxRequestUsage ?? bucket.limit ?? bucket.maxRequests); - if (bucketUsed !== undefined && bucketLimit !== undefined && bucketLimit > 0) { - used = bucketUsed; - limit = bucketLimit; - break; - } - } - } - if (used === undefined || limit === undefined || limit <= 0) return null; - const percent = normalizePercent((used / limit) * 100); - if (percent === undefined) return null; - const startOfMonth = normalizeResetAt(body.startOfMonth ?? body.billingCycleStart); - // Next reset = same day next month, computed in UTC to avoid timezone-shifted rollover. - const monthlyResetAt = startOfMonth !== undefined - ? (() => { - const start = new Date(startOfMonth); - return Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, start.getUTCDate()); - })() - : undefined; - const built = report(provider, "cursor:auth-usage", { - monthlyPercent: percent, - ...(monthlyResetAt !== undefined ? { monthlyResetAt } : {}), - updatedAt: Date.now(), - }); - return built ? { ...built, reverseEngineered: true } : null; -} - -function quotaInfoEntries(modelInfo: Record): Record[] { - const entries: Record[] = []; - const add = (value: unknown, tier?: string) => { - const rec = asRecord(value); - if (!rec) return; - entries.push(tier ? { ...rec, tier } : rec); - }; - const addArray = (value: unknown) => { - if (!Array.isArray(value)) return; - for (const entry of value) add(entry); - }; - - if (Array.isArray(modelInfo.quotaInfo)) addArray(modelInfo.quotaInfo); - else add(modelInfo.quotaInfo); - addArray(modelInfo.quotaInfos); - - const byTier = asRecord(modelInfo.quotaInfoByTier); - if (byTier) { - for (const [tier, value] of Object.entries(byTier)) { - if (Array.isArray(value)) { - for (const entry of value) add(entry, tier); - } else { - add(value, tier); - } - } - } - return entries; -} - -function classifyAntigravityFamily(modelId: string, modelInfo: Record, quotaInfo: Record): "Gem" | "Cla" | null { - const displayName = typeof modelInfo.displayName === "string" ? modelInfo.displayName : ""; - const tier = typeof quotaInfo.tier === "string" ? quotaInfo.tier : ""; - const haystack = `${modelId} ${displayName} ${tier}`.toLowerCase(); - if (haystack.includes("gemini")) return "Gem"; - if (haystack.includes("claude") || haystack.includes("opus") || haystack.includes("sonnet") || haystack.includes("gpt-oss") || haystack.includes("gpt_oss")) return "Cla"; - return null; -} - -function antigravityUsedPercent(quotaInfo: Record): number | undefined { - const target = asRecord(quotaInfo.remaining) ?? quotaInfo; - const remaining = normalizePercent(toFiniteNumber(target.remainingFraction) !== undefined - ? toFiniteNumber(target.remainingFraction)! * 100 - : toFiniteNumber(target.remainingPercentage) !== undefined - ? toFiniteNumber(target.remainingPercentage)! * 100 - : undefined); - if (remaining === undefined) return undefined; - return normalizePercent(100 - remaining); -} - -/** Gem/Cla windows from a `fetchAvailableModels` body; shared by the provider and account probes. */ -function antigravityWindowsFromModels(body: Record | null): ProviderQuotaWindow[] { - const models = asRecord(body?.models); - if (!models) return []; - - const windows = new Map(); - for (const [modelId, rawModelInfo] of Object.entries(models)) { - const modelInfo = asRecord(rawModelInfo); - if (!modelInfo) continue; - for (const quotaInfo of quotaInfoEntries(modelInfo)) { - const label = classifyAntigravityFamily(modelId, modelInfo, quotaInfo); - if (!label || windows.has(label)) continue; - const percent = antigravityUsedPercent(quotaInfo); - if (percent === undefined) continue; - windows.set(label, { - label, - percent, - ...(normalizeResetAt(quotaInfo.resetTime) !== undefined ? { resetAt: normalizeResetAt(quotaInfo.resetTime) } : {}), - }); - } - } - - const customWindows = ["Gem", "Cla"].flatMap(label => { - const window = windows.get(label); - return window ? [window] : []; - }); - return customWindows; -} - -/** - * Parse Google Antigravity quota from `v1internal:retrieveUserQuotaSummary`. - * Groups contain Gemini models and Claude/3P models, each with 5h and weekly limit buckets. - */ -function parseAntigravityQuotaSummary(body: Record | null): ProviderQuota | null { - const groups = Array.isArray(body?.groups) ? (body.groups as unknown[]) : []; - if (groups.length === 0) return null; - - const customWindowsMap = new Map(); - - for (const rawGroup of groups) { - const group = asRecord(rawGroup); - if (!group) continue; - const groupName = `${typeof group.displayName === "string" ? group.displayName : ""} ${typeof group.description === "string" ? group.description : ""}`.toLowerCase(); - const isGemini = groupName.includes("gemini"); - const isClaude = groupName.includes("claude") || groupName.includes("3p") || groupName.includes("gpt"); - - const buckets = Array.isArray(group.buckets) ? (group.buckets as unknown[]) : []; - for (const rawBucket of buckets) { - const bucket = asRecord(rawBucket); - if (!bucket) continue; - const windowStr = `${typeof bucket.window === "string" ? bucket.window : ""} ${typeof bucket.bucketId === "string" ? bucket.bucketId : ""} ${typeof bucket.displayName === "string" ? bucket.displayName : ""}`.toLowerCase(); - const percent = antigravityUsedPercent(bucket); - if (percent === undefined) continue; - const resetAt = normalizeResetAt(bucket.resetTime); - - const isWeekly = windowStr.includes("week"); - const is5h = windowStr.includes("5h") || windowStr.includes("five"); - - if (isGemini) { - const label = is5h ? "Gem" : isWeekly ? "Gem (Weekly)" : ""; - if (label && !customWindowsMap.has(label)) { - customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) }); - } - } else if (isClaude) { - const label = is5h ? "Cla" : isWeekly ? "Cla (Weekly)" : ""; - if (label && !customWindowsMap.has(label)) { - customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) }); - } - } else { - const baseLabel = typeof group.displayName === "string" ? group.displayName : "Other"; - const label = isWeekly ? `${baseLabel} (Weekly)` : baseLabel; - if (!customWindowsMap.has(label)) { - customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) }); - } - } - } - } - - const PREFERRED_ORDER = ["Gem", "Gem (Weekly)", "Cla", "Cla (Weekly)"]; - const customWindows = Array.from(customWindowsMap.values()).sort((a, b) => { - const ia = PREFERRED_ORDER.indexOf(a.label); - const ib = PREFERRED_ORDER.indexOf(b.label); - if (ia !== -1 && ib !== -1) return ia - ib; - if (ia !== -1) return -1; - if (ib !== -1) return 1; - return a.label.localeCompare(b.label); - }); - - if (customWindows.length === 0) { - return null; - } - - return { - customWindows, - updatedAt: Date.now(), - }; -} - -const ANTIGRAVITY_ACCOUNT_QUOTA_BASE = "https://daily-cloudcode-pa.googleapis.com"; -const ANTIGRAVITY_QUOTA_SUMMARY_URL = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:retrieveUserQuotaSummary`; -const ANTIGRAVITY_QUOTA_MODELS_URL = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:fetchAvailableModels`; - -/** Only these fixed accounting destinations may use transparent Fake-IP DNS. */ -export function isCanonicalAntigravityQuotaUrl(name: string, url: string): boolean { - return name === "google-antigravity" - && (url === ANTIGRAVITY_QUOTA_SUMMARY_URL || url === ANTIGRAVITY_QUOTA_MODELS_URL); -} - -let antigravityOutboundDependencies: ProviderOutboundDependencies = { - isCanonicalUrl: isCanonicalAntigravityQuotaUrl, -}; - -/** Test seam: inject resolver/pinned transport for provider and per-account probes. */ -export function setAntigravityAccountQuotaTransportForTests(dependencies: ProviderOutboundDependencies | null): void { - antigravityOutboundDependencies = { ...dependencies, isCanonicalUrl: isCanonicalAntigravityQuotaUrl }; -} - -/** - * Per-account Antigravity quota (#1082). Always probes Google's own Cloud Code Assist host - * through the pinned provider-outbound transport: a configured `baseUrl` is a routing choice - * for requests, not a second source of Google's accounting for a stored credential, and fixing - * the destination keeps the `provider\0accountId` cache identity exact across config changes. - * A redirect or non-2xx yields null (unavailable), never a partial row. - */ -type AntigravityQuotaProbeResult = - | { kind: "available"; quota: ProviderQuota; source: "google-antigravity:retrieveUserQuotaSummary" | "google-antigravity:fetchAvailableModels" } - | { kind: "unavailable"; failure: QuotaFailureCode; legacy: { kind: "null" } | { kind: "throw"; error: unknown } }; - -function quotaTransportFailure(error: unknown): QuotaFailureCode { - if (error instanceof ProviderOutboundPolicyError) return "destination_blocked"; - if (error instanceof DestinationDnsResolutionError) return "dns_failed"; - if (error instanceof PinnedHttpError) return error.code === "output_byte_limit" ? "response_unusable" : "timeout"; - if (error instanceof DOMException && error.name === "TimeoutError") return "timeout"; - return "transport_error"; -} - -function quotaHttpFailure(status: number): QuotaFailureCode { - if (status >= 300 && status < 400) return "redirect_blocked"; - if (status === 401 || status === 403) return "access_denied"; - if (status === 429) return "rate_limited"; - return "upstream_error"; -} - -function unavailableAntigravityQuota(failure: QuotaFailureCode): AntigravityQuotaProbeResult { - return { kind: "unavailable", failure, legacy: { kind: "null" } }; -} - -/** - * Prefer a summary network-policy diagnosis over a vaguer fallback. A blocked - * destination is an actionable local-network fact, while "upstream_error" tells - * the operator to go look at Google. A successful models probe still clears - * the first failure completely. - */ -function antigravityUnavailableFailure( - summaryFailure: QuotaFailureCode | undefined, - fallbackFailure: QuotaFailureCode, -): QuotaFailureCode { - if ( - (summaryFailure === "destination_blocked" || summaryFailure === "dns_failed") - && fallbackFailure !== "destination_blocked" - && fallbackFailure !== "dns_failed" - ) { - return summaryFailure; - } - return fallbackFailure; -} - -async function probeAntigravityUsageQuota(accessToken: string, projectId: string): Promise { - const fetchQuota = (url: string) => providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, { - headers: { - Accept: "application/json", "Content-Type": "application/json", - "User-Agent": antigravityUserAgent(), Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ project: projectId }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }, antigravityOutboundDependencies); - let summaryFailure: QuotaFailureCode | undefined; - try { - const response = await fetchQuota(ANTIGRAVITY_QUOTA_SUMMARY_URL); - if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_SUMMARY_URL)) return unavailableAntigravityQuota("redirect_blocked"); - if (response.status === 401 || response.status === 403) return unavailableAntigravityQuota("access_denied"); - if (response.ok) { - const quota = parseAntigravityQuotaSummary(asRecord(await readQuotaJson(response))); - if (quota) return { kind: "available", quota, source: "google-antigravity:retrieveUserQuotaSummary" }; - } - } catch (error) { - // Existing behavior: summary transport/parse failure may recover through the models probe. - summaryFailure = quotaTransportFailure(error); - } - try { - const response = await fetchQuota(ANTIGRAVITY_QUOTA_MODELS_URL); - if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_MODELS_URL)) { - return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, "redirect_blocked")); - } - if (!response.ok) { - return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, quotaHttpFailure(response.status))); - } - const customWindows = antigravityWindowsFromModels(asRecord(await readQuotaJson(response))); - if (!customWindows.length) { - return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, "response_unusable")); - } - return { kind: "available", quota: { customWindows, updatedAt: Date.now() }, source: "google-antigravity:fetchAvailableModels" }; - } catch (error) { - // The public compatibility wrapper still rejects this exact fallback error; it never enters a DTO. - return { - kind: "unavailable", - failure: antigravityUnavailableFailure(summaryFailure, quotaTransportFailure(error)), - legacy: { kind: "throw", error }, - }; - } -} - -export async function fetchAntigravityUsageQuota(accessToken: string, projectId: string): Promise { - const result = await probeAntigravityUsageQuota(accessToken, projectId); - if (result.kind === "available") return result.quota; - if (result.legacy.kind === "throw") throw result.legacy.error; - return null; -} - -async function fetchAntigravityQuota(provider: string): Promise { - const credential = getCredential("google-antigravity"); - if (!credential?.projectId) return null; - let accessToken: string; - try { accessToken = await getValidAccessToken("google-antigravity"); } catch { return null; } - const result = await probeAntigravityUsageQuota(accessToken, credential.projectId); - if (result.kind === "available") return report(provider, result.source, result.quota); - if (result.legacy.kind === "throw") throw result.legacy.error; - return null; -} -type KeyQuotaReader = (name: string, provider: OcxProviderConfig) => Promise; - -/** Same selector drives cheap capabilities and uncached reads; never resolves credentials. */ -function keyQuotaReaderForProvider(name: string, provider: OcxProviderConfig): KeyQuotaReader | null { - if (provider.disabled === true || (provider.authMode ?? "key") !== "key") return null; - if (isCanonicalKimiCodeBaseUrl(provider.baseUrl)) { - return async (id, config) => { - const bearer = await resolveKimiQuotaBearer(config); - return bearer ? fetchKimiQuota(id, config, bearer) : null; - }; - } - if (name === "commandcode" && isCanonicalCommandCodeBaseUrl(provider.baseUrl)) { - return async (id, config) => { - const bearer = await resolveCommandCodeQuotaBearer(config); - return bearer ? fetchCommandCodeQuota(id, config, bearer) : null; - }; - } - if (registryEntryForProviderDestination(provider)?.id === "opencode-go") return fetchOpenCodeGoQuota; - if (isCanonicalA6apiBaseUrl(provider.baseUrl)) return fetchA6apiQuota; - if (name === "openrouter" && isCanonicalOpenRouterBaseUrl(provider.baseUrl)) return fetchOpenRouterQuota; - if (name === "deepseek" && isCanonicalDeepSeekBaseUrl(provider.baseUrl)) return fetchDeepSeekQuota; - if (name === "cline-pass" && isCanonicalClineBaseUrl(provider.baseUrl)) return fetchClineQuota; - if (isCanonicalOllamaCloudBaseUrl(provider.baseUrl ?? getProviderRegistryEntry(name)?.baseUrl)) return fetchOllamaCloudQuota; - // #4201: the Responses preset is the same domestic GLM Coding Plan subscription on the OpenAI - // Responses wire, so it reads the same monitor endpoint. Eligibility stays a name list AND the - // canonical-URL guard: the guard is what keeps BigModel's bare-key Authorization from reaching a - // lookalike host, so a same-named custom destination still dispatches nothing. - if (["zai", "glm", "glm-cn", "zhipu-bigmodel-coding", "zhipu-bigmodel-responses"].includes(name) && isCanonicalZaiBaseUrl(provider.baseUrl)) return fetchZaiQuota; - if (["minimax", "minimax-cn"].includes(name) && isCanonicalMinimaxBaseUrl(provider.baseUrl)) return fetchMinimaxQuota; - if (name === "moonshot" && isCanonicalMoonshotBaseUrl(provider.baseUrl)) return fetchMoonshotQuota; - if (name === "venice" && isCanonicalVeniceBaseUrl(provider.baseUrl)) return fetchVeniceQuota; - if (name === "synthetic" && isCanonicalSyntheticBaseUrl(provider.baseUrl)) return fetchSyntheticQuota; - if (name === "deepinfra" && isCanonicalDeepInfraBaseUrl(provider.baseUrl)) return fetchDeepInfraQuota; - if (name === "neuralwatt" && isCanonicalNeuralwattBaseUrl(provider.baseUrl)) return fetchNeuralwattQuota; - return null; -} +import { listCodexAuthAccountsSnapshot } from "../codex/auth-api"; +import { resolveEnvValue } from "../config"; +import { getAccountCredential, getAccountSet } from "../oauth/store"; +import { apiKeyPoolEntryId } from "./api-keys"; +import { captureConfigGeneration, sweepExpiredOnWrite } from "../lib/state-store-sweeper"; +import { ACCOUNT_QUOTA_TTL_MS, CACHE_TTL_MS } from "./quota-wire"; +import { replaceCachedProviderQuotas } from "./quota-routing-cache"; +import { + commitKiroAccountUsageState, + fetchKiroUsageSnapshot, + type KiroUsageSnapshot, + kiroUsageContextForAccount, +} from "./kiro-usage"; +import { mapQuotaRoster, readProviderApiKeyQuotas, type ProviderApiKeyQuota } from "./quota-key-accounts"; +import type { OcxConfig, OcxProviderConfig } from "../types"; +import type { ProviderQuota, QuotaFailureCode } from "./quota-types"; +import { + accountReportCurrent, + AUTHORITATIVE_EMPTY_QUOTA, + bumpProviderQuotaInvalidationEpoch, + cacheKeyWithAggregationState, + getProviderQuotaReportCache, + hasCodexPoolProvider, + inflight, + invalidationEpoch, + isBuiltInChatGptForwardProvider, + isProviderQuotaReportCurrent, + LAST_GOOD_MAX_AGE_MS, + providerQuotaBeforePublishForTests, + routingEvidence, + setProviderQuotaReportCache, + TERMINAL_QUOTA_FAILURE, + type CodexAuthAccountsSnapshotPromise, + type ProviderQuotaProbeResult, + type ProviderQuotaReport, + type ProviderQuotaResponse, +} from "./quota/report-cache"; +import { + accountCacheKey, + accountQuotaCache, + accountQuotaInflight, + explicitAccountEpoch, + explicitAccountReader, + explicitQuotaConfig, + explicitQuotaDestination, + explicitQuotaIdentity, + getTokenForAccountQuotaProbe, + hasPassiveAccountQuota, + hydrateAccountQuotaCache, + mayCommitAccountQuotaKey, + mayCommitProviderQuotaKey, + normalizeAnthropicQuota, + supportsPerAccountQuota, + type AccountQuotaCacheEntry, + type ProviderAccountQuota, +} from "./quota/account-cache"; +import { + fetchAnthropicQuota, + fetchAnthropicUsageQuota, + fetchChatGptForwardQuota, + fetchCursorQuota, + fetchKiroQuota, + fetchMuseKeyQuota, + fetchPassiveProviderQuota, + fetchXaiQuota, +} from "./quota/vendor-probes-oauth"; +import { fetchCommandCodeQuota, fetchKimiQuota, keyQuotaReaderForProvider } from "./quota/vendor-probes-key"; +import { antigravityQuotaDiagnosticIdentity, fetchAntigravityQuota, probeAntigravityUsageQuota } from "./quota/antigravity"; -export function providerApiKeyQuotaMode(name: string, provider: OcxProviderConfig): AccountQuotaMode { - return keyQuotaReaderForProvider(name, provider) ? "probe" : "unsupported"; -} +export type { ProviderQuota, ProviderQuotaCreditsUsd, ProviderQuotaWindow } from "./quota-types"; +export { QUOTA_RESPONSE_MAX_BYTES } from "./quota-wire"; +export { + clearProviderQuotaCache, + publishKeyReportForTests, + readProviderQuotaJsonForTests, + setProviderQuotaBeforePublishForTests, + type ProviderQuotaReport, + type ProviderQuotaResponse, +} from "./quota/report-cache"; +export { + clearAccountQuotaCache, + getCachedProviderAccountQuota, + hasPassiveAccountQuota, + parseAnthropicRateLimitHeaders, + providerOAuthAccountQuotaMode, + readPassiveProviderAccountQuotas, + recordAnthropicAccountQuotaFromHeaders, + recordPassiveAccountQuota, + reconcileProviderAccountQuotaRows, + resetProviderQuotaReconcileStateForTests, + setCachedProviderAccountQuotaForTests, + supportsPerAccountQuota, + sweepExpiredProviderAccountQuotaRows, + type ProviderAccountQuota, +} from "./quota/account-cache"; +export { fetchAntigravityUsageQuota, isCanonicalAntigravityQuotaUrl, setAntigravityAccountQuotaTransportForTests } from "./quota/antigravity"; +export { parseOllamaCloudQuota, parseZaiQuotaLimits, providerApiKeyQuotaMode } from "./quota/vendor-probes-key"; +export { parseXaiCreditsResponse } from "./quota/vendor-probes-oauth"; export async function fetchProviderApiKeyQuotas(config: OcxConfig, name: string, forceRefresh = false): Promise { const provider = config.providers[name]; @@ -3220,19 +244,21 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh // by construction and never becomes fresher on its own. Without the exemption a single // configured passive provider makes this predicate permanently false, so every dashboard // poll re-probes every OTHER provider upstream instead of serving the 5-minute cache. - const cacheFresh = cache && cache.key === key && now - cache.ts < CACHE_TTL_MS - && cache.response.reports.every(item => + const currentCache = getProviderQuotaReportCache(); + const cacheFresh = currentCache && currentCache.key === key && now - currentCache.ts < CACHE_TTL_MS + && currentCache.response.reports.every(item => (item.observed === true || now - item.updatedAt < LAST_GOOD_MAX_AGE_MS) && isProviderQuotaReportCurrent(item)); - if (!forceRefresh && cacheFresh) return cache!.response; + if (!forceRefresh && cacheFresh) return currentCache!.response; const joinable = inflight.get(key); if (!forceRefresh && joinable && joinable.epoch === invalidationEpoch) return joinable.promise; // A forced probe takes commit authority: older in-flight probes must not overwrite its result. - if (forceRefresh) invalidationEpoch += 1; + if (forceRefresh) bumpProviderQuotaInvalidationEpoch(); const epoch = invalidationEpoch; const promise = (async (): Promise => { - const previous = cache && cache.key === key ? cache.response.reports : []; + const previousCache = getProviderQuotaReportCache(); + const previous = previousCache && previousCache.key === key ? previousCache.response.reports : []; const probeResults = await Promise.all( Object.entries(config.providers).map(([name, provider]) => ( maybeFetchProviderQuota(name, provider, config, forceRefresh, prefetchedCodexSnapshot) @@ -3296,7 +322,7 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh && generationMismatchedProviders.size === 0 ) { const reports = response.reports.filter(item => mayCommitProviderQuotaKey(item.provider, writerGeneration)); - cache = { key, ts: Date.now(), response: { ...response, reports } }; + setProviderQuotaReportCache({ key, ts: Date.now(), response: { ...response, reports } }); replaceCachedProviderQuotas(reports, routingEvidence); notifyProviderQuotaSnapshot(reports, config); } @@ -3311,3 +337,222 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh if (inflight.get(key) === entry) inflight.delete(key); } } + +async function readExplicitAccountQuota(provider: string, accountId: string, configured?: OcxProviderConfig): Promise<{ + result: ProviderQuotaProbeResult; + identity: string | undefined; + isCurrent: () => boolean; +} | null> { + const target = explicitQuotaConfig(provider, configured); + if (!target || !explicitQuotaDestination(provider, target)) return null; + const config = { ...target }; + const epoch = explicitAccountEpoch; + const accessToken = await getTokenForAccountQuotaProbe(provider, accountId); + const credential = getAccountCredential(provider, accountId); + if (!credential || credential.access !== accessToken) return null; + // Pair the post-renewal credential with the destination captured before renewal. + const identity = explicitQuotaIdentity(provider, accountId, config); + const isCurrent = () => epoch === explicitAccountEpoch + && identity === explicitQuotaIdentity(provider, accountId, configured); + if (!isCurrent()) return null; + let result: ProviderQuotaProbeResult; + switch (provider) { + case "xai": result = await fetchXaiQuota(provider, { accessToken, upstreamAccountId: credential.accountId }); break; + case "cursor": result = await fetchCursorQuota(provider, accessToken); break; + case "kimi": result = await fetchKimiQuota(provider, config, accessToken); break; + case "command-code": result = await fetchCommandCodeQuota(provider, config, accessToken); break; + default: return null; + } + return { result, identity, isCurrent }; +} + +async function fetchExplicitAccountQuota(provider: string, accountId: string, force: boolean, configured?: OcxProviderConfig): Promise { + const key = accountCacheKey(provider, accountId); + const identity = explicitQuotaIdentity(provider, accountId, configured); + const previous = accountQuotaCache.get(key); + const cached = identity && previous?.identity === identity && previous.isCurrent?.() ? previous : undefined; + if (!force && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS + && (!cached.quota || Date.now() - cached.quota.updatedAt < LAST_GOOD_MAX_AGE_MS)) return cached; + const flightKey = `${key}\u0000${identity ?? "missing"}`; + const running = accountQuotaInflight.get(flightKey); + if (running) return running; + const epoch = explicitAccountEpoch; + const lastGood = cached?.quota && Date.now() - cached.quota.updatedAt < LAST_GOOD_MAX_AGE_MS ? cached.quota : null; + const flight = (async (): Promise => { + let read: Awaited> = null; + try { read = await readExplicitAccountQuota(provider, accountId, configured); } catch { /* unavailable */ } + const isCurrent = read?.isCurrent ?? (() => epoch === explicitAccountEpoch && !!identity + && identity === explicitQuotaIdentity(provider, accountId, configured)); + const result = read?.result; + const current = epoch === explicitAccountEpoch && isCurrent(); + const quota = current && result && typeof result !== "symbol" ? result.quota : null; + const empty = result === AUTHORITATIVE_EMPTY_QUOTA; + const entry: AccountQuotaCacheEntry = { + ts: Date.now(), + quota: quota ?? (current && result !== TERMINAL_QUOTA_FAILURE && !empty + && lastGood && Date.now() - lastGood.updatedAt < LAST_GOOD_MAX_AGE_MS ? lastGood : null), + ...(!current || (!quota && !empty) ? { unavailable: true as const } : {}), + identity: read?.identity ?? identity, + isCurrent: () => epoch === explicitAccountEpoch && isCurrent(), + }; + if (entry.isCurrent?.()) accountQuotaCache.set(key, entry); + return entry; + })().finally(() => { if (accountQuotaInflight.get(flightKey) === flight) accountQuotaInflight.delete(flightKey); }); + accountQuotaInflight.set(flightKey, flight); + return flight; +} + +async function fetchExplicitCurrentQuota(provider: string, config: OcxProviderConfig, liveConfig: OcxConfig): Promise { + const id = getAccountSet(provider)?.activeAccountId; + if (!id) return null; + const read = await readExplicitAccountQuota(provider, id, config); + if (!read) return null; + const isCurrent = () => liveConfig.providers[provider] === config + && read.isCurrent() && getAccountSet(provider)?.activeAccountId === id; + if (!isCurrent()) return TERMINAL_QUOTA_FAILURE; + if (read.result && typeof read.result !== "symbol") accountReportCurrent.set(read.result, isCurrent); + return read.result; +} + + +async function fetchAccountQuota( + provider: string, + accountId: string, + forceRefresh: boolean, + providerConfig?: OcxProviderConfig, +): Promise { + if (!supportsPerAccountQuota(provider)) return { ts: Date.now(), quota: null, unavailable: true }; + if (explicitAccountReader(provider)) return fetchExplicitAccountQuota(provider, accountId, forceRefresh, providerConfig); + if (provider === "anthropic") hydrateAccountQuotaCache(); + const key = accountCacheKey(provider, accountId); + const writerGeneration = captureConfigGeneration(); + const cached = accountQuotaCache.get(key); + if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) { + if (provider === "google-antigravity" && cached.quotaFailure && cached.quotaFailureIsCurrent?.() !== true) return { ...cached, quotaFailure: undefined }; + return provider === "anthropic" ? { ...cached, quota: normalizeAnthropicQuota(cached.quota, Date.now()) } : cached; + } + const joinable = accountQuotaInflight.get(key); + if (joinable) return joinable; + + const epoch = explicitAccountEpoch; + const probe = (async (): Promise => { + let diagnosticIdentity: string | undefined; + let quotaFailure: QuotaFailureCode | undefined; + const quotaFailureIsCurrent = () => { + try { return epoch === explicitAccountEpoch && diagnosticIdentity !== undefined && diagnosticIdentity === antigravityQuotaDiagnosticIdentity(accountId); } + catch { return false; } + }; + const diagnosticFields = () => quotaFailure && quotaFailureIsCurrent() ? { quotaFailure, quotaFailureIsCurrent } : {}; + try { + if (provider === "google-antigravity") diagnosticIdentity = antigravityQuotaDiagnosticIdentity(accountId); + let quota: ProviderQuota | null; + let kiroSnapshot: KiroUsageSnapshot | null = null; + if (provider === "kiro") { + // Kiro resolves the bearer and its routing metadata from ONE account-scoped + // snapshot. It deliberately does not use getTokenForAccountQuotaProbe: that + // helper refuses to refresh a background `local-cli` slot because Anthropic's + // lock can adopt a mismatched Claude CLI identity, but Kiro marks every + // CLI-imported credential `local-cli`, so the same rule would blank the quota of + // every inactive pool account the moment its token expired. + kiroSnapshot = await fetchKiroUsageSnapshot(await kiroUsageContextForAccount(accountId)); + quota = kiroSnapshot?.quota ?? null; + } else { + const token = await getTokenForAccountQuotaProbe(provider, accountId); + if (provider === "google-antigravity") { + // Per-account Gem/Cla windows (#1082). The project id is part of the stored + // credential; without it the probe cannot be made, and that is "unavailable", + // never 0%. + const credential = getAccountCredential(provider, accountId); + diagnosticIdentity = credential?.access === token ? antigravityQuotaDiagnosticIdentity(accountId, credential) : undefined; + if (!diagnosticIdentity || !credential?.projectId) throw new Error("antigravity account unavailable"); + const result = await probeAntigravityUsageQuota(token, credential.projectId); + quota = result.kind === "available" ? result.quota : null; + if (result.kind === "unavailable") quotaFailure = result.failure; + } else if (provider === "anthropic") { + quota = await fetchAnthropicUsageQuota(token); + } else { + return { ts: Date.now(), quota: null, unavailable: true }; + } + } + if (!quota) { + // Preserve last-good bars and mark unavailable; advance TTL so failures + // negative-cache instead of re-probing on every GUI poll. + const entry: AccountQuotaCacheEntry = { + ts: Date.now(), + // Settle once for all joiners against observations committed during the probe. + quota: provider === "anthropic" + ? normalizeAnthropicQuota(accountQuotaCache.get(key)?.quota, Date.now()) : cached?.quota ?? null, + unavailable: true, + ...diagnosticFields(), + }; + if (mayCommitAccountQuotaKey(key, writerGeneration)) { + accountQuotaCache.set(key, entry); + if (provider === "kiro") commitKiroAccountUsageState(key, null); + sweepExpiredOnWrite(entry.ts); + } + return entry; + } + const entry: AccountQuotaCacheEntry = { + ts: Date.now(), quota: provider === "anthropic" ? normalizeAnthropicQuota(quota, Date.now()) : quota, + }; + if (mayCommitAccountQuotaKey(key, writerGeneration)) { + accountQuotaCache.set(key, entry); + // Exhaustion state rides the SAME commit guard as the quota row: a probe from a + // superseded config generation must not publish either half. + if (provider === "kiro") commitKiroAccountUsageState(key, kiroSnapshot); + sweepExpiredOnWrite(entry.ts); + } + return entry; + } catch { + if (provider === "google-antigravity") quotaFailure = "account_unavailable"; + const entry: AccountQuotaCacheEntry = { + ts: Date.now(), + quota: provider === "anthropic" + ? normalizeAnthropicQuota(accountQuotaCache.get(key)?.quota, Date.now()) : cached?.quota ?? null, + unavailable: true, + ...diagnosticFields(), + }; + if (mayCommitAccountQuotaKey(key, writerGeneration)) { + accountQuotaCache.set(key, entry); + sweepExpiredOnWrite(entry.ts); + } + return entry; + } + })().finally(() => { + if (accountQuotaInflight.get(key) === probe) accountQuotaInflight.delete(key); + }); + accountQuotaInflight.set(key, probe); + return probe; +} + +/** + * Per-account quota rows for a provider's logged-in accounts. Probes run in parallel; a + * single failing account never blocks the others. + */ +export async function fetchProviderAccountQuotas( + provider: string, + forceRefresh = false, + providerConfig?: OcxProviderConfig, +): Promise { + if (!supportsPerAccountQuota(provider)) return []; + const set = getAccountSet(provider); + if (!set) return []; + return mapQuotaRoster(set.accounts, async account => { + const entry = await fetchAccountQuota(provider, account.id, forceRefresh, providerConfig); + const result: ProviderAccountQuota = { + accountId: account.id, + quota: provider === "anthropic" ? normalizeAnthropicQuota(entry.quota, Date.now()) : entry.quota, + ...(entry.unavailable ? { unavailable: true as const } : {}), + ...(entry.unavailable && entry.quotaFailure && entry.quotaFailureIsCurrent?.() === true ? { quotaFailure: entry.quotaFailure } : {}), + }; + if (entry.quotaFailureIsCurrent) Object.defineProperty(result, "quotaFailureIsCurrent", { value: entry.quotaFailureIsCurrent }); + if (!explicitAccountReader(provider)) return result; + const identity = entry.identity; + Object.defineProperty(result, "isCurrent", { value: () => { + if (entry.isCurrent) return entry.isCurrent(); + const credential = getAccountCredential(provider, account.id); + return !!credential && (!identity || explicitQuotaIdentity(provider, account.id, providerConfig) === identity); + } }); + return result; + }); +} diff --git a/src/providers/quota/account-cache.ts b/src/providers/quota/account-cache.ts new file mode 100644 index 0000000000..93c437a61d --- /dev/null +++ b/src/providers/quota/account-cache.ts @@ -0,0 +1,441 @@ +import { createHash } from "node:crypto"; +import { getValidAccessTokenForAccount } from "../../oauth"; +import { getAccountCredential, getAccountSet } from "../../oauth/store"; +import type { GenerationContext } from "../../lib/state-store-sweeper"; +import { ACCOUNT_QUOTA_TTL_MS, toFiniteNumber } from "../quota-wire"; +import { clearKiroAccountUsageState, reconcileKiroAccountUsageState } from "../kiro-usage"; +import { cancelPendingAccountQuotaPersist, readPersistedAccountQuotas, schedulePersistAccountQuotas } from "../account-quota-disk"; +import { replaceCachedProviderQuotas } from "../quota-routing-cache"; +import { getProviderRegistryEntry } from "../registry"; +import { getProviderQuotaReportCache, hasQuotaRows, routingEvidence, setProviderQuotaReportCache } from "./report-cache"; +import { isCanonicalCommandCodeBaseUrl, isCanonicalKimiCodeBaseUrl } from "./vendor-probes-key"; +import type { AccountQuotaMode, ProviderQuota, ProviderQuotaWindow, QuotaFailureCode } from "../quota-types"; +import type { OcxConfig, OcxProviderConfig } from "../../types"; + +/** Match oauth/index REFRESH_SKEW_MS — use stored access without refresh when still fresh. */ +const ACCOUNT_TOKEN_SKEW_MS = 60_000; + +/** + * Anthropic and Kiro both report usage per CREDENTIAL, so every logged-in account can be + * probed with its own bearer token — the active-account selection and the local usage log + * are irrelevant here. Mirrors the Codex pool behaviour + * (codex/auth-api.ts:fetchPoolAccountQuota), including a per-account TTL so N accounts cost + * at most N upstream calls per window. `ACCOUNT_QUOTA_TTL_MS` lives in `quota-wire.ts` + * because the Kiro exhaustion reader applies the same staleness bound. + */ +export type AccountQuotaCacheEntry = { + ts: number; + quota: ProviderQuota | null; + /** Last probe failed (429 / network / expired login); still may hold last-good quota. */ + unavailable?: true; + quotaFailure?: QuotaFailureCode; + quotaFailureIsCurrent?: () => boolean; + /** Private new-reader identity; never persisted or serialized. */ + identity?: string; + isCurrent?: () => boolean; +}; +/** Expired measurements become unknown; missing reset evidence never implies a fresh allowance. */ +export function normalizeAnthropicQuota(quota: ProviderQuota | null | undefined, now: number): ProviderQuota | null { + if (!quota) return null; + const validReset = (resetAt: unknown): resetAt is number => typeof resetAt === "number" + && Number.isFinite(resetAt) && resetAt > 0 && Number.isFinite(new Date(resetAt).getTime()); + let result = quota; + for (const [percent, reset] of [ + ["fiveHourPercent", "fiveHourResetAt"], + ["weeklyPercent", "weeklyResetAt"], + ["monthlyPercent", "monthlyResetAt"], + ] as const) { + const resetAt = quota[reset]; + if (resetAt === undefined) continue; + const valid = validReset(resetAt); + if (valid && resetAt > now) continue; + if (result === quota) result = { ...quota }; + if (valid) delete result[percent]; + delete result[reset]; + } + // Persisted rows validate only the outer quota object, so custom data may be malformed. + if (quota.customWindows !== undefined) { + const windows = Array.isArray(quota.customWindows) ? quota.customWindows : []; + const retained: ProviderQuotaWindow[] = []; + let changed = !Array.isArray(quota.customWindows); + for (const window of windows) { + if (!window || typeof window !== "object" || typeof window.label !== "string" || !window.label.trim() + || typeof window.percent !== "number" || !Number.isFinite(window.percent) + || window.percent < 0 || window.percent > 100) { + changed = true; + continue; + } + if (validReset(window.resetAt) && window.resetAt <= now) { + changed = true; + continue; + } + if (window.resetAt !== undefined && !validReset(window.resetAt)) { + const normalized = { ...window }; + delete normalized.resetAt; + retained.push(normalized); + changed = true; + } else { + retained.push(window); + } + } + if (changed) { + if (result === quota) result = { ...quota }; + if (retained.length) result.customWindows = retained; + else delete result.customWindows; + } + } + return hasQuotaRows(result) ? result : null; +} + +export const accountQuotaCache = new Map(); +export let explicitAccountEpoch = 0; + +/** + * Seed the cache from the last run, once. + * + * Without this a restart forgets every measurement, so the pool opens its next turn with + * no idea which account has room — the exact blindness pre-dispatch selection exists to + * remove. A hydrated row is still subject to the ordinary TTL, so it orders the first + * request and is replaced by a live probe immediately after. + */ +let diskHydrated = false; +export function hydrateAccountQuotaCache(): void { + if (diskHydrated) return; + diskHydrated = true; + for (const [key, quota] of readPersistedAccountQuotas()) { + // Disk stores observation time, not the Anthropic usage probe's clock. + if (!accountQuotaCache.has(key)) { + const anthropic = key.startsWith("anthropic\u0000"); + accountQuotaCache.set(key, { + ts: anthropic ? 0 : quota.updatedAt, + quota: anthropic ? normalizeAnthropicQuota(quota, Date.now()) : quota, + }); + } + } +} + +export function persistAccountQuotaCache(): void { + schedulePersistAccountQuotas(function* () { + const now = Date.now(); + for (const [key, entry] of accountQuotaCache) { + const quota = key.startsWith("anthropic\u0000") ? normalizeAnthropicQuota(entry.quota, now) : entry.quota; + if (quota) yield [key, quota] as [string, ProviderQuota]; + } + }); +} +export const accountQuotaInflight = new Map>(); +let lastReconciledGeneration = 0; +let liveAccountQuotaKeys = new Set(); +let liveProviderQuotaKeys = new Set(); + +export function mayCommitAccountQuotaKey(key: string, writerGeneration: number): boolean { + return writerGeneration >= lastReconciledGeneration || liveAccountQuotaKeys.has(key); +} + +export function mayCommitProviderQuotaKey(key: string, writerGeneration: number): boolean { + return writerGeneration >= lastReconciledGeneration || liveProviderQuotaKeys.has(key); +} + +export interface ProviderAccountQuota { + accountId: string; + quota: ProviderQuota | null; + /** Set when the probe could not reach upstream (expired login, 429, network). */ + unavailable?: true; + quotaFailure?: QuotaFailureCode; + quotaFailureIsCurrent?: () => boolean; + isCurrent?: () => boolean; +} + +/** Providers whose per-account quota can be probed. Extend as other OAuth APIs are covered. */ +export function supportsPerAccountQuota(provider: string): boolean { + return provider === "anthropic" || provider === "kiro" || provider === "google-antigravity" + || explicitAccountReader(provider); +} + +export function explicitAccountReader(provider: string): boolean { + return provider === "xai" || provider === "cursor" || provider === "kimi" || provider === "command-code"; +} + +export function providerOAuthAccountQuotaMode(provider: string): AccountQuotaMode { + return hasPassiveAccountQuota(provider) ? "passive" : supportsPerAccountQuota(provider) ? "probe" : "unsupported"; +} + +export function accountCacheKey(provider: string, accountId: string): string { + return `${provider}\u0000${accountId}`; +} + +/** + * Synchronous last-good per-account quota read for routing. Never probes the network. + * Returns null when nothing is cached (or the cached row has no bars). + */ +export function getCachedProviderAccountQuota(provider: string, accountId: string): ProviderQuota | null { + const entry = accountQuotaCache.get(accountCacheKey(provider, accountId)); + if (entry?.isCurrent && !entry.isCurrent()) return null; + return provider === "anthropic" ? normalizeAnthropicQuota(entry?.quota, Date.now()) : entry?.quota ?? null; +} + +/** Test-only: seed or clear the per-account quota cache without probing upstream. */ +export function setCachedProviderAccountQuotaForTests( + provider: string, + accountId: string, + quota: ProviderQuota | null, +): void { + const key = accountCacheKey(provider, accountId); + if (quota === null) { + accountQuotaCache.delete(key); + return; + } + accountQuotaCache.set(key, { ts: Date.now(), quota }); +} + +/** Unified headers report utilization fractions and epoch-second reset times. */ +function anthropicHeaderResetAt(value: string | null): number | undefined { + const seconds = toFiniteNumber(value); + if (seconds === undefined || seconds <= 0) return undefined; + const timestamp = seconds * 1000; + return Number.isFinite(new Date(timestamp).getTime()) ? timestamp : undefined; +} + +export function parseAnthropicRateLimitHeaders(headers: Headers): ProviderQuota | null { + const fiveHourPercent = normalizeUtilizationFraction(headers.get("anthropic-ratelimit-unified-5h-utilization")); + const weeklyPercent = normalizeUtilizationFraction(headers.get("anthropic-ratelimit-unified-7d-utilization")); + if (fiveHourPercent === undefined && weeklyPercent === undefined) return null; + const fiveHourResetAt = anthropicHeaderResetAt(headers.get("anthropic-ratelimit-unified-5h-reset")); + const weeklyResetAt = anthropicHeaderResetAt(headers.get("anthropic-ratelimit-unified-7d-reset")); + return { + ...(fiveHourPercent !== undefined ? { fiveHourPercent } : {}), + ...(fiveHourPercent !== undefined && fiveHourResetAt !== undefined ? { fiveHourResetAt } : {}), + ...(weeklyPercent !== undefined ? { weeklyPercent } : {}), + ...(weeklyPercent !== undefined && weeklyResetAt !== undefined ? { weeklyResetAt } : {}), + updatedAt: Date.now(), + }; +} + +/** Reject unknown scales; round fraction conversion for persisted/displayed percentages. */ +function normalizeUtilizationFraction(value: string | null): number | undefined { + const numeric = toFiniteNumber(value); + if (numeric === undefined || numeric < 0 || numeric > 1) return undefined; + return Math.round(numeric * 10_000) / 100; +} + +/** + * Merge serving-account observations without advancing the usage probe's clock or + * erasing model-specific windows. The caller owns credential attribution; this guard + * prevents a retired account key from being revived by an older config generation. + */ +export function recordAnthropicAccountQuotaFromHeaders( + accountId: string, + headers: Headers, + writerGeneration: number, +): void { + if (!accountId) return; + const observed = parseAnthropicRateLimitHeaders(headers); + if (!observed) return; + const key = accountCacheKey("anthropic", accountId); + if (!mayCommitAccountQuotaKey(key, writerGeneration)) return; + // Hydrate before writing, for the same reason `recordPassiveAccountQuota` does: this write + // arrives unprompted from the request path, and `persistAccountQuotaCache` serializes the + // whole map. Landing before any reader has hydrated would persist this single row and erase + // every other provider's saved row. + hydrateAccountQuotaCache(); + const previous = accountQuotaCache.get(key); + accountQuotaCache.set(key, { + ...previous, + // Headers do not prove that the last usage probe succeeded. + ts: previous?.ts ?? 0, + quota: normalizeAnthropicQuota({ + ...normalizeAnthropicQuota(previous?.quota, observed.updatedAt), ...observed, + }, observed.updatedAt), + }); + persistAccountQuotaCache(); +} + +/** + * Providers whose per-account quota is OBSERVED in-band, never probed. + * + * Deliberately separate from `supportsPerAccountQuota` rather than folded into it. That + * predicate gates explicit upstream readers. Meta publishes no quota endpoint, so it + * remains a cache-only observation even when every probe reader is account-scoped. + */ +export function hasPassiveAccountQuota(provider: string): boolean { + return provider === "meta-muse"; +} + +/** + * Record a quota observed in-band on a streaming turn. + * + * The CALLER captures `writerGeneration` when it resolves the serving credential, not + * this function at write time. A streaming turn is a long await, and a generation + * captured immediately before the write cannot see a config or account change that + * happened EARLIER in the same turn — which is exactly the case the fence exists for. + */ +export function recordPassiveAccountQuota( + provider: string, + accountId: string, + quota: ProviderQuota, + writerGeneration: number, +): void { + if (!hasPassiveAccountQuota(provider) || !accountId) return; + const key = accountCacheKey(provider, accountId); + if (!mayCommitAccountQuotaKey(key, writerGeneration)) return; + // Hydrate BEFORE writing, not only on the read path. `persistAccountQuotaCache` + // serializes the whole in-memory map, so a passive write that lands before anything + // has read the cache would persist this one row and erase every other provider's + // saved row -- and `diskHydrated` would then stop any later reader from recovering + // them. A probe writer cannot hit this because its own read hydrates first; an + // observation arrives unprompted, so it must hydrate itself. + hydrateAccountQuotaCache(); + accountQuotaCache.set(key, { ts: Date.now(), quota }); + // Persisted so a restart keeps the last observation: with no probe to re-establish it, + // a forgotten row stays forgotten until the user happens to run another streaming turn. + persistAccountQuotaCache(); + // sweepExpiredOnWrite is deliberately NOT called. Existing probe writers call it + // because they run on a poll; this runs on the request path, where a state sweep does + // not belong. Passive rows are still reclaimed by generation reconciliation + // (reconcileProviderAccountQuotaRows) and by the disk reader's age bound. +} + +/** + * Cache-only per-account rows for a passive provider. Never probes, never refreshes. + * + * An account with no observation is OMITTED rather than returned with `quota: null` and + * `unavailable`: that pair means "a probe was attempted and failed", and no probe was + * ever attempted here. A user who has not yet run a streaming turn simply has no + * measurement, which is not an error state. + */ +export function readPassiveProviderAccountQuotas(provider: string): ProviderAccountQuota[] { + if (!hasPassiveAccountQuota(provider)) return []; + // Idempotent, and otherwise only reached from probe paths a passive provider never + // enters — without it a restart shows nothing until the next streaming turn, even + // though the row is sitting on disk. + hydrateAccountQuotaCache(); + const set = getAccountSet(provider); + if (!set) return []; + const rows: ProviderAccountQuota[] = []; + for (const account of set.accounts) { + const entry = accountQuotaCache.get(accountCacheKey(provider, account.id)); + if (entry?.quota) rows.push({ accountId: account.id, quota: entry.quota }); + } + return rows; +} + +export function sweepExpiredProviderAccountQuotaRows(now = Date.now()): number { + let removed = 0; + for (const [key, entry] of accountQuotaCache) { + // Anthropic observations extend retention, never the usage probe's eligibility clock. + const retainedAt = key.startsWith("anthropic\u0000") + ? Math.max(entry.ts, entry.quota?.updatedAt ?? 0) + : entry.ts; + if (retainedAt + ACCOUNT_QUOTA_TTL_MS > now) continue; + accountQuotaCache.delete(key); + removed += 1; + } + return removed; +} + +export function reconcileProviderAccountQuotaRows(context: GenerationContext): number { + if (context.generation <= lastReconciledGeneration) return 0; + let removed = 0; + for (const key of accountQuotaCache.keys()) { + if (context.oauthAccountKeys.has(key)) continue; + accountQuotaCache.delete(key); + removed += 1; + } + // Kiro exhaustion rows are keyed identically, so they retire with their quota row; a + // verdict outliving its account would hand the replacement a cooldown it never earned. + removed += reconcileKiroAccountUsageState(context.oauthAccountKeys); + const cachedReports = getProviderQuotaReportCache(); + if (cachedReports) { + const reports = cachedReports.response.reports.filter(report => context.providerNames.has(report.provider)); + removed += cachedReports.response.reports.length - reports.length; + setProviderQuotaReportCache({ ...cachedReports, response: { ...cachedReports.response, reports } }); + replaceCachedProviderQuotas(reports, routingEvidence); + } + liveAccountQuotaKeys = new Set(context.oauthAccountKeys); + liveProviderQuotaKeys = new Set(context.providerNames); + lastReconciledGeneration = context.generation; + return removed; +} + +/** Test-only reset so a direct reconcile call in one file cannot leak across files. */ +export function resetProviderQuotaReconcileStateForTests(): void { + lastReconciledGeneration = 0; + liveAccountQuotaKeys = new Set(); + liveProviderQuotaKeys = new Set(); +} + +/** Drop cached per-account rows (all, or just one provider's). */ +export function clearAccountQuotaCache(provider?: string): void { + explicitAccountEpoch += 1; + if (!provider) { + accountQuotaCache.clear(); + accountQuotaInflight.clear(); + clearKiroAccountUsageState(); + // A cleared cache must not be re-seeded from the file it was just cleared of, and any + // pending write of the old rows is abandoned. + diskHydrated = false; + cancelPendingAccountQuotaPersist(); + return; + } + const prefix = `${provider}\u0000`; + for (const key of [...accountQuotaCache.keys()]) { + if (key.startsWith(prefix)) accountQuotaCache.delete(key); + } + clearKiroAccountUsageState(prefix); + // Drop in-flight probes too so a late resolve cannot repopulate after logout/remove. + for (const key of [...accountQuotaInflight.keys()]) { + if (key.startsWith(prefix)) accountQuotaInflight.delete(key); + } + persistAccountQuotaCache(); +} + +/** + * Resolve a bearer for quota probing without silently adopting a newer global + * Claude CLI credential into a background multiauth slot. + * + * - Fresh stored access → use as-is (no refresh). + * - Active account with expired access → normal refresh path. + * - Background `local-cli` with expired access → fail closed (unavailable): + * `getValidAccessTokenForAccount` can persist a mismatched Claude CLI identity. + * - Background ordinary OAuth (`source !== "local-cli"`) → safe to refresh; + * Anthropic's lock only adopts disk credentials for `local-cli` rows. + */ +export async function getTokenForAccountQuotaProbe(provider: string, accountId: string): Promise { + const stored = getAccountCredential(provider, accountId); + if (!stored) throw new Error("account credential missing"); + if (stored.expires > Date.now() + ACCOUNT_TOKEN_SKEW_MS) return stored.access; + const activeId = getAccountSet(provider)?.activeAccountId; + if (activeId !== accountId && stored.source === "local-cli") { + throw new Error("background local-cli token expired; skip CLI-adopting refresh for quota probe"); + } + return getValidAccessTokenForAccount(provider, accountId); +} + +export function explicitQuotaConfig(provider: string, configured?: OcxProviderConfig): OcxProviderConfig | undefined { + if (configured) return configured; + const entry = getProviderRegistryEntry(provider); + return entry ? { adapter: entry.adapter, baseUrl: entry.baseUrl, authMode: "oauth" } : undefined; +} + +export function explicitQuotaIdentity(provider: string, accountId: string, configured?: OcxProviderConfig): string | undefined { + const credential = getAccountCredential(provider, accountId); + const target = explicitQuotaConfig(provider, configured); + if (!credential || !target) return undefined; + return quotaCredentialIdentity(provider, accountId, credential, target); +} + +export function quotaCredentialIdentity(provider: string, accountId: string, credential: NonNullable>, target: OcxProviderConfig): string { + return createHash("sha256").update(JSON.stringify([ + provider, accountId, credential.access, credential.refresh, credential.expires, + credential.accountId, credential.projectId, credential.source, + target.adapter, target.baseUrl, target.authMode, target.disabled === true, + ])).digest("hex"); +} + +export function explicitQuotaDestination(provider: string, config: OcxProviderConfig): boolean { + if (config.disabled === true || config.authMode !== "oauth") return false; + if (provider === "kimi") return isCanonicalKimiCodeBaseUrl(config.baseUrl); + if (provider === "command-code") return isCanonicalCommandCodeBaseUrl(config.baseUrl); + // These readers use fixed canonical billing origins, never config.baseUrl. + return provider === "xai" || provider === "cursor"; +} diff --git a/src/providers/quota/antigravity.ts b/src/providers/quota/antigravity.ts new file mode 100644 index 0000000000..bafdc1eee0 --- /dev/null +++ b/src/providers/quota/antigravity.ts @@ -0,0 +1,295 @@ +import { antigravityUserAgent } from "../../adapters/client-fingerprint"; +import { DestinationDnsResolutionError } from "../../lib/destination-policy"; +import { PinnedHttpError } from "../../lib/pinned-http"; +import { ProviderOutboundPolicyError, providerOutboundPost, providerRedirectError, type ProviderOutboundDependencies } from "../../lib/provider-outbound"; +import { getValidAccessToken } from "../../oauth"; +import { getAccountCredential, getCredential } from "../../oauth/store"; +import { asRecord, normalizePercent, normalizeResetAt, readQuotaJson, REQUEST_TIMEOUT_MS, toFiniteNumber } from "../quota-wire"; +import { report, type ProviderQuotaReport } from "./report-cache"; +import { quotaCredentialIdentity } from "./account-cache"; +import type { ProviderQuota, ProviderQuotaWindow, QuotaFailureCode } from "../quota-types"; + +export function antigravityQuotaDiagnosticIdentity(accountId: string, credential = getAccountCredential("google-antigravity", accountId)): string | undefined { + return credential ? quotaCredentialIdentity("google-antigravity", accountId, credential, { + adapter: "google", baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE, authMode: "oauth", + }) : undefined; +} + + +function quotaInfoEntries(modelInfo: Record): Record[] { + const entries: Record[] = []; + const add = (value: unknown, tier?: string) => { + const rec = asRecord(value); + if (!rec) return; + entries.push(tier ? { ...rec, tier } : rec); + }; + const addArray = (value: unknown) => { + if (!Array.isArray(value)) return; + for (const entry of value) add(entry); + }; + + if (Array.isArray(modelInfo.quotaInfo)) addArray(modelInfo.quotaInfo); + else add(modelInfo.quotaInfo); + addArray(modelInfo.quotaInfos); + + const byTier = asRecord(modelInfo.quotaInfoByTier); + if (byTier) { + for (const [tier, value] of Object.entries(byTier)) { + if (Array.isArray(value)) { + for (const entry of value) add(entry, tier); + } else { + add(value, tier); + } + } + } + return entries; +} + +function classifyAntigravityFamily(modelId: string, modelInfo: Record, quotaInfo: Record): "Gem" | "Cla" | null { + const displayName = typeof modelInfo.displayName === "string" ? modelInfo.displayName : ""; + const tier = typeof quotaInfo.tier === "string" ? quotaInfo.tier : ""; + const haystack = `${modelId} ${displayName} ${tier}`.toLowerCase(); + if (haystack.includes("gemini")) return "Gem"; + if (haystack.includes("claude") || haystack.includes("opus") || haystack.includes("sonnet") || haystack.includes("gpt-oss") || haystack.includes("gpt_oss")) return "Cla"; + return null; +} + +function antigravityUsedPercent(quotaInfo: Record): number | undefined { + const target = asRecord(quotaInfo.remaining) ?? quotaInfo; + const remaining = normalizePercent(toFiniteNumber(target.remainingFraction) !== undefined + ? toFiniteNumber(target.remainingFraction)! * 100 + : toFiniteNumber(target.remainingPercentage) !== undefined + ? toFiniteNumber(target.remainingPercentage)! * 100 + : undefined); + if (remaining === undefined) return undefined; + return normalizePercent(100 - remaining); +} + +/** Gem/Cla windows from a `fetchAvailableModels` body; shared by the provider and account probes. */ +function antigravityWindowsFromModels(body: Record | null): ProviderQuotaWindow[] { + const models = asRecord(body?.models); + if (!models) return []; + + const windows = new Map(); + for (const [modelId, rawModelInfo] of Object.entries(models)) { + const modelInfo = asRecord(rawModelInfo); + if (!modelInfo) continue; + for (const quotaInfo of quotaInfoEntries(modelInfo)) { + const label = classifyAntigravityFamily(modelId, modelInfo, quotaInfo); + if (!label || windows.has(label)) continue; + const percent = antigravityUsedPercent(quotaInfo); + if (percent === undefined) continue; + windows.set(label, { + label, + percent, + ...(normalizeResetAt(quotaInfo.resetTime) !== undefined ? { resetAt: normalizeResetAt(quotaInfo.resetTime) } : {}), + }); + } + } + + const customWindows = ["Gem", "Cla"].flatMap(label => { + const window = windows.get(label); + return window ? [window] : []; + }); + return customWindows; +} + +/** + * Parse Google Antigravity quota from `v1internal:retrieveUserQuotaSummary`. + * Groups contain Gemini models and Claude/3P models, each with 5h and weekly limit buckets. + */ +function parseAntigravityQuotaSummary(body: Record | null): ProviderQuota | null { + const groups = Array.isArray(body?.groups) ? (body.groups as unknown[]) : []; + if (groups.length === 0) return null; + + const customWindowsMap = new Map(); + + for (const rawGroup of groups) { + const group = asRecord(rawGroup); + if (!group) continue; + const groupName = `${typeof group.displayName === "string" ? group.displayName : ""} ${typeof group.description === "string" ? group.description : ""}`.toLowerCase(); + const isGemini = groupName.includes("gemini"); + const isClaude = groupName.includes("claude") || groupName.includes("3p") || groupName.includes("gpt"); + + const buckets = Array.isArray(group.buckets) ? (group.buckets as unknown[]) : []; + for (const rawBucket of buckets) { + const bucket = asRecord(rawBucket); + if (!bucket) continue; + const windowStr = `${typeof bucket.window === "string" ? bucket.window : ""} ${typeof bucket.bucketId === "string" ? bucket.bucketId : ""} ${typeof bucket.displayName === "string" ? bucket.displayName : ""}`.toLowerCase(); + const percent = antigravityUsedPercent(bucket); + if (percent === undefined) continue; + const resetAt = normalizeResetAt(bucket.resetTime); + + const isWeekly = windowStr.includes("week"); + const is5h = windowStr.includes("5h") || windowStr.includes("five"); + + if (isGemini) { + const label = is5h ? "Gem" : isWeekly ? "Gem (Weekly)" : ""; + if (label && !customWindowsMap.has(label)) { + customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) }); + } + } else if (isClaude) { + const label = is5h ? "Cla" : isWeekly ? "Cla (Weekly)" : ""; + if (label && !customWindowsMap.has(label)) { + customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) }); + } + } else { + const baseLabel = typeof group.displayName === "string" ? group.displayName : "Other"; + const label = isWeekly ? `${baseLabel} (Weekly)` : baseLabel; + if (!customWindowsMap.has(label)) { + customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) }); + } + } + } + } + + const PREFERRED_ORDER = ["Gem", "Gem (Weekly)", "Cla", "Cla (Weekly)"]; + const customWindows = Array.from(customWindowsMap.values()).sort((a, b) => { + const ia = PREFERRED_ORDER.indexOf(a.label); + const ib = PREFERRED_ORDER.indexOf(b.label); + if (ia !== -1 && ib !== -1) return ia - ib; + if (ia !== -1) return -1; + if (ib !== -1) return 1; + return a.label.localeCompare(b.label); + }); + + if (customWindows.length === 0) { + return null; + } + + return { + customWindows, + updatedAt: Date.now(), + }; +} + +const ANTIGRAVITY_ACCOUNT_QUOTA_BASE = "https://daily-cloudcode-pa.googleapis.com"; +const ANTIGRAVITY_QUOTA_SUMMARY_URL = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:retrieveUserQuotaSummary`; +const ANTIGRAVITY_QUOTA_MODELS_URL = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:fetchAvailableModels`; + +/** Only these fixed accounting destinations may use transparent Fake-IP DNS. */ +export function isCanonicalAntigravityQuotaUrl(name: string, url: string): boolean { + return name === "google-antigravity" + && (url === ANTIGRAVITY_QUOTA_SUMMARY_URL || url === ANTIGRAVITY_QUOTA_MODELS_URL); +} + +let antigravityOutboundDependencies: ProviderOutboundDependencies = { + isCanonicalUrl: isCanonicalAntigravityQuotaUrl, +}; + +/** Test seam: inject resolver/pinned transport for provider and per-account probes. */ +export function setAntigravityAccountQuotaTransportForTests(dependencies: ProviderOutboundDependencies | null): void { + antigravityOutboundDependencies = { ...dependencies, isCanonicalUrl: isCanonicalAntigravityQuotaUrl }; +} + +/** + * Per-account Antigravity quota (#1082). Always probes Google's own Cloud Code Assist host + * through the pinned provider-outbound transport: a configured `baseUrl` is a routing choice + * for requests, not a second source of Google's accounting for a stored credential, and fixing + * the destination keeps the `provider\0accountId` cache identity exact across config changes. + * A redirect or non-2xx yields null (unavailable), never a partial row. + */ +type AntigravityQuotaProbeResult = + | { kind: "available"; quota: ProviderQuota; source: "google-antigravity:retrieveUserQuotaSummary" | "google-antigravity:fetchAvailableModels" } + | { kind: "unavailable"; failure: QuotaFailureCode; legacy: { kind: "null" } | { kind: "throw"; error: unknown } }; + +function quotaTransportFailure(error: unknown): QuotaFailureCode { + if (error instanceof ProviderOutboundPolicyError) return "destination_blocked"; + if (error instanceof DestinationDnsResolutionError) return "dns_failed"; + if (error instanceof PinnedHttpError) return error.code === "output_byte_limit" ? "response_unusable" : "timeout"; + if (error instanceof DOMException && error.name === "TimeoutError") return "timeout"; + return "transport_error"; +} + +function quotaHttpFailure(status: number): QuotaFailureCode { + if (status >= 300 && status < 400) return "redirect_blocked"; + if (status === 401 || status === 403) return "access_denied"; + if (status === 429) return "rate_limited"; + return "upstream_error"; +} + +function unavailableAntigravityQuota(failure: QuotaFailureCode): AntigravityQuotaProbeResult { + return { kind: "unavailable", failure, legacy: { kind: "null" } }; +} + +/** + * Prefer a summary network-policy diagnosis over a vaguer fallback. A blocked + * destination is an actionable local-network fact, while "upstream_error" tells + * the operator to go look at Google. A successful models probe still clears + * the first failure completely. + */ +function antigravityUnavailableFailure( + summaryFailure: QuotaFailureCode | undefined, + fallbackFailure: QuotaFailureCode, +): QuotaFailureCode { + if ( + (summaryFailure === "destination_blocked" || summaryFailure === "dns_failed") + && fallbackFailure !== "destination_blocked" + && fallbackFailure !== "dns_failed" + ) { + return summaryFailure; + } + return fallbackFailure; +} + +export async function probeAntigravityUsageQuota(accessToken: string, projectId: string): Promise { + const fetchQuota = (url: string) => providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, { + headers: { + Accept: "application/json", "Content-Type": "application/json", + "User-Agent": antigravityUserAgent(), Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ project: projectId }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }, antigravityOutboundDependencies); + let summaryFailure: QuotaFailureCode | undefined; + try { + const response = await fetchQuota(ANTIGRAVITY_QUOTA_SUMMARY_URL); + if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_SUMMARY_URL)) return unavailableAntigravityQuota("redirect_blocked"); + if (response.status === 401 || response.status === 403) return unavailableAntigravityQuota("access_denied"); + if (response.ok) { + const quota = parseAntigravityQuotaSummary(asRecord(await readQuotaJson(response))); + if (quota) return { kind: "available", quota, source: "google-antigravity:retrieveUserQuotaSummary" }; + } + } catch (error) { + // Existing behavior: summary transport/parse failure may recover through the models probe. + summaryFailure = quotaTransportFailure(error); + } + try { + const response = await fetchQuota(ANTIGRAVITY_QUOTA_MODELS_URL); + if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_MODELS_URL)) { + return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, "redirect_blocked")); + } + if (!response.ok) { + return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, quotaHttpFailure(response.status))); + } + const customWindows = antigravityWindowsFromModels(asRecord(await readQuotaJson(response))); + if (!customWindows.length) { + return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, "response_unusable")); + } + return { kind: "available", quota: { customWindows, updatedAt: Date.now() }, source: "google-antigravity:fetchAvailableModels" }; + } catch (error) { + // The public compatibility wrapper still rejects this exact fallback error; it never enters a DTO. + return { + kind: "unavailable", + failure: antigravityUnavailableFailure(summaryFailure, quotaTransportFailure(error)), + legacy: { kind: "throw", error }, + }; + } +} + +export async function fetchAntigravityUsageQuota(accessToken: string, projectId: string): Promise { + const result = await probeAntigravityUsageQuota(accessToken, projectId); + if (result.kind === "available") return result.quota; + if (result.legacy.kind === "throw") throw result.legacy.error; + return null; +} + +export async function fetchAntigravityQuota(provider: string): Promise { + const credential = getCredential("google-antigravity"); + if (!credential?.projectId) return null; + let accessToken: string; + try { accessToken = await getValidAccessToken("google-antigravity"); } catch { return null; } + const result = await probeAntigravityUsageQuota(accessToken, credential.projectId); + if (result.kind === "available") return report(provider, result.source, result.quota); + if (result.legacy.kind === "throw") throw result.legacy.error; + return null; +} diff --git a/src/providers/quota/report-cache.ts b/src/providers/quota/report-cache.ts new file mode 100644 index 0000000000..44010ebbf7 --- /dev/null +++ b/src/providers/quota/report-cache.ts @@ -0,0 +1,320 @@ +import { createHash } from "node:crypto"; +import { effectiveCodexAuthAccountId, listCodexAuthAccountsSnapshot } from "../../codex/auth-api"; +import { withoutRetiredCodexQuota, type StoredAccountQuota } from "../../codex/quota"; +import { isMainAccountIdentityGenerationLive } from "../../codex/main-account-cache"; +import { codexPlanKey } from "../../codex/plan"; +import { resolveProviderApiKey } from "../key-store"; +import { apiKeyPoolEntryId } from "../api-keys"; +import { getProviderRegistryEntry, providerCodexAccountMode } from "../registry"; +import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../openai-tiers"; +import { CODEX_CAPACITY_MAX_QUOTA_AGE_MS, type CodexCapacityAggregation, type CodexCapacityQuota } from "../codex-capacity"; +import { clearCachedProviderQuotas, providerQuotaRoutingBinding, type ProviderQuotaRoutingEvidence } from "../quota-routing-cache"; +import { clearProviderApiKeyQuotaCache } from "../quota-key-accounts"; +import { QUOTA_JSON_READ_FAILURE, readQuotaJson } from "../quota-wire"; +import type { OcxConfig, OcxProviderConfig } from "../../types"; +import type { ProviderQuota, ProviderRoutingQuota } from "../quota-types"; + +/** Keep a failed probe's previous row at most this long before dropping it. */ +export const LAST_GOOD_MAX_AGE_MS = CODEX_CAPACITY_MAX_QUOTA_AGE_MS; +const nativeMainReportGenerations = new WeakMap(); +export const accountReportCurrent = new WeakMap boolean>(); +export const routingEvidence = new WeakMap(); +export let providerQuotaBeforePublishForTests: (() => void | Promise) | null = null; + +/** Test-only seam for identity/config invalidation after probes but before publication. */ +export function setProviderQuotaBeforePublishForTests( + hook: (() => void | Promise) | null, +): void { + providerQuotaBeforePublishForTests = hook; +} +export const TERMINAL_QUOTA_FAILURE = Symbol("terminal-quota-failure"); +/** + * The probe succeeded and the upstream authoritatively reported NO model-quota windows. + * + * Distinct from `null`, which means "this probe told us nothing" and deliberately preserves + * the last-good row for up to 30 minutes. Collapsing the two would let a stale report outlive + * the authoritative answer that replaced it: a GLM plan whose payload carries only MCP + * `TIME_LIMIT` rows has no model windows, and the dashboard and quota-aware routing must stop + * showing the previous token windows rather than keep them for another half hour. + * + * Suppression is shared with `TERMINAL_QUOTA_FAILURE`; only the reason differs. + */ +export const AUTHORITATIVE_EMPTY_QUOTA = Symbol("authoritative-empty-quota"); +export type ProviderQuotaProbeResult = + | ProviderQuotaReport + | null + | typeof TERMINAL_QUOTA_FAILURE + | typeof AUTHORITATIVE_EMPTY_QUOTA; + +export interface ProviderQuotaReport { + provider: string; + label: string; + source: string; + quota: ProviderQuota; + updatedAt: number; + /** Added by the management response projection, never stored on a cached report. */ + routingQuota?: ProviderRoutingQuota; + reverseEngineered?: boolean; + /** + * The row was OBSERVED in-band on a streaming turn rather than probed. + * + * Age means something different for these. A probed provider re-reads on its own TTL, + * so a row older than the last-good bound means the probe is failing and showing it + * would misrepresent a live number. A passive provider publishes no endpoint at all + * (`hasPassiveAccountQuota`), so its last observation is not a stale reading of + * something fresher — it is the only measurement that exists, and dropping it leaves + * the operator with nothing. Consumers that enforce a freshness bound must exempt + * these and state the observation age instead. + */ + observed?: boolean; + aggregation?: CodexCapacityAggregation; +} + +export interface ProviderQuotaResponse { + generatedAt: number; + reports: ProviderQuotaReport[]; +} + +let cache: { key: string; ts: number; response: ProviderQuotaResponse } | null = null; +export const inflight = new Map }>(); +/** Bumped on cache clear and on force-refresh start; stale-epoch probes lose commit authority. */ +export let invalidationEpoch = 0; + +/** Owner-module accessors: cache reassignment stays inside this file. */ +export function getProviderQuotaReportCache(): { key: string; ts: number; response: ProviderQuotaResponse } | null { + return cache; +} + +export function setProviderQuotaReportCache(next: { key: string; ts: number; response: ProviderQuotaResponse } | null): void { + cache = next; +} + +export function bumpProviderQuotaInvalidationEpoch(): void { + invalidationEpoch += 1; +} + +/** Invalidate the report cache (e.g. after switching a provider's active account). */ +export function clearProviderQuotaCache(): void { + cache = null; + clearCachedProviderQuotas(); + clearProviderApiKeyQuotaCache(); + invalidationEpoch += 1; +} + +function cacheKey(config: OcxConfig): string { + const providers = Object.entries(config.providers) + .map(([name, provider]) => { + const resolvedKey = typeof provider.apiKey === "string" + ? resolveProviderApiKey(provider.apiKey)?.trim() + : undefined; + const activeKeyId = resolvedKey ? apiKeyPoolEntryId(resolvedKey) : "none"; + return `${name}:${provider.adapter}:${provider.authMode ?? "key"}:${providerCodexAccountMode(name, provider) ?? "none"}:${provider.disabled === true ? "off" : "on"}:${provider.baseUrl}:${activeKeyId}`; + }) + .sort() + .join("|"); + return `${config.defaultProvider}|${providers}`; +} + +export type CodexAuthAccountsSnapshotPromise = ReturnType; + +export function hasCodexPoolProvider(config: OcxConfig): boolean { + return Object.entries(config.providers).some(([name, provider]) => ( + provider.disabled !== true + && isBuiltInChatGptForwardProvider(name, provider) + && providerCodexAccountMode(name, provider) !== "direct" + )); +} + +function quotaSignatureValue(quota: CodexCapacityQuota | null): unknown { + if (!quota) return null; + return { + fiveHourPercent: quota.fiveHourPercent, + fiveHourResetAt: quota.fiveHourResetAt, + weeklyPercent: quota.weeklyPercent, + weeklyResetAt: quota.weeklyResetAt, + monthlyPercent: quota.monthlyPercent, + monthlyResetAt: quota.monthlyResetAt, + updatedAt: quota.updatedAt, + customWindows: [...(quota.customWindows ?? [])] + .map(window => ({ label: window.label, percent: window.percent, resetAt: window.resetAt })) + .sort((a, b) => a.label.localeCompare(b.label)), + }; +} + +export function providerQuotaFromCodexQuota( + quota: StoredAccountQuota | Omit | null | undefined, +): CodexCapacityQuota | null { + if (!quota) return null; + // Direct snapshots bypass account DTOs; sanitize here as well as at ingestion. + quota = withoutRetiredCodexQuota(quota); + if (!quota) return null; + const projected: CodexCapacityQuota = { + ...(quota.shortPercent !== undefined ? { fiveHourPercent: quota.shortPercent } : {}), + ...(quota.shortResetAt !== undefined ? { fiveHourResetAt: quota.shortResetAt } : {}), + ...(quota.weeklyPercent !== undefined ? { weeklyPercent: quota.weeklyPercent } : {}), + ...(quota.weeklyResetAt !== undefined ? { weeklyResetAt: quota.weeklyResetAt } : {}), + ...(quota.monthlyPercent !== undefined ? { monthlyPercent: quota.monthlyPercent } : {}), + ...(quota.monthlyResetAt !== undefined ? { monthlyResetAt: quota.monthlyResetAt } : {}), + ...(quota.customWindows !== undefined ? { customWindows: quota.customWindows } : {}), + updatedAt: "updatedAt" in quota ? quota.updatedAt : Date.now(), + }; + return hasQuotaRows(projected) ? projected : null; +} + +/** Hash only presentation-relevant state; account ids and email addresses never enter the key. */ +export function cacheKeyWithAggregationState( + config: OcxConfig, + prefetchedSnapshot?: CodexAuthAccountsSnapshotPromise, +): string | Promise { + const base = cacheKey(config); + if (!hasCodexPoolProvider(config)) return base; + return (async () => { + try { + const activeId = effectiveCodexAuthAccountId(config); + const snapshot = await (prefetchedSnapshot ?? listCodexAuthAccountsSnapshot(config, false)); + const rows = snapshot.accounts.map(account => ({ + isMain: account.isMain, + active: account.id === activeId, + plan: codexPlanKey(account.plan) ?? null, + paused: account.paused, + needsReauth: account.needsReauth === true, + quota: quotaSignatureValue(providerQuotaFromCodexQuota(account.quota)), + })); + const canonicalRows = rows.map(row => JSON.stringify(row)).sort(); + const digest = createHash("sha256").update(JSON.stringify(canonicalRows)).digest("hex").slice(0, 24); + return `${base}|codex-pool:${digest}`; + } catch { + return `${base}|codex-pool:unavailable`; + } + })(); +} + +function publicCapacityWindow(window: import("../codex-capacity").CodexCapacityWindowAggregation) { + const { totalWeight: _totalWeight, consumedWeight: _consumedWeight, remainingWeight: _remainingWeight, ...safe } = window; + return safe; +} + +/** Management API metadata intentionally omits configured/weighted unit counts. */ +export function publicCapacityAggregation( + aggregation: CodexCapacityAggregation, + presentation: NonNullable, +): CodexCapacityAggregation { + const safeCurrentAccount = presentation === "coverage-only" && aggregation.currentAccount + ? { ...aggregation.currentAccount, quota: null } + : aggregation.currentAccount; + return { + ...aggregation, + presentation, + ...(safeCurrentAccount ? { currentAccount: safeCurrentAccount } : {}), + ...(aggregation.fiveHour ? { fiveHour: publicCapacityWindow(aggregation.fiveHour) } : {}), + ...(aggregation.weekly ? { weekly: publicCapacityWindow(aggregation.weekly) } : {}), + ...(aggregation.monthly ? { monthly: publicCapacityWindow(aggregation.monthly) } : {}), + ...(aggregation.customWindows ? { + customWindows: aggregation.customWindows.map(window => ({ + label: window.label, + ...publicCapacityWindow(window), + })), + } : {}), + }; +} + +export function hasQuotaRows(quota: ProviderQuota | null | undefined): quota is ProviderQuota { + if (!quota) return false; + return typeof quota.fiveHourPercent === "number" + || typeof quota.weeklyPercent === "number" + || typeof quota.monthlyPercent === "number" + || quota.creditsUsd?.unlimited === true + || typeof quota.creditsUsd?.percent === "number" + || !!quota.customWindows?.some(window => typeof window.percent === "number"); +} + +export function providerLabel(providerId: string): string { + return getProviderRegistryEntry(providerId)?.label ?? providerId; +} + +/** Test-only access to the quota reader's deadline and cancellation contract. */ +export async function readProviderQuotaJsonForTests(response: Response, timeoutMs: number): Promise { + const result = await readQuotaJson(response, timeoutMs); + return result === QUOTA_JSON_READ_FAILURE ? null : result; +} + +export function isBuiltInChatGptForwardProvider(name: string, provider: OcxProviderConfig): boolean { + return name === OPENAI_CODEX_PROVIDER_ID && isCanonicalOpenAiForwardProvider(provider); +} + +export function report( + provider: string, + source: string, + quota: ProviderQuota, + aggregation?: CodexCapacityAggregation, +): ProviderQuotaReport | null { + if (!hasQuotaRows(quota)) return null; + return { + provider, + label: providerLabel(provider), + source, + quota, + updatedAt: quota.updatedAt, + ...(aggregation ? { aggregation } : {}), + }; +} + +/** + * Publish a credential-bound report, and routing evidence only when the producer + * hands over its inference-only projection. + * + * The projection is deliberately not defaulted to the display quota. A producer must + * decide that its rows really do constrain inference on the probed credential; omitting + * the argument leaves the report display-only, so a new producer cannot inherit + * provider-veto authority merely by calling this helper. Ownership alone is not the + * scope decision: providerQuotaRoutingBinding resolving is necessary, never sufficient. + */ +export function keyReport( + provider: string, + source: string, + quota: ProviderQuota, + config: OcxProviderConfig, + probedCredential: string, + inferenceQuota?: ProviderQuota, +): ProviderQuotaReport | null { + const result = report(provider, source, quota); + if (!result || !inferenceQuota) return result; + const binding = providerQuotaRoutingBinding(provider, config, probedCredential); + if (binding) routingEvidence.set(result, { quota: inferenceQuota, binding }); + return result; +} + +export function tagNativeMainReport( + value: ProviderQuotaReport | null, + generation: number, +): ProviderQuotaReport | null { + if (value) nativeMainReportGenerations.set(value, generation); + return value; +} + +/** + * Test-only seam: publish exactly as a credential-bound producer does, and hand back the + * routing evidence the publication actually attached. + * + * Live producers all pass a projection today, so no probe fixture can prove the OTHER half + * of the contract: that omitting it stays display-only. Routing an omitted argument through + * the real helper keeps that provable, and a re-introduced `= quota` default would be + * observed here (a defaulted parameter also fires for an explicitly undefined argument). + */ +export function publishKeyReportForTests( + provider: string, + source: string, + quota: ProviderQuota, + config: OcxProviderConfig, + probedCredential: string, + inferenceQuota?: ProviderQuota, +): { report: ProviderQuotaReport | null; routing: ProviderQuotaRoutingEvidence | undefined } { + const result = keyReport(provider, source, quota, config, probedCredential, inferenceQuota); + return { report: result, routing: result ? routingEvidence.get(result) : undefined }; +} + +export function isProviderQuotaReportCurrent(value: ProviderQuotaReport): boolean { + const generation = nativeMainReportGenerations.get(value); + return (generation === undefined || isMainAccountIdentityGenerationLive(generation)) + && (accountReportCurrent.get(value)?.() ?? true); +} diff --git a/src/providers/quota/vendor-probes-key.ts b/src/providers/quota/vendor-probes-key.ts new file mode 100644 index 0000000000..17594f548e --- /dev/null +++ b/src/providers/quota/vendor-probes-key.ts @@ -0,0 +1,1243 @@ +import { resolveProviderApiKey } from "../key-store"; +import { getProviderRegistryEntry, registryEntryForProviderDestination } from "../registry"; +import { isCanonicalOllamaCloudUrl } from "../../adapters/ollama-native-url"; +import { QUOTA_JSON_READ_FAILURE, asRecord, normalizePercent, normalizeResetAt, readQuotaJson, REQUEST_TIMEOUT_MS, toFiniteNumber } from "../quota-wire"; +import { + AUTHORITATIVE_EMPTY_QUOTA, + hasQuotaRows, + keyReport, + report, + TERMINAL_QUOTA_FAILURE, + type ProviderQuotaProbeResult, + type ProviderQuotaReport, +} from "./report-cache"; +import { getTokenForAccountQuotaProbe } from "./account-cache"; +import type { AccountQuotaMode, ProviderQuota, ProviderQuotaCreditsUsd } from "../quota-types"; +import type { OcxProviderConfig } from "../../types"; + +const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1"; +const KIMI_CODE_USAGE_URL = `${KIMI_CODE_BASE_URL}/usages`; +const COMMAND_CODE_BASE_URL = "https://api.commandcode.ai"; +const COMMAND_CODE_WHOAMI_URL = `${COMMAND_CODE_BASE_URL}/alpha/whoami`; +const COMMAND_CODE_CREDITS_URL = `${COMMAND_CODE_BASE_URL}/alpha/billing/credits`; +const COMMAND_CODE_SUBSCRIPTIONS_URL = `${COMMAND_CODE_BASE_URL}/alpha/billing/subscriptions`; +const COMMAND_CODE_USAGE_URL = `${COMMAND_CODE_BASE_URL}/alpha/usage/summary`; +const A6API_BASE_URL = "https://api.a6api.com"; +const OPENCODE_GO_BASE_URL = "https://opencode.ai/zen/go/v1"; +const OPENCODE_GO_USAGE_URL = `${OPENCODE_GO_BASE_URL}/usage`; +const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; +const DEEPSEEK_BASE_URL = "https://api.deepseek.com"; +const CLINE_BASE_URL = "https://api.cline.bot"; +const OLLAMA_CLOUD_BASE_URL = "https://ollama.com"; +const OLLAMA_CLOUD_USAGE_URL = `${OLLAMA_CLOUD_BASE_URL}/api/usage`; +const ZAI_BASE_URL = "https://api.z.ai"; +const ZAI_CN_BASE_URL = "https://open.bigmodel.cn"; +const MINIMAX_REMAINS_URL = "https://www.minimax.io/v1/token_plan/remains"; +const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; +const VENICE_BASE_URL = "https://api.venice.ai/api/v1"; +const SYNTHETIC_BASE_URL = "https://api.synthetic.new/v2"; +const DEEPINFRA_BASE_URL = "https://api.deepinfra.com"; +const NEURALWATT_BASE_URL = "https://api.neuralwatt.com/v1"; + + +function isCanonicalA6apiBaseUrl(baseUrl: string): boolean { + const normalized = normalizedBaseUrl(baseUrl); + return normalized === A6API_BASE_URL || normalized === `${A6API_BASE_URL}/v1`; +} + +function isCanonicalOpenCodeGoBaseUrl(baseUrl: string): boolean { + return normalizedBaseUrl(baseUrl) === OPENCODE_GO_BASE_URL; +} + +function isCanonicalOpenRouterBaseUrl(baseUrl: string): boolean { + const normalized = normalizedBaseUrl(baseUrl); + return normalized === OPENROUTER_BASE_URL; +} + +function isCanonicalDeepSeekBaseUrl(baseUrl: string): boolean { + const normalized = normalizedBaseUrl(baseUrl); + return normalized === DEEPSEEK_BASE_URL || normalized === `${DEEPSEEK_BASE_URL}/v1`; +} + +function isCanonicalClineBaseUrl(baseUrl: string): boolean { + const normalized = normalizedBaseUrl(baseUrl); + return normalized === CLINE_BASE_URL || normalized === `${CLINE_BASE_URL}/api/v1`; +} + +function isCanonicalOllamaCloudBaseUrl(baseUrl?: string): boolean { + if (!baseUrl) return false; + try { + return isCanonicalOllamaCloudUrl(baseUrl); + } catch { + return false; + } +} + +function zaiQuotaMonitorHost(baseUrl: string): string | null { + // Admission and destination selection must share one mapping: admitting a new + // international wire must never fall through to the CN host/authentication scheme. + switch (normalizedBaseUrl(baseUrl)) { + case ZAI_BASE_URL: + case `${ZAI_BASE_URL}/api/coding/paas/v4`: + case `${ZAI_BASE_URL}/api/anthropic`: + case `${ZAI_BASE_URL}/api/v1`: + return ZAI_BASE_URL; + case ZAI_CN_BASE_URL: + case `${ZAI_CN_BASE_URL}/api/coding/paas/v4`: + case `${ZAI_CN_BASE_URL}/api/v1`: + return ZAI_CN_BASE_URL; + default: + return null; + } +} + +function isCanonicalZaiBaseUrl(baseUrl: string): boolean { + return zaiQuotaMonitorHost(baseUrl) !== null; +} + +function isCanonicalMinimaxBaseUrl(baseUrl: string): boolean { + const normalized = normalizedBaseUrl(baseUrl); + return normalized === "https://api.minimax.io/v1" || normalized === "https://api.minimaxi.com/v1"; +} + +function isCanonicalMoonshotBaseUrl(baseUrl: string): boolean { + const normalized = normalizedBaseUrl(baseUrl); + return normalized === MOONSHOT_BASE_URL || normalized === "https://api.moonshot.cn/v1"; +} + +function isCanonicalVeniceBaseUrl(baseUrl: string): boolean { + return normalizedBaseUrl(baseUrl) === VENICE_BASE_URL; +} + +function isCanonicalSyntheticBaseUrl(baseUrl: string): boolean { + const normalized = normalizedBaseUrl(baseUrl); + return normalized === SYNTHETIC_BASE_URL || normalized === "https://api.synthetic.new/openai/v1"; +} + +function isCanonicalDeepInfraBaseUrl(baseUrl: string): boolean { + const normalized = normalizedBaseUrl(baseUrl); + return normalized === DEEPINFRA_BASE_URL || normalized === `${DEEPINFRA_BASE_URL}/v1/openai`; +} + +function isCanonicalNeuralwattBaseUrl(baseUrl: string): boolean { + return normalizedBaseUrl(baseUrl) === NEURALWATT_BASE_URL; +} + +function a6apiPayload(value: unknown): Record | null { + const body = asRecord(value); + return asRecord(body?.data) ?? body; +} + +function firstFinite(record: Record | null, names: string[]): number | undefined { + if (!record) return undefined; + for (const name of names) { + const value = toFiniteNumber(record[name]); + if (value !== undefined) return value; + } + return undefined; +} + +async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Promise { + // Never send a configured API key to a lookalike host or through a redirect. + if (!isCanonicalA6apiBaseUrl(config.baseUrl)) return null; + const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + if (!apiKey) return null; + const headers = { Accept: "application/json", Authorization: `Bearer ${apiKey}` } as const; + const [subscriptionResponse, tokenResponse] = await Promise.all([ + fetch(`${A6API_BASE_URL}/dashboard/billing/subscription`, { + headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }), + fetch(`${A6API_BASE_URL}/api/usage/token/`, { + headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }), + ]); + if (!subscriptionResponse.ok || !tokenResponse.ok) { + const statuses = [subscriptionResponse.status, tokenResponse.status]; + // 408/429 are transient (timeout/throttle), not invalid-account signals: keep the + // last-good row like 5xx/network failures. 401/403 (bad key) and 404 (contract change) + // stay terminal. + return statuses.some(status => status >= 400 && status < 500 && status !== 429 && status !== 408) + ? TERMINAL_QUOTA_FAILURE + : null; + } + const [subscriptionBody, tokenBody] = await Promise.all([ + readQuotaJson(subscriptionResponse), + readQuotaJson(tokenResponse), + ]); + if (subscriptionBody === QUOTA_JSON_READ_FAILURE || tokenBody === QUOTA_JSON_READ_FAILURE) return null; + const subscription = a6apiPayload(subscriptionBody); + const token = a6apiPayload(tokenBody); + const unlimited = token?.unlimited_quota === true + || token?.unlimited_quota === 1 + || token?.unlimited_quota === "true"; + const normalizedExpiry = normalizeResetAt(token?.expires_at); + const expiry = normalizedExpiry && normalizedExpiry > 0 + ? { expiresAt: normalizedExpiry } + : {}; + if (unlimited) { + // Every row is an API-credit constraint on inference, so the display quota is also + // the routing projection. Passing it explicitly is the opt-in. + const quota: ProviderQuota = { + creditsUsd: { + used: 0, + limit: 0, + remaining: 0, + percent: 0, + unlimited: true, + ...expiry, + }, + customWindows: [{ label: "Unlimited API credits", percent: 0 }], + updatedAt: Date.now(), + }; + return keyReport(provider, "a6api:billing", quota, config, apiKey, quota); + } + const limitUsd = firstFinite(subscription, ["hard_limit_usd"]); + const grantedUnits = firstFinite(token, ["total_granted"]); + const usedUnits = firstFinite(token, ["total_used"]); + const availableUnits = firstFinite(token, ["total_available"]); + const reconciledUnits = usedUnits !== undefined && availableUnits !== undefined + ? usedUnits + availableUnits + : undefined; + const reconciliationTolerance = grantedUnits !== undefined + ? Math.abs(grantedUnits) * 1e-9 + : 0; + if (limitUsd === undefined || grantedUnits === undefined || usedUnits === undefined + || availableUnits === undefined || limitUsd <= 0 || grantedUnits <= 0 + || usedUnits < 0 || availableUnits < 0 + || reconciledUnits === undefined + || Math.abs(reconciledUnits - grantedUnits) > reconciliationTolerance) return TERMINAL_QUOTA_FAILURE; + const usdPerUnit = limitUsd / grantedUnits; + const usedUsd = usedUnits * usdPerUnit; + const remainingUsd = Math.max(0, availableUnits * usdPerUnit); + const percent = normalizePercent((usedUsd / limitUsd) * 100); + if (percent === undefined) return TERMINAL_QUOTA_FAILURE; + const label = `API credits ($${remainingUsd.toFixed(2)} of $${limitUsd.toFixed(2)} remaining)`; + const quota: ProviderQuota = { + creditsUsd: { + used: usedUsd, + limit: limitUsd, + remaining: remainingUsd, + percent, + ...expiry, + }, + customWindows: [{ label, percent }], + updatedAt: Date.now(), + }; + // The credit balance funds inference itself, so display and routing scope agree. + return keyReport(provider, "a6api:billing", quota, config, apiKey, quota); +} + +function parseOpenCodeGoUsageWindow(value: unknown): { percent: number; resetAt?: number } | null { + const row = asRecord(value); + if (!row) return null; + const percent = normalizePercent(row.percent); + if (percent === undefined) return null; + const resetAt = normalizeResetAt(row.resetsAt); + return { percent, ...(resetAt !== undefined ? { resetAt } : {}) }; +} + +async function fetchOpenCodeGoQuota(provider: string, config: OcxProviderConfig): Promise { + // Never send a configured API key when the provider destination is not the built-in Go endpoint. + if (!isCanonicalOpenCodeGoBaseUrl(config.baseUrl)) return null; + const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + if (!apiKey) return null; + const response = await fetch(OPENCODE_GO_USAGE_URL, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await readQuotaJson(response)); + const usage = asRecord(body?.usage); + if (!usage) return null; + const rolling = parseOpenCodeGoUsageWindow(usage.rolling); + const weekly = parseOpenCodeGoUsageWindow(usage.weekly); + const monthly = parseOpenCodeGoUsageWindow(usage.monthly); + const quota: ProviderQuota = { + ...(rolling ? { + fiveHourPercent: rolling.percent, + ...(rolling.resetAt !== undefined ? { fiveHourResetAt: rolling.resetAt } : {}), + } : {}), + ...(weekly ? { + weeklyPercent: weekly.percent, + ...(weekly.resetAt !== undefined ? { weeklyResetAt: weekly.resetAt } : {}), + } : {}), + ...(monthly ? { + monthlyPercent: monthly.percent, + ...(monthly.resetAt !== undefined ? { monthlyResetAt: monthly.resetAt } : {}), + } : {}), + updatedAt: Date.now(), + }; + return keyReport(provider, "opencode-go:usage", quota, config, apiKey, quota); +} + +/** + * OpenRouter `GET /api/v1/key` — the key's own credit balance and optional + * per-key spending cap. `limit` is the configured cap (absent = uncapped); + * `usage` is lifetime spend; `limit_remaining` is what is left of the cap. + * When no cap is set there is no hard limit to meter against, so no bar is + * produced — the provider falls back to its documented reference. + */ +async function fetchOpenRouterQuota(provider: string, config: OcxProviderConfig): Promise { + // Never send a configured API key to a lookalike host or through a redirect. + if (!isCanonicalOpenRouterBaseUrl(config.baseUrl)) return null; + const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + if (!apiKey) return null; + const response = await fetch(`${OPENROUTER_BASE_URL}/key`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await readQuotaJson(response)); + const data = asRecord(body?.data) ?? body; + if (!data) return null; + const limit = toFiniteNumber(data.limit); + const limitRemaining = toFiniteNumber(data.limit_remaining); + const usage = toFiniteNumber(data.usage); + // A successful no-cap response is a DELIBERATE change, not a transient + // failure: the old capped row must be dropped, not preserved as last-good. + if (limit === undefined || limit <= 0) return TERMINAL_QUOTA_FAILURE; + // Prefer the authoritative remaining-cap value when present: `usage` is + // lifetime accumulated spend and overstates a reset or re-capped key. + const used = limitRemaining !== undefined + ? Math.max(0, limit - limitRemaining) + : usage !== undefined && usage >= 0 ? usage : undefined; + if (used === undefined) return null; + const percent = normalizePercent((used / limit) * 100); + if (percent === undefined) return null; + const remaining = Math.max(0, limit - used); + const label = `API credits ($${remaining.toFixed(2)} of $${limit.toFixed(2)} remaining)`; + // The per-key spending cap stops every request this credential can make, so the + // whole report is inference-wide routing evidence. + const quota: ProviderQuota = { + customWindows: [{ label, percent }], + updatedAt: Date.now(), + }; + return keyReport(provider, "openrouter:key-info", quota, config, apiKey, quota); +} + +/** + * DeepSeek `GET /user/balance` — the account's granted + topped-up credit + * balance. The payload places `total_balance` / `granted_balance` inside + * entries of `balance_infos` (one row per currency); the row for the account's + * currency is selected by preference. `granted_balance` is a CURRENT balance + * component, not the original grant ceiling, so no consumed percentage is + * fabricated — the balance is reported as a balance-only window. + */ +async function fetchDeepSeekQuota(provider: string, config: OcxProviderConfig): Promise { + if (!isCanonicalDeepSeekBaseUrl(config.baseUrl)) return null; + const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + if (!apiKey) return null; + const response = await fetch(`${DEEPSEEK_BASE_URL}/user/balance`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await readQuotaJson(response)); + // The payload nests balances under `balance_infos` rows keyed by currency; + // prefer a USD row, then CNY, then the first row that parses. + const infos = Array.isArray(body?.balance_infos) ? body.balance_infos as unknown[] : null; + const rows = infos + ? infos.map((raw): Record | null => asRecord(raw)).filter((r): r is Record => r !== null) + : []; + const pick = (currency: string): Record | null => + rows.find(row => String(row.currency ?? "").toUpperCase() === currency) ?? null; + const preferred = pick("USD") ?? pick("CNY") ?? rows[0] ?? null; + if (!preferred) return null; + const totalBalance = toFiniteNumber(preferred.total_balance); + const grantedBalance = toFiniteNumber(preferred.granted_balance); + const toppedUp = toFiniteNumber(preferred.topped_up_balance); + const balance = totalBalance ?? grantedBalance ?? toppedUp; + if (balance === undefined || balance < 0) return null; + const label = grantedBalance !== undefined && grantedBalance > 0 + ? `API balance ($${balance.toFixed(2)} total, $${grantedBalance.toFixed(2)} granted)` + : `API balance ($${balance.toFixed(2)})`; + return report(provider, "deepseek:balance", { + customWindows: [{ label, percent: 0 }], + updatedAt: Date.now(), + }); +} + +/** + * ClinePass `GET /api/v1/users/me/plan/usage-limits` — the subscription's + * rolling five-hour, weekly, and monthly utilization, matching the existing + * ProviderQuota windows directly. The endpoint 404s (or returns a null plan) + * for accounts without an active ClinePass, which is a no-report, not an error. + */ +async function fetchClineQuota(provider: string, config: OcxProviderConfig): Promise { + if (!isCanonicalClineBaseUrl(config.baseUrl)) return null; + const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + if (!apiKey) return null; + const response = await fetch(`${CLINE_BASE_URL}/api/v1/users/me/plan/usage-limits`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + // 404 = no active plan; a plain "no plan" is a no-report, everything else + // 4xx (except 408/429) is a credential/contract problem. + if (response.status === 404) return null; + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await readQuotaJson(response)); + const data = asRecord(body?.data) ?? body; + const limits = Array.isArray(data?.limits) ? data.limits : null; + if (!limits) return null; + const quota: ProviderQuota = { updatedAt: Date.now() }; + let windows = 0; + for (const raw of limits) { + const row = asRecord(raw); + if (!row) continue; + const percent = normalizePercent(row.percentUsed); + if (percent === undefined) continue; + const resetAt = normalizeResetAt(row.resetsAt); + if (row.type === "five_hour") { + quota.fiveHourPercent = percent; + if (resetAt !== undefined) quota.fiveHourResetAt = resetAt; + windows += 1; + } else if (row.type === "weekly") { + quota.weeklyPercent = percent; + if (resetAt !== undefined) quota.weeklyResetAt = resetAt; + windows += 1; + } else if (row.type === "monthly") { + quota.monthlyPercent = percent; + if (resetAt !== undefined) quota.monthlyResetAt = resetAt; + windows += 1; + } + } + return windows > 0 ? keyReport(provider, "cline:plan-usage-limits", quota, config, apiKey, quota) : null; +} + +/** + * Ollama Cloud `GET https://ollama.com/api/usage` — returns account usage. + * Legacy plans report rolling 5-hour `limits.session.usage` and 7-day + * `limits.weekly.usage`. Migrated monthly-credit plans report + * `limits.monthly.usage`. `usage` values are normalized fractions (0..1). + */ +function parseOllamaPercent(usageValue: unknown): number | undefined { + const usage = toFiniteNumber(usageValue); + if (usage === undefined || usage < 0) return undefined; + const percent = Math.round(usage * 10000) / 100; + return normalizePercent(percent); +} + +export function parseOllamaCloudQuota(body: Record | null): ProviderQuota | null { + if (!body) return null; + const limits = asRecord(body.limits); + if (!limits) return null; + + const quota: ProviderQuota = { updatedAt: Date.now() }; + let windows = 0; + + const session = asRecord(limits.session); + if (session) { + const percent = parseOllamaPercent(session.usage); + if (percent !== undefined) { + quota.fiveHourPercent = percent; + windows += 1; + } + } + + const weekly = asRecord(limits.weekly); + if (weekly) { + const percent = parseOllamaPercent(weekly.usage); + if (percent !== undefined) { + quota.weeklyPercent = percent; + windows += 1; + } + } + + const monthly = asRecord(limits.monthly); + if (monthly) { + const percent = parseOllamaPercent(monthly.usage); + if (percent !== undefined) { + quota.monthlyPercent = percent; + windows += 1; + } + } + + return windows > 0 ? quota : null; +} + +async function fetchOllamaCloudQuota(provider: string, config: OcxProviderConfig): Promise { + const effectiveBaseUrl = config.baseUrl ?? getProviderRegistryEntry(provider)?.baseUrl ?? ""; + if (!isCanonicalOllamaCloudBaseUrl(effectiveBaseUrl)) return null; + const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + if (!apiKey) return null; + const response = await fetch(OLLAMA_CLOUD_USAGE_URL, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + if (response.status === 404) return null; + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await readQuotaJson(response)); + const quota = parseOllamaCloudQuota(body); + return quota ? keyReport(provider, "ollama-cloud:usage", quota, config, apiKey, quota) : null; +} + +/** + * Z.AI GLM Coding Plan `GET /api/monitor/usage/quota/limit` — the coding-plan + * limits arrive as a `limits` array of `TOKENS_LIMIT` (newer plans call the + * same rows `CREDIT_LIMIT`) and `TIME_LIMIT` rows. `TOKENS_LIMIT`/`CREDIT_LIMIT` + * rows carry the window length as `unit`/`number`: unit 3 is hours (number 5 → + * the rolling five-hour window), unit 6 is weeks (number 1 → the weekly + * window). Every row's `percentage` is the consumed share (falling + * back to `currentValue`/`usage` when absent) and `nextResetTime` (unix ms) + * the window reset. + * + * `TIME_LIMIT` rows are deliberately ignored (issue #1168). They are the shared + * monthly MCP *call* allowance for Web Search / Web Reader / Zread — not a + * model-token budget — and `ProviderQuota.monthlyPercent` is consumed as a + * model-capacity signal: `headroomOf()` in `src/oauth/account-quota-rank.ts` + * takes the MAX across every window, so a user who spent their MCP search + * allowance would be ranked as having no model capacity left, and the dashboard + * would draw a full monthly bar for a plan whose model tokens are untouched. + * A payload carrying only `TIME_LIMIT` rows therefore reports no quota at all, + * which is the honest answer rather than a fabricated one. + */ +export function parseZaiQuotaLimits(data: Record | null): ProviderQuota | null { + const limits = Array.isArray(data?.limits) ? data.limits as unknown[] : null; + if (!limits) return null; + const quota: ProviderQuota = { updatedAt: Date.now() }; + let windows = 0; + for (const raw of limits) { + const row = asRecord(raw); + if (!row) continue; + // Gate on row type before deriving a percentage: an MCP row must not even + // contribute a parsed value to a model-quota report. + if (row.type !== "TOKENS_LIMIT" && row.type !== "CREDIT_LIMIT") continue; + const resetAt = normalizeResetAt(row.nextResetTime); + let percent = normalizePercent(row.percentage); + if (percent === undefined) { + const used = toFiniteNumber(row.currentValue); + const total = toFiniteNumber(row.usage); + if (used !== undefined && total !== undefined && total > 0) { + percent = normalizePercent((used / total) * 100); + } + } + if (percent === undefined) continue; + const unit = toFiniteNumber(row.unit); + const number = toFiniteNumber(row.number); + if (unit === 3 && number === 5) { + quota.fiveHourPercent = percent; + if (resetAt !== undefined) quota.fiveHourResetAt = resetAt; + windows += 1; + } else if (unit === 6 && number === 1) { + quota.weeklyPercent = percent; + if (resetAt !== undefined) quota.weeklyResetAt = resetAt; + windows += 1; + } + } + return windows > 0 ? quota : null; +} + +/** + * Legacy Z.AI payload shape: percent fields with window identifiers directly on + * the data object (optionally nested under `quota`). Kept as a fallback so + * older responses keep rendering when the `limits` array is absent. + */ +function parseZaiQuotaLegacyFields(data: Record | null): ProviderQuota | null { + if (!data) return null; + const quota: ProviderQuota = { updatedAt: Date.now() }; + let windows = 0; + const percentAt = (key: string): number | undefined => { + const value = normalizePercent(data[key]); + if (value !== undefined) return value; + const nested = asRecord(data.quota); + return nested ? normalizePercent(nested[key]) : undefined; + }; + const fiveHour = percentAt("fiveHourPercent") ?? percentAt("fiveHourUsage") ?? percentAt("fiveHourUsed"); + const weekly = percentAt("weeklyPercent") ?? percentAt("weeklyUsage") ?? percentAt("weeklyUsed"); + const monthly = percentAt("monthlyPercent") ?? percentAt("mcpPercent") ?? percentAt("monthlyMCPUsage"); + if (fiveHour !== undefined) { + quota.fiveHourPercent = fiveHour; + windows += 1; + } + if (weekly !== undefined) { + quota.weeklyPercent = weekly; + windows += 1; + } + if (monthly !== undefined) { + quota.monthlyPercent = monthly; + windows += 1; + } + return windows > 0 ? quota : null; +} + +/** + * Fetches the Z.AI GLM Coding Plan quota — on whichever region the provider + * points at (api.z.ai or open.bigmodel.cn). The `limits` array shape is + * preferred; older field-name payloads fall back to the legacy parser. + * + * Authentication differs by host (issue #1168). `api.z.ai` takes the API key as + * a Bearer token per Z.AI's API reference; `open.bigmodel.cn` expects the key + * directly in `Authorization` with no scheme prefix and answers a Bearer header + * with an auth error, which is why BigModel Coding Plan quota never rendered. + * The host is already canonicalized by `isCanonicalZaiBaseUrl` above and + * `redirect: "error"` stays set, so the bare key cannot travel to a lookalike + * host or follow a redirect off-origin. + */ +async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promise { + const monitorHost = zaiQuotaMonitorHost(config.baseUrl); + if (!monitorHost) return null; + const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + if (!apiKey) return null; + const authorization = monitorHost === ZAI_CN_BASE_URL ? apiKey : `Bearer ${apiKey}`; + const response = await fetch(`${monitorHost}/api/monitor/usage/quota/limit`, { + headers: { Accept: "application/json", Authorization: authorization }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await readQuotaJson(response)); + if (!body || body.success === false) return null; + const data = asRecord(body.data) ?? body; + if (Array.isArray(data?.limits)) { + const quota = parseZaiQuotaLimits(data); + // A well-formed `limits[]` we fully understood is authoritative even when it yields no + // model window — for example a plan reporting only the monthly MCP `TIME_LIMIT` row. + // Returning `null` here would preserve the previous token windows for up to 30 minutes + // and keep quota-aware routing acting on a report the provider has already superseded. + return quota + ? keyReport(provider, "zai:quota-limit", quota, config, apiKey, quota) + : AUTHORITATIVE_EMPTY_QUOTA; + } + const legacy = parseZaiQuotaLegacyFields(data); + if (!legacy) return null; + // The legacy monthly figure also carries MCP usage; it is display evidence, not + // proof that model inference is unavailable. Modern TOKEN_LIMIT rows above are scoped. + const inferenceQuota = { ...legacy }; + delete inferenceQuota.monthlyPercent; + delete inferenceQuota.monthlyResetAt; + return keyReport(provider, "zai:quota-limit", legacy, config, apiKey, inferenceQuota); +} + +/** + * MiniMax Token Plan `GET /v1/token_plan/remains` — the subscription's + * remaining quota as a countdown-time value (ms). The endpoint does not expose + * the plan's total duration, so no percentage is fabricated from a presumed + * window: the remaining time is reported as a duration-only window. When the + * API supplies a total (`total_time` / `plan_duration_ms`), a consumed share + * is derived from it. Region selects the host: `minimax` → www.minimax.io, + * `minimax-cn` → api.minimaxi.com. + */ +async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): Promise { + if (!isCanonicalMinimaxBaseUrl(config.baseUrl)) return null; + const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + if (!apiKey) return null; + const cnHost = normalizedBaseUrl(config.baseUrl)?.startsWith("https://api.minimaxi.com"); + const remainsUrl = cnHost ? "https://api.minimaxi.com/v1/token_plan/remains" : MINIMAX_REMAINS_URL; + const response = await fetch(remainsUrl, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await readQuotaJson(response)); + if (!body || body.success === false) return null; + const data = asRecord(body.data) ?? body; + const remainsMs = toFiniteNumber(data.remains_time ?? data.remainsTime); + if (remainsMs === undefined || remainsMs < 0) return null; + const hours = Math.floor(remainsMs / 3_600_000); + const label = `Token Plan remaining (${hours}h)`; + // Only derive a consumed share when the API actually reports the plan total; + // a presumed window (e.g. 30 days) would fabricate utilization. A valid + // response that omits the total after a prior refresh had it is a DELIBERATE + // contract change — the old row must be dropped (terminal), not preserved as + // a transient last-good. + const totalMs = toFiniteNumber(data.total_time ?? data.plan_duration_ms ?? data.total_duration_ms); + if (totalMs === undefined || totalMs <= 0) return TERMINAL_QUOTA_FAILURE; + const consumed = Math.max(0, totalMs - remainsMs); + const percent = normalizePercent((consumed / totalMs) * 100); + if (percent === undefined) return null; + return report(provider, "minimax:token-plan-remains", { + customWindows: [{ label, percent }], + updatedAt: Date.now(), + }); +} + +/** + * Moonshot/Kimi `GET /v1/users/me/balance` — the account's available balance + * (voucher + cash). Renders a single balance window against the sum of + * voucher + cash when positive (there is no per-window rate limit to meter). + */ +async function fetchMoonshotQuota(provider: string, config: OcxProviderConfig): Promise { + if (!isCanonicalMoonshotBaseUrl(config.baseUrl)) return null; + const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + if (!apiKey) return null; + const host = normalizedBaseUrl(config.baseUrl)?.startsWith("https://api.moonshot.cn") ? "https://api.moonshot.cn/v1" : MOONSHOT_BASE_URL; + const response = await fetch(`${host}/users/me/balance`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await readQuotaJson(response)); + const data = asRecord(body?.data) ?? body; + if (!data) return null; + const available = toFiniteNumber(data.available_balance); + const voucher = toFiniteNumber(data.voucher_balance); + const cash = toFiniteNumber(data.cash_balance); + if (available === undefined || available < 0) return null; + // Moonshot exposes no per-window quota ceiling, only a balance — report it + // as a balance-only window (percent 0) rather than a fabricated utilization. + // Currency is host-scoped: China platform (api.moonshot.cn) bills in CNY; + // the international platform (api.moonshot.ai) bills in USD. Do not force + // either side into the other unit — the number is correct, only the unit + // must match the host. + const isChinaHost = host.startsWith("https://api.moonshot.cn"); + const money = (n: number) => isChinaHost ? `¥${n.toFixed(2)}` : `$${n.toFixed(2)}`; + const unit = isChinaHost ? "CNY" : "USD"; + const label = voucher !== undefined && cash !== undefined + ? `Balance (${money(available)} ${unit} available, ${money(voucher)} voucher)` + : `Balance (${money(available)} ${unit} available)`; + return report(provider, "moonshot:balance", { + customWindows: [{ label, percent: 0 }], + updatedAt: Date.now(), + }); +} + +/** + * Venice `GET /api/v1/billing/balance` — DIEM (native credits) or USD balance. + * Shows the remaining balance; epoch allocation progress when present. + */ +async function fetchVeniceQuota(provider: string, config: OcxProviderConfig): Promise { + if (!isCanonicalVeniceBaseUrl(config.baseUrl)) return null; + const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + if (!apiKey) return null; + const response = await fetch(`${VENICE_BASE_URL}/billing/balance`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await readQuotaJson(response)); + const data = asRecord(body?.data) ?? body; + if (!data) return null; + const diemBalance = toFiniteNumber(data.balance); + const usdBalance = toFiniteNumber(data.balance_usd); + const epochUsed = toFiniteNumber(data.diem_epoch_used); + const epochAllocated = toFiniteNumber(data.diem_epoch_allocated); + if (diemBalance === undefined && usdBalance === undefined) return null; + const label = diemBalance !== undefined + ? `DIEM balance (${Math.round(diemBalance)})` + : `USD balance ($${usdBalance?.toFixed(2) ?? "?"})`; + if (epochAllocated !== undefined && epochAllocated > 0 && epochUsed !== undefined) { + const percent = normalizePercent((epochUsed / epochAllocated) * 100); + if (percent === undefined) return null; + return report(provider, "venice:billing-balance", { + customWindows: [{ label, percent }], + updatedAt: Date.now(), + }); + } + return report(provider, "venice:billing-balance", { + customWindows: [{ label, percent: 0 }], + updatedAt: Date.now(), + }); +} + +/** + * Synthetic `GET /v2/quotas` — the known quota lanes (rolling 5-hour, + * weekly token, search-hourly) mapped onto the quota windows. + */ +async function fetchSyntheticQuota(provider: string, config: OcxProviderConfig): Promise { + if (!isCanonicalSyntheticBaseUrl(config.baseUrl)) return null; + const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + if (!apiKey) return null; + const response = await fetch(`${SYNTHETIC_BASE_URL}/quotas`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await readQuotaJson(response)); + const data = asRecord(body?.data) ?? body; + const quota: ProviderQuota = { updatedAt: Date.now() }; + let windows = 0; + const percentAt = (key: string): number | undefined => { + const value = normalizePercent(data?.[key]); + if (value !== undefined) return value; + const nested = asRecord(data?.quota) ?? asRecord(data?.quotas); + return nested ? normalizePercent(nested[key]) : undefined; + }; + const fiveHour = percentAt("rollingFiveHourLimit"); + const weekly = percentAt("weeklyTokenLimit"); + if (fiveHour !== undefined) { + quota.fiveHourPercent = fiveHour; + windows += 1; + } + if (weekly !== undefined) { + quota.weeklyPercent = weekly; + windows += 1; + } + const search = asRecord(data?.search); + const searchHourly = search ? normalizePercent(search.hourly) : undefined; + if (searchHourly !== undefined) { + quota.customWindows = [...(quota.customWindows ?? []), { label: "Search hourly", percent: searchHourly }]; + windows += 1; + } + const inferenceQuota = { ...quota }; + delete inferenceQuota.customWindows; // search.hourly does not constrain model inference. + return windows > 0 ? keyReport(provider, "synthetic:quotas", quota, config, apiKey, inferenceQuota) : null; +} + +/** + * DeepInfra `GET /payment/checklist?compute_owed=true` — prepaid balance, + * recent spend, spending limit, and suspension state. Renders a balance + * window (prepaid funds are a negative `stripe_balance` → positive available). + */ +async function fetchDeepInfraQuota(provider: string, config: OcxProviderConfig): Promise { + if (!isCanonicalDeepInfraBaseUrl(config.baseUrl)) return null; + const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + if (!apiKey) return null; + const response = await fetch(`${DEEPINFRA_BASE_URL}/payment/checklist?compute_owed=true`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await readQuotaJson(response)); + const data = asRecord(body?.data) ?? body; + if (!data) return null; + const stripeBalance = toFiniteNumber(data.stripe_balance); + const spendLimit = toFiniteNumber(data.spending_limit); + const total = toFiniteNumber(data.total_amount_due); + if (stripeBalance === undefined) return null; + // Prepaid funds are negative; a positive value is money owed. + const available = stripeBalance < 0 ? -stripeBalance : 0; + if (spendLimit !== undefined && spendLimit > 0) { + const spent = total !== undefined && total > 0 ? total : Math.max(0, spendLimit - available); + const percent = normalizePercent((spent / spendLimit) * 100); + if (percent === undefined) return null; + return report(provider, "deepinfra:billing-checklist", { + customWindows: [{ label: `Billing cycle spend ($${spent.toFixed(2)} of $${spendLimit.toFixed(2)})`, percent }], + updatedAt: Date.now(), + }); + } + return report(provider, "deepinfra:billing-checklist", { + customWindows: [{ label: `Prepaid balance ($${available.toFixed(2)})`, percent: 0 }], + updatedAt: Date.now(), + }); +} + +/** + * Neuralwatt `GET /v1/quota` — subscription kWh usage (primary window) and + * prepaid USD credit balance (secondary). + */ +async function fetchNeuralwattQuota(provider: string, config: OcxProviderConfig): Promise { + if (!isCanonicalNeuralwattBaseUrl(config.baseUrl)) return null; + const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + if (!apiKey) return null; + const response = await fetch(`${NEURALWATT_BASE_URL}/quota`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await readQuotaJson(response)); + const data = asRecord(body?.data) ?? body; + const quota: ProviderQuota = { updatedAt: Date.now() }; + let windows = 0; + const subscription = asRecord(data?.subscription); + const kwhUsed = subscription ? toFiniteNumber(subscription.kwh_used) : undefined; + const kwhIncluded = subscription ? toFiniteNumber(subscription.kwh_included) : undefined; + if (kwhUsed !== undefined && kwhIncluded !== undefined && kwhIncluded > 0) { + const percent = normalizePercent((kwhUsed / kwhIncluded) * 100); + if (percent !== undefined) { + quota.fiveHourPercent = percent; + const periodEnd = subscription ? normalizeResetAt(subscription.current_period_end) : undefined; + if (periodEnd !== undefined) quota.fiveHourResetAt = periodEnd; + windows += 1; + } + } + const balance = asRecord(data?.balance); + const totalCredits = balance ? toFiniteNumber(balance.total_credits_usd) : undefined; + const remainingCredits = balance ? toFiniteNumber(balance.credits_remaining_usd) : undefined; + if (totalCredits !== undefined && totalCredits > 0 && remainingCredits !== undefined) { + // Utilization is CONSUMED credits, not the remaining share. + const used = Math.max(0, totalCredits - remainingCredits); + const percent = normalizePercent((used / totalCredits) * 100); + if (percent !== undefined) { + quota.customWindows = [...(quota.customWindows ?? []), { label: "Prepaid credits", percent }]; + windows += 1; + } + } + return windows > 0 ? report(provider, "neuralwatt:quota", quota) : null; +} + + +function normalizedBaseUrl(value: string): string | null { + try { + const url = new URL(value); + if (url.username || url.password || url.search || url.hash) return null; + return `${url.origin.toLowerCase()}${url.pathname.replace(/\/+$/, "")}`; + } catch { + return null; + } +} + +function quotaResetAt(row: Record): number | undefined { + return normalizeResetAt(row.resetTime ?? row.resetAt ?? row.reset_time ?? row.reset_at); +} + +export function isCanonicalKimiCodeBaseUrl(baseUrl: string): boolean { + return normalizedBaseUrl(baseUrl) === KIMI_CODE_BASE_URL; +} + +export function isCanonicalCommandCodeBaseUrl(baseUrl: string): boolean { + const normalized = normalizedBaseUrl(baseUrl); + // OAuth preset points at the API root; the Provider-API preset at /provider/v1. + return normalized === COMMAND_CODE_BASE_URL || normalized === `${COMMAND_CODE_BASE_URL}/provider/v1`; +} + +/** Prefer the nested `data` shell when the outer object is only an envelope. */ +function unwrapKimiQuotaPayload(value: unknown): Record | null { + const body = asRecord(value); + if (!body) return null; + const nested = asRecord(body.data); + if (!nested) return body; + // A null/non-usable outer field is a placeholder, not data — an envelope like + // { usage: null, data: { usage: {...} } } must still unwrap to the nested payload. + const usable = (field: unknown): boolean => field !== undefined && field !== null; + const outerHasUsage = usable(body.usage) || usable(body.limits) || usable(body.totalQuota); + const nestedHasUsage = usable(nested.usage) || usable(nested.limits) || usable(nested.totalQuota); + return !outerHasUsage && nestedHasUsage ? nested : body; +} + +function kimiLimitLabel(item: Record, detail: Record): string { + return [item.name, item.title, item.scope, detail.name, detail.title] + .filter((value): value is string => typeof value === "string") + .join(" ") + .toLowerCase(); +} + +function parseKimiQuotaRow(value: unknown, resetFallback?: Record): { percent: number; resetAt?: number } | null { + const row = asRecord(value); + if (!row) return null; + const resetAt = quotaResetAt(row) ?? (resetFallback ? quotaResetAt(resetFallback) : undefined); + const limit = toFiniteNumber(row.limit); + if (limit !== undefined && limit > 0) { + let used = toFiniteNumber(row.used); + if (used === undefined) { + const remaining = toFiniteNumber(row.remaining); + if (remaining !== undefined) used = limit - remaining; + } + if (used !== undefined) { + const percent = normalizePercent((used / limit) * 100); + if (percent !== undefined) return { percent, ...(resetAt !== undefined ? { resetAt } : {}) }; + } + } + // Some payloads expose utilisation directly when limit/used arithmetic is absent. + const direct = normalizePercent(row.utilization ?? row.percent ?? row.usedPercent ?? row.used_percent); + return direct === undefined ? null : { percent: direct, ...(resetAt !== undefined ? { resetAt } : {}) }; +} + +function isKimiFiveHourLimit(item: Record, detail: Record, window: Record): boolean { + const duration = toFiniteNumber(window.duration ?? item.duration ?? detail.duration); + const unit = String(window.timeUnit ?? item.timeUnit ?? detail.timeUnit ?? "").toUpperCase(); + if ((unit.includes("MINUTE") && duration === 300) || (unit.includes("HOUR") && duration === 5)) return true; + return /(^|\b)5\s*(?:h|hour)/.test(kimiLimitLabel(item, detail)); +} + +function isKimiWeeklyLimit(item: Record, detail: Record, window: Record): boolean { + const duration = toFiniteNumber(window.duration ?? item.duration ?? detail.duration); + const unit = String(window.timeUnit ?? item.timeUnit ?? detail.timeUnit ?? "").toUpperCase(); + if ((unit.includes("DAY") && duration === 7) || (unit.includes("HOUR") && duration === 168)) return true; + return /weekly|7\s*(?:d|day)/.test(kimiLimitLabel(item, detail)); +} + +function parseKimiQuotaPayload(value: unknown): ProviderQuota | null { + const body = unwrapKimiQuotaPayload(value); + if (!body) return null; + let weekly = parseKimiQuotaRow(body.usage); + const total = parseKimiQuotaRow(body.totalQuota); + let fiveHour: { percent: number; resetAt?: number } | null = null; + if (Array.isArray(body.limits)) { + for (const rawItem of body.limits) { + const item = asRecord(rawItem); + if (!item) continue; + const detail = asRecord(item.detail) ?? item; + const window = asRecord(item.window) ?? {}; + if (!fiveHour && isKimiFiveHourLimit(item, detail, window)) { + fiveHour = parseKimiQuotaRow(detail, window); + } + if (!weekly && isKimiWeeklyLimit(item, detail, window)) { + weekly = parseKimiQuotaRow(detail, window); + } + if (fiveHour && weekly) break; + } + } + const quota: ProviderQuota = { + ...(fiveHour ? { + fiveHourPercent: fiveHour.percent, + ...(fiveHour.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}), + } : {}), + ...(weekly ? { + weeklyPercent: weekly.percent, + ...(weekly.resetAt !== undefined ? { weeklyResetAt: weekly.resetAt } : {}), + } : {}), + ...(total ? { customWindows: [{ label: "Total subscription credits", percent: total.percent, ...(total.resetAt !== undefined ? { resetAt: total.resetAt } : {}) }] } : {}), + updatedAt: Date.now(), + }; + return hasQuotaRows(quota) ? quota : null; +} + +async function resolveKimiQuotaBearer(config: OcxProviderConfig, accountId?: string): Promise { + if (config.authMode === "oauth") { + try { + return accountId ? await getTokenForAccountQuotaProbe("kimi", accountId) : null; + } catch { + return null; + } + } + // ACTIVE key only: silently walking apiKeyPool when the primary env reference is + // unresolved would render a quota bar for a DIFFERENT account than the one routing + // requests — a wrong meter is worse than no meter. + const primary = resolveProviderApiKey(config.apiKey)?.trim(); + return primary || null; +} + +export async function fetchKimiQuota(provider: string, config: OcxProviderConfig, accessToken: string): Promise { + // Never release credentials to a user-edited or lookalike provider host. + if (!isCanonicalKimiCodeBaseUrl(config.baseUrl)) return null; + if (!accessToken) return null; + const response = await fetch(KIMI_CODE_USAGE_URL, { + headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) return null; + const quota = parseKimiQuotaPayload(await readQuotaJson(response)); + return quota ? keyReport(provider, "kimi:usages", quota, config, accessToken, quota) : null; +} + +/** + * Command Code rolling window: `{ cap, used, resetAt }` off /alpha/billing/credits, + * normalized to a percent with an optional reset timestamp. + */ +function parseCommandCodeWindow(value: unknown): { percent: number; resetAt?: number } | null { + const row = asRecord(value); + if (!row) return null; + const cap = toFiniteNumber(row.cap); + const used = toFiniteNumber(row.used); + if (cap === undefined || used === undefined || cap <= 0 || used < 0) return null; + const percent = normalizePercent((used / cap) * 100); + if (percent === undefined) return null; + const resetAt = quotaResetAt(row); + return { percent, ...(resetAt !== undefined ? { resetAt } : {}) }; +} + +/** Soft-fail GET returning a parsed record, or null when unavailable. */ +async function fetchCommandCodeJson(url: string, bearer: string): Promise | null> { + try { + const response = await fetch(url, { + headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) return null; + return asRecord(await readQuotaJson(response)); + } catch { + return null; + } +} + +/** + * Soft-fail period spend (used) against the remaining credit pools → creditsUsd. + * Period scoping: `since=` keeps spend aligned with the + * pools' billing cycle, and `currentPeriodEnd` becomes expiresAt. + */ +async function fetchCommandCodeSpend( + bearer: string, + credits: Record | null, + orgQuery: string, +): Promise { + if (!credits) return undefined; + const subscriptionBody = await fetchCommandCodeJson(`${COMMAND_CODE_SUBSCRIPTIONS_URL}${orgQuery}`, bearer); + const subscription = asRecord(subscriptionBody?.data) ?? subscriptionBody; + const periodStart = typeof subscription?.currentPeriodStart === "string" ? subscription.currentPeriodStart.trim() : ""; + // Unscoped /usage/summary is lifetime spend; mixing it with current-cycle + // remaining pools produces a wrong percent. Omit creditsUsd until a period exists. + if (!periodStart) return undefined; + const sinceQuery = `${orgQuery ? "&" : "?"}since=${encodeURIComponent(periodStart)}`; + const expiresAt = normalizeResetAt(subscription?.currentPeriodEnd); + const summaryBody = await fetchCommandCodeJson(`${COMMAND_CODE_USAGE_URL}${orgQuery}${sinceQuery}`, bearer); + const summary = asRecord(summaryBody?.data) ?? summaryBody; + const used = toFiniteNumber(summary?.totalCost) ?? toFiniteNumber(summary?.totalMonthlyCredits); + if (used === undefined || used < 0) return undefined; + const pools = [credits.monthlyCredits, credits.purchasedCredits, credits.freeCredits] + .map(value => toFiniteNumber(value)) + .filter((value): value is number => value !== undefined); + // Field presence is what separates a real balance from absent data: an exhausted + // all-zero account still reports remaining=0, while no remaining-credit field at + // all means there is nothing to meter. + if (pools.length === 0) return undefined; + const remaining = pools.reduce((sum, value) => sum + Math.max(0, value ?? 0), 0); + const limit = used + remaining; + const percent = normalizePercent(limit > 0 ? (used / limit) * 100 : 0); + // Purchased credits roll over past the subscription period end, so an expiry is + // only truthful when the aggregate contains no non-expiring purchased pool. + const purchased = toFiniteNumber(credits.purchasedCredits) ?? 0; + return percent === undefined + ? undefined + : { + used, + limit, + remaining, + percent, + ...(expiresAt !== undefined && purchased <= 0 ? { expiresAt } : {}), + }; +} + +/** OAuth access token or ACTIVE Provider-API key for the Command Code quota probe. */ +async function resolveCommandCodeQuotaBearer(config: OcxProviderConfig, accountId?: string): Promise { + if (config.authMode === "oauth") { + try { + return accountId ? await getTokenForAccountQuotaProbe("command-code", accountId) : null; + } catch { + return null; + } + } + // ACTIVE key only: a quota bar for a different account than the one routing + // requests is a wrong meter, not a helpful one. + return resolveProviderApiKey(config.apiKey)?.trim() || null; +} + +/** + * Command Code `GET /alpha/billing/credits` — the same Bearer surface the CLI's + * usage view uses (windowLimits.fiveHour / windowLimits.weekly), plus soft + * whoami (team orgId scoping) and subscription-scoped spend for creditsUsd. + */ +export async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig, bearer: string): Promise { + // Never release credentials to a user-edited or lookalike provider host. + if (!isCanonicalCommandCodeBaseUrl(config.baseUrl)) return null; + if (!bearer) return null; + const whoamiBody = await fetchCommandCodeJson(COMMAND_CODE_WHOAMI_URL, bearer); + const whoami = asRecord(whoamiBody?.data) ?? whoamiBody; + const org = asRecord(whoami?.org); + const orgId = typeof org?.id === "string" && org.id.trim() ? org.id.trim() : null; + const orgQuery = orgId ? `?orgId=${encodeURIComponent(orgId)}` : ""; + const response = await fetch(`${COMMAND_CODE_CREDITS_URL}${orgQuery}`, { + headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const raw = asRecord(await readQuotaJson(response)); + const body = asRecord(raw?.data) ?? raw; + const credits = asRecord(body?.credits); + const limits = asRecord(body?.windowLimits); + if (!credits && !limits) return null; + const fiveHour = parseCommandCodeWindow(limits?.fiveHour); + const weekly = parseCommandCodeWindow(limits?.weekly); + const creditsUsd = await fetchCommandCodeSpend(bearer, credits, orgQuery); + const quota: ProviderQuota = { + ...(fiveHour ? { + fiveHourPercent: fiveHour.percent, + ...(fiveHour.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}), + } : {}), + ...(weekly ? { + weeklyPercent: weekly.percent, + ...(weekly.resetAt !== undefined ? { weeklyResetAt: weekly.resetAt } : {}), + } : {}), + ...(creditsUsd ? { creditsUsd } : {}), + updatedAt: Date.now(), + }; + // Rolling windows and the credit balance both gate inference on this bearer. + return keyReport(provider, "command-code:credits", quota, config, bearer, quota); +} + + +type KeyQuotaReader = (name: string, provider: OcxProviderConfig) => Promise; + +/** Same selector drives cheap capabilities and uncached reads; never resolves credentials. */ +export function keyQuotaReaderForProvider(name: string, provider: OcxProviderConfig): KeyQuotaReader | null { + if (provider.disabled === true || (provider.authMode ?? "key") !== "key") return null; + if (isCanonicalKimiCodeBaseUrl(provider.baseUrl)) { + return async (id, config) => { + const bearer = await resolveKimiQuotaBearer(config); + return bearer ? fetchKimiQuota(id, config, bearer) : null; + }; + } + if (name === "commandcode" && isCanonicalCommandCodeBaseUrl(provider.baseUrl)) { + return async (id, config) => { + const bearer = await resolveCommandCodeQuotaBearer(config); + return bearer ? fetchCommandCodeQuota(id, config, bearer) : null; + }; + } + if (registryEntryForProviderDestination(provider)?.id === "opencode-go") return fetchOpenCodeGoQuota; + if (isCanonicalA6apiBaseUrl(provider.baseUrl)) return fetchA6apiQuota; + if (name === "openrouter" && isCanonicalOpenRouterBaseUrl(provider.baseUrl)) return fetchOpenRouterQuota; + if (name === "deepseek" && isCanonicalDeepSeekBaseUrl(provider.baseUrl)) return fetchDeepSeekQuota; + if (name === "cline-pass" && isCanonicalClineBaseUrl(provider.baseUrl)) return fetchClineQuota; + if (isCanonicalOllamaCloudBaseUrl(provider.baseUrl ?? getProviderRegistryEntry(name)?.baseUrl)) return fetchOllamaCloudQuota; + // #4201: the Responses preset is the same domestic GLM Coding Plan subscription on the OpenAI + // Responses wire, so it reads the same monitor endpoint. Eligibility stays a name list AND the + // canonical-URL guard: the guard is what keeps BigModel's bare-key Authorization from reaching a + // lookalike host, so a same-named custom destination still dispatches nothing. + if (["zai", "glm", "glm-cn", "zhipu-bigmodel-coding", "zhipu-bigmodel-responses"].includes(name) && isCanonicalZaiBaseUrl(provider.baseUrl)) return fetchZaiQuota; + if (["minimax", "minimax-cn"].includes(name) && isCanonicalMinimaxBaseUrl(provider.baseUrl)) return fetchMinimaxQuota; + if (name === "moonshot" && isCanonicalMoonshotBaseUrl(provider.baseUrl)) return fetchMoonshotQuota; + if (name === "venice" && isCanonicalVeniceBaseUrl(provider.baseUrl)) return fetchVeniceQuota; + if (name === "synthetic" && isCanonicalSyntheticBaseUrl(provider.baseUrl)) return fetchSyntheticQuota; + if (name === "deepinfra" && isCanonicalDeepInfraBaseUrl(provider.baseUrl)) return fetchDeepInfraQuota; + if (name === "neuralwatt" && isCanonicalNeuralwattBaseUrl(provider.baseUrl)) return fetchNeuralwattQuota; + return null; +} + +export function providerApiKeyQuotaMode(name: string, provider: OcxProviderConfig): AccountQuotaMode { + return keyQuotaReaderForProvider(name, provider) ? "probe" : "unsupported"; +} diff --git a/src/providers/quota/vendor-probes-oauth.ts b/src/providers/quota/vendor-probes-oauth.ts new file mode 100644 index 0000000000..7b9c9e7df5 --- /dev/null +++ b/src/providers/quota/vendor-probes-oauth.ts @@ -0,0 +1,590 @@ +import { effectiveCodexAuthAccountId, fetchMainAccountInfoSnapshot, listCodexAuthAccountsSnapshot } from "../../codex/auth-api"; +import { MAIN_CODEX_ACCOUNT_ID } from "../../codex/main-account"; +import { getValidAccessToken } from "../../oauth"; +import { getAccountCredential, getAccountSet } from "../../oauth/store"; +import { fetchMuseKeyQuotaSnapshot } from "../muse-key-quota"; +import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "../xai-transport"; +import { + commitKiroAccountUsageState, + fetchKiroUsageSnapshot, + type KiroUsageSnapshot, + kiroUsageContextForAccount, +} from "../kiro-usage"; +import { captureConfigGeneration } from "../../lib/state-store-sweeper"; +import { aggregateCodexPoolCapacity, CODEX_CAPACITY_MAX_QUOTA_AGE_MS, type CodexCapacityQuota } from "../codex-capacity"; +import { asRecord, normalizePercent, normalizeResetAt, readQuotaJson, REQUEST_TIMEOUT_MS, toFiniteNumber } from "../quota-wire"; +import { providerCodexAccountMode } from "../registry"; +import { + hasQuotaRows, + providerLabel, + providerQuotaFromCodexQuota, + publicCapacityAggregation, + report, + tagNativeMainReport, + type CodexAuthAccountsSnapshotPromise, + type ProviderQuotaReport, +} from "./report-cache"; +import { + accountCacheKey, + accountQuotaCache, + hydrateAccountQuotaCache, + mayCommitAccountQuotaKey, + persistAccountQuotaCache, +} from "./account-cache"; +import type { OcxConfig, OcxProviderConfig } from "../../types"; +import type { ProviderQuota, ProviderQuotaWindow } from "../quota-types"; + +const XAI_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing"; +const XAI_CREDITS_URL = `${XAI_BILLING_URL}?format=credits`; + +export async function fetchChatGptForwardQuota( + config: OcxConfig, + provider: string, + providerConfig: OcxProviderConfig, + forceRefresh: boolean, + prefetchedSnapshot?: CodexAuthAccountsSnapshotPromise, +): Promise { + if (providerCodexAccountMode(provider, providerConfig) === "direct") { + const snapshot = await fetchMainAccountInfoSnapshot(forceRefresh); + const quota = providerQuotaFromCodexQuota(snapshot.info.quota); + if (quota) quota.updatedAt = Date.now(); + return quota + ? tagNativeMainReport(report(provider, "chatgpt:wham", quota), snapshot.mainIdentityGeneration) + : null; + } + const snapshot = await (prefetchedSnapshot ?? listCodexAuthAccountsSnapshot(config, forceRefresh)); + const accounts = snapshot.accounts; + const activeId = effectiveCodexAuthAccountId(config); + const capacityAccounts = accounts.map(account => ({ + ...account, + active: account.id === activeId, + quota: providerQuotaFromCodexQuota(account.quota), + })); + const active = capacityAccounts.find(account => account.active) + ?? capacityAccounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID) + ?? capacityAccounts[0]; + const now = Date.now(); + const capacity = aggregateCodexPoolCapacity(capacityAccounts, now); + if (capacity.aggregation && capacity.quota) { + return tagNativeMainReport( + report( + provider, + "chatgpt:wham", + capacity.quota as ProviderQuota, + publicCapacityAggregation(capacity.aggregation, "aggregate"), + ), + snapshot.mainIdentityGeneration, + ); + } + const activeUsable = !!active && !active.paused && active.needsReauth !== true; + const quota = activeUsable && active?.quota + ? { ...active.quota, updatedAt: active.quota.updatedAt ?? Date.now() } as CodexCapacityQuota + : null; + const quotaFresh = !!quota + && Number.isFinite(quota.updatedAt) + && now - quota.updatedAt < CODEX_CAPACITY_MAX_QUOTA_AGE_MS; + if (quota && quotaFresh) { + const fallback = report( + provider, + "chatgpt:wham", + quota as ProviderQuota, + capacity.aggregation + ? publicCapacityAggregation(capacity.aggregation, "effective-account-fallback") + : undefined, + ); + return tagNativeMainReport(fallback, snapshot.mainIdentityGeneration); + } + if (capacity.aggregation) { + const updatedAt = Date.now(); + return tagNativeMainReport( + { + provider, + label: providerLabel(provider), + source: "chatgpt:wham", + quota: { updatedAt }, + updatedAt, + aggregation: publicCapacityAggregation(capacity.aggregation, "coverage-only"), + }, + snapshot.mainIdentityGeneration, + ); + } + return null; +} + +function centsValue(value: unknown): number | undefined { + const rec = asRecord(value); + return rec ? toFiniteNumber(rec.val) : undefined; +} + +/** Decode JWT payload `sub` for xAI weekly credits when the stored credential lacks accountId. */ +function xaiUserIdFromAccessToken(accessToken: string): string | undefined { + const parts = accessToken.split("."); + if (parts.length < 2 || !parts[1]) return undefined; + try { + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as { sub?: unknown }; + return typeof payload.sub === "string" && payload.sub.trim() ? payload.sub.trim() : undefined; + } catch { + return undefined; + } +} + +/** + * Grok Build weekly credits envelope: + * `{ config: { creditUsagePercent?, currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", end } } }`. + * Omitted percent is treated as 0 (proto3 default). + */ +export function parseXaiCreditsResponse(value: unknown): { percent: number; resetAt?: number } | null { + const body = asRecord(value); + const config = asRecord(body?.config); + if (!config) return null; + const period = asRecord(config.currentPeriod); + if (!period || period.type !== "USAGE_PERIOD_TYPE_WEEKLY") return null; + let percent = 0; + if (config.creditUsagePercent !== undefined) { + const normalized = normalizePercent(config.creditUsagePercent); + if (normalized === undefined) return null; + percent = normalized; + } + const resetAt = normalizeResetAt(period.end); + return { + percent, + ...(resetAt !== undefined ? { resetAt } : {}), + }; +} + +async function fetchXaiWeeklyCredits(accessToken: string, userId: string): Promise { + try { + const response = await fetch(XAI_CREDITS_URL, { + redirect: "error", + headers: { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + [XAI_GROK_COMPATIBILITY.headers.tokenAuth]: "xai-grok-cli", + [XAI_GROK_COMPATIBILITY.headers.authenticateResponse]: "authenticate-response", + "x-userid": userId, + [XAI_GROK_COMPATIBILITY.headers.clientVersion]: XAI_GROK_CLIENT_VERSION, + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) return null; + const parsed = parseXaiCreditsResponse(await readQuotaJson(response)); + if (!parsed) return null; + return { + weeklyPercent: parsed.percent, + ...(parsed.resetAt !== undefined ? { weeklyResetAt: parsed.resetAt } : {}), + updatedAt: Date.now(), + }; + } catch { + return null; + } +} + +export async function fetchXaiQuota(provider: string, context: { accessToken: string; upstreamAccountId?: string }): Promise { + const { accessToken } = context; + + // Prefer the SuperGrok weekly credits window that actually gates prompting (#1283). + const userId = context.upstreamAccountId?.trim() || xaiUserIdFromAccessToken(accessToken); + if (userId) { + const weekly = await fetchXaiWeeklyCredits(accessToken, userId); + if (weekly) return report(provider, "xai:grok-billing-credits", weekly); + } + + // Legacy monthly dollar pool — retained when weekly is unavailable. + try { + const response = await fetch(XAI_BILLING_URL, { + redirect: "error", + headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) return null; + const body = asRecord(await readQuotaJson(response)); + const config = asRecord(body?.config); + if (!config) return null; + const limitCents = centsValue(config.monthlyLimit); + const usedCents = centsValue(config.used); + if (limitCents === undefined || usedCents === undefined || limitCents <= 0) return null; + const percent = normalizePercent((usedCents / limitCents) * 100); + if (percent === undefined) return null; + return report(provider, "xai:grok-billing", { + monthlyPercent: percent, + monthlyResetAt: normalizeResetAt(config.billingPeriodEnd), + updatedAt: Date.now(), + }); + } catch { + return null; + } +} + +function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number } | null { + const rec = asRecord(value); + if (!rec) return null; + const percent = normalizePercent(rec.utilization); + const resetAt = normalizeResetAt(rec.resets_at); + if (percent === undefined && resetAt === undefined) return null; + return { percent, resetAt }; +} + +function parseClaudeLimit(value: unknown): { label: string; percent: number; resetAt?: number } | null { + const rec = asRecord(value); + if (!rec) return null; + const percent = normalizePercent(rec.percent); + if (percent === undefined) return null; + const scope = asRecord(rec.scope); + const model = asRecord(scope?.model); + const rawLabel = String(model?.display_name ?? "").trim(); + if (!rawLabel) return null; + const lowerLabel = rawLabel.toLowerCase(); + const label = lowerLabel.includes("fable") ? "Fable" + : lowerLabel.includes("opus") ? "Opus" + : lowerLabel.includes("sonnet") ? "Sonnet" + : rawLabel; + const resetAt = normalizeResetAt(rec.resets_at); + return { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) }; +} + +/** Claude's OAuth usage endpoint, probed with ONE account's own bearer token. */ +const anthropicUsageInflight = new Map>(); + +/** + * Anthropic per-credential usage. + * + * This endpoint reports quota only. Its body carries `five_hour`, `seven_day`, the + * model-scoped weekly buckets (`seven_day_fable`/`_opus`/`_sonnet`) and a `limits` array, + * and **no subscription or tier field** — nor does the OAuth token response, which yields only + * `account.uuid` and `account.email_address` (`src/oauth/anthropic.ts`). That is why + * `OAuthAccountSummary.plan` is `null` for Anthropic rather than populated here (#3777); it is + * a missing upstream field, not an unfinished mapping. + * + * A tier must not be inferred from what is here. Percentages are normalized per account, so a + * Max x5 seat at 50% is byte-identical to a Max x20 seat at 50%, and the presence of a + * model-scoped window tracks entitlement rather than seat size. Populate `plan` only when + * upstream returns the tier itself. + */ +export async function fetchAnthropicUsageQuota(accessToken: string): Promise { + const joinable = anthropicUsageInflight.get(accessToken); + if (joinable) return joinable; + + const probe = (async (): Promise => { + const response = await fetch("https://api.anthropic.com/api/oauth/usage", { + headers: { + Accept: "application/json, text/plain, */*", + "Content-Type": "application/json", + "User-Agent": "claude-cli/2.1.63 (external, cli)", + "anthropic-beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05", + Authorization: `Bearer ${accessToken}`, + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) return null; + const body = asRecord(await readQuotaJson(response)); + if (!body) return null; + const fiveHour = parseClaudeBucket(body.five_hour); + const sevenDay = parseClaudeBucket(body.seven_day); + const fable = parseClaudeBucket(body.seven_day_fable); + const opus = parseClaudeBucket(body.seven_day_opus); + const sonnet = parseClaudeBucket(body.seven_day_sonnet); + const customWindows: ProviderQuotaWindow[] = []; + if (fable?.percent !== undefined) customWindows.push({ label: "Fable", percent: fable.percent, ...(fable.resetAt !== undefined ? { resetAt: fable.resetAt } : {}) }); + if (opus?.percent !== undefined) customWindows.push({ label: "Opus", percent: opus.percent, ...(opus.resetAt !== undefined ? { resetAt: opus.resetAt } : {}) }); + if (sonnet?.percent !== undefined) customWindows.push({ label: "Sonnet", percent: sonnet.percent, ...(sonnet.resetAt !== undefined ? { resetAt: sonnet.resetAt } : {}) }); + const knownLabels = new Set(customWindows.map(window => window.label.toLowerCase())); + const limits = Array.isArray(body.limits) ? body.limits : []; + for (const rawLimit of limits) { + const limitRecord = asRecord(rawLimit); + // `session` and `weekly_all` mirror the canonical five-hour and weekly + // buckets above; only model-scoped weekly limits add a third window. + if (String(limitRecord?.kind ?? "").trim().toLowerCase() !== "weekly_scoped") continue; + const limit = parseClaudeLimit(rawLimit); + if (!limit || knownLabels.has(limit.label.toLowerCase())) continue; + knownLabels.add(limit.label.toLowerCase()); + customWindows.push(limit); + } + const quota: ProviderQuota = { + // Claude's 5-hour window is a first-class rate limit, same as the Codex login 5h/weekly + // rows: report it in the canonical fields so the dashboard renders it with the standard + // "5-hour limit" label and ordering instead of as a generic extra window. + ...(fiveHour?.percent !== undefined ? { fiveHourPercent: fiveHour.percent } : {}), + ...(fiveHour?.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}), + ...(sevenDay?.percent !== undefined ? { weeklyPercent: sevenDay.percent } : {}), + ...(sevenDay?.resetAt !== undefined ? { weeklyResetAt: sevenDay.resetAt } : {}), + ...(customWindows.length > 0 ? { customWindows } : {}), + updatedAt: Date.now(), + }; + // Empty / schema-changed payloads must not cache as "success with no bars". + return hasQuotaRows(quota) ? quota : null; + })().finally(() => { + if (anthropicUsageInflight.get(accessToken) === probe) anthropicUsageInflight.delete(accessToken); + }); + anthropicUsageInflight.set(accessToken, probe); + return probe; +} + +export async function fetchAnthropicQuota(provider: string): Promise { + // Capture the account we intend to probe before awaiting — a mid-flight active + // switch must not seed the wrong account's cache with this response. + const probedAccountId = getAccountSet("anthropic")?.activeAccountId; + const probedAccountKey = probedAccountId ? accountCacheKey("anthropic", probedAccountId) : null; + const writerGeneration = captureConfigGeneration(); + let accessToken: string; + try { + accessToken = await getValidAccessToken("anthropic"); + } catch { + return null; + } + const quota = await fetchAnthropicUsageQuota(accessToken); + if (!quota) return null; + // Share the active-account probe with the per-account cache so Providers-page + // loads do not double-hit Anthropic's rate-limited usage endpoint. + if (probedAccountId && probedAccountKey) { + const stillOwnsToken = getAccountCredential("anthropic", probedAccountId)?.access === accessToken; + if (stillOwnsToken && mayCommitAccountQuotaKey(probedAccountKey, writerGeneration)) { + accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota }); + } + } + return report(provider, "anthropic:oauth-usage", quota); +} + +/** + * Provider-level Kiro row: the active account's usage, shown on the Providers page. + * + * The per-account cache is seeded from the same probe so opening that page does not read + * the active account twice, and the account id is captured before the await so a + * concurrent account switch cannot file this answer under the wrong account. + */ +export async function fetchKiroQuota(provider: string): Promise { + const probedAccountId = getAccountSet("kiro")?.activeAccountId; + if (!probedAccountId) return null; + const probedAccountKey = accountCacheKey("kiro", probedAccountId); + const writerGeneration = captureConfigGeneration(); + let snapshot: KiroUsageSnapshot | null; + try { + snapshot = await fetchKiroUsageSnapshot(await kiroUsageContextForAccount(probedAccountId)); + } catch { + return null; + } + if (!snapshot) return null; + if (mayCommitAccountQuotaKey(probedAccountKey, writerGeneration)) { + accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota: snapshot.quota }); + commitKiroAccountUsageState(probedAccountKey, snapshot); + } + return report(provider, "kiro:usage-limits", snapshot.quota); +} + +/** + * Provider-level row probed from the key endpoint, for an account that CAN be probed. + * + * Written through the same account cache the passive path reads, so the measurement + * survives a restart and the per-account rows at oauth-account-routes.ts:313 pick it up + * with no mode change. Deliberately does not flip providerOAuthAccountQuotaMode: that + * mode selects readPassiveProviderAccountQuotas, and the probed per-account path it would + * switch to is gated on supportsPerAccountQuota, which has no meta-muse reader, so the + * GUI account list would go from showing observations to showing nothing. + */ +export async function fetchMuseKeyQuota(provider: string): Promise { + const probedAccountId = getAccountSet(provider)?.activeAccountId; + if (!probedAccountId) return null; + const oauthAccessToken = getAccountCredential(provider, probedAccountId)?.muse?.oauthAccessToken; + // An imported or pasted credential has no account token and never will: it is + // capability, not provider id, that decides whether a probe is possible. + if (!oauthAccessToken) return null; + const probedAccountKey = accountCacheKey(provider, probedAccountId); + const writerGeneration = captureConfigGeneration(); + const quota = await fetchMuseKeyQuotaSnapshot(probedAccountId, oauthAccessToken); + if (!quota) return null; + if (mayCommitAccountQuotaKey(probedAccountKey, writerGeneration)) { + // Hydrate before writing, for the same reason recordPassiveAccountQuota does: + // persistAccountQuotaCache serializes the whole in-memory map. + hydrateAccountQuotaCache(); + accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota }); + persistAccountQuotaCache(); + } + return report(provider, `${provider}:key-endpoint`, quota); +} +/** + * Provider-level row for a passive provider: the ACTIVE account's last observed + * subscription windows, the same shape `fetchAnthropicQuota` and `fetchKiroQuota` + * return. + * + * Cache-only. A dashboard load or `ocx account refresh` must never spend an inference + * turn, so `forceRefresh` does not exist on this path — there is nothing to refresh. + * `report.updatedAt` is the observation time, which is what both GUI surfaces render + * as the relative age of the row. + */ +export async function fetchPassiveProviderQuota(provider: string): Promise { + const activeId = getAccountSet(provider)?.activeAccountId; + if (!activeId) return null; + // Idempotent; without it a proxy restart shows nothing until the next streaming turn + // even though the last observation is on disk. + hydrateAccountQuotaCache(); + const entry = accountQuotaCache.get(accountCacheKey(provider, activeId)); + if (!entry?.quota) return null; + const built = report(provider, `${provider}:subscription-observation`, entry.quota); + // Tagged here rather than inside report(), which every probed path shares. + return built ? { ...built, observed: true } : null; +} + +// --------------------------------------------------------------------------- +// Per-account quota (multiauth) +// --------------------------------------------------------------------------- + + +/** Cursor included usage via api2.cursor.sh (Bearer from OAuth) — unofficial, may change. */ +export async function fetchCursorQuota(provider: string, accessToken: string): Promise { + + const authHeaders = { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + "User-Agent": "opencodex-quota", + } as const; + + // Prefer dashboard period usage (Pro/Team/Ultra spend allowance in USD cents). + // Field names follow Cursor's Connect RPC shape (limit/remaining/includedSpend), not usedCents. + try { + const periodRes = await fetch("https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", { + method: "POST", + redirect: "error", + headers: { + ...authHeaders, + "Content-Type": "application/json", + "Connect-Protocol-Version": "1", + }, + body: "{}", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (periodRes.ok) { + const body = asRecord(await readQuotaJson(periodRes)); + const planUsage = asRecord(body?.planUsage); + if (planUsage) { + const resetAt = normalizeResetAt(body?.billingCycleEnd ?? planUsage.billingCycleEnd ?? body?.periodEnd); + + // Primary meter: overall included allowance (Cursor Settings → Usage total %). + // autoPercentUsed / apiPercentUsed are secondary pools and must not replace the total. + const limit = toFiniteNumber(planUsage.limit ?? planUsage.limitCents ?? planUsage.totalLimitCents); + const remaining = toFiniteNumber(planUsage.remaining ?? planUsage.remainingCents); + const includedSpend = toFiniteNumber(planUsage.includedSpend ?? planUsage.usedCents ?? planUsage.used); + const totalSpend = toFiniteNumber(planUsage.totalSpend); + let used: number | undefined; + if (includedSpend !== undefined) used = includedSpend; + else if (limit !== undefined && remaining !== undefined) used = Math.max(0, limit - remaining); + else if (totalSpend !== undefined) used = totalSpend; + const totalPercent = normalizePercent(planUsage.totalPercentUsed ?? planUsage.percentUsed) + ?? (limit !== undefined && limit > 0 && used !== undefined + ? normalizePercent((used / limit) * 100) + : undefined); + + const autoPercent = normalizePercent(planUsage.autoPercentUsed); + const apiPercent = normalizePercent(planUsage.apiPercentUsed); + const customWindows: ProviderQuotaWindow[] = []; + if (autoPercent !== undefined) { + customWindows.push({ + label: "First-party models", + percent: autoPercent, + ...(resetAt !== undefined ? { resetAt } : {}), + }); + } + if (apiPercent !== undefined) { + customWindows.push({ + label: "API usage", + percent: apiPercent, + ...(resetAt !== undefined ? { resetAt } : {}), + }); + } + + if (totalPercent !== undefined || customWindows.length > 0) { + const built = report(provider, "cursor:period-usage", { + ...(totalPercent !== undefined ? { + monthlyPercent: totalPercent, + ...(resetAt !== undefined ? { monthlyResetAt: resetAt } : {}), + } : {}), + ...(customWindows.length > 0 ? { customWindows } : {}), + updatedAt: Date.now(), + }); + if (built) return { ...built, reverseEngineered: true }; + } + } + } + } catch { + /* fall through */ + } + + // /api/usage/summary — same host, sometimes richer than /auth/usage for Team plans. + try { + const summaryRes = await fetch("https://api2.cursor.sh/api/usage/summary", { + headers: authHeaders, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (summaryRes.ok) { + const body = asRecord(await readQuotaJson(summaryRes)); + const individual = asRecord(body?.individualUsage); + const plan = asRecord(individual?.plan); + if (plan) { + const used = toFiniteNumber(plan.used); + const limit = toFiniteNumber(plan.limit); + const percent = normalizePercent(plan.totalPercentUsed) + ?? (used !== undefined && limit !== undefined && limit > 0 + ? normalizePercent((used / limit) * 100) + : undefined); + if (percent !== undefined) { + const built = report(provider, "cursor:usage-summary", { + monthlyPercent: percent, + monthlyResetAt: normalizeResetAt(body?.billingCycleEnd), + updatedAt: Date.now(), + }); + if (built) return { ...built, reverseEngineered: true }; + } + } + } + } catch { + /* fall through to /auth/usage */ + } + + const response = await fetch("https://api2.cursor.sh/auth/usage", { + headers: authHeaders, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) return null; + const body = asRecord(await readQuotaJson(response)); + if (!body) return null; + + // Prefer the gpt-4 bucket (historical "fast requests"); else first model with used+limit. + let used: number | undefined; + let limit: number | undefined; + const gpt4 = asRecord(body["gpt-4"]); + if (gpt4) { + used = toFiniteNumber(gpt4.numRequests ?? gpt4.used); + limit = toFiniteNumber(gpt4.maxRequestUsage ?? gpt4.limit ?? gpt4.maxRequests); + } + if (used === undefined || limit === undefined || limit <= 0) { + for (const [key, value] of Object.entries(body)) { + if (key === "startOfMonth" || key === "billingCycleStart") continue; + const bucket = asRecord(value); + if (!bucket) continue; + const bucketUsed = toFiniteNumber(bucket.numRequests ?? bucket.used); + const bucketLimit = toFiniteNumber(bucket.maxRequestUsage ?? bucket.limit ?? bucket.maxRequests); + if (bucketUsed !== undefined && bucketLimit !== undefined && bucketLimit > 0) { + used = bucketUsed; + limit = bucketLimit; + break; + } + } + } + if (used === undefined || limit === undefined || limit <= 0) return null; + const percent = normalizePercent((used / limit) * 100); + if (percent === undefined) return null; + const startOfMonth = normalizeResetAt(body.startOfMonth ?? body.billingCycleStart); + // Next reset = same day next month, computed in UTC to avoid timezone-shifted rollover. + const monthlyResetAt = startOfMonth !== undefined + ? (() => { + const start = new Date(startOfMonth); + return Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, start.getUTCDate()); + })() + : undefined; + const built = report(provider, "cursor:auth-usage", { + monthlyPercent: percent, + ...(monthlyResetAt !== undefined ? { monthlyResetAt } : {}), + updatedAt: Date.now(), + }); + return built ? { ...built, reverseEngineered: true } : null; +} diff --git a/structure/catalog.md b/structure/catalog.md index 47a827426b..4f72e802b6 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -340,7 +340,7 @@ Live sideband admission and its bounded upstream handshake follow the [runtime c ## Provider-scoped approval reviewer -`src/codex/catalog/sync.ts` resolves exact case-preserving provider/model reviewer selectors against the final catalog in both retained sync and `src/codex/convergence.ts`. Valid per-model selection wins over valid provider-wide selection, then the root selector supplies fallback. Native root stamps retain the observed original value and applied selector bound to their slug; removal restores the original only while the applied value is unchanged. The native provenance remains after restoration so an equal provider reviewer cannot trigger legacy reclassification on the next sync. Ambiguous legacy unmarked catalogs retain their existing heuristic cleanup. Provider stamps do not change routing or credentials. +`src/codex/catalog/auto-review.ts` resolves exact case-preserving provider/model reviewer selectors against the final catalog in both retained sync and `src/codex/convergence.ts`. Valid per-model selection wins over valid provider-wide selection, then the root selector supplies fallback. Native root stamps retain the observed original value and applied selector bound to their slug; removal restores the original only while the applied value is unchanged. The native provenance remains after restoration so an equal provider reviewer cannot trigger legacy reclassification on the next sync. Ambiguous legacy unmarked catalogs retain their existing heuristic cleanup. Provider stamps do not change routing or credentials. The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 0496735405..fbbe949174 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -499,7 +499,7 @@ untouched. ## Z.ai quota destination ownership -`src/providers/quota.ts` uses one exact normalized-base mapping for both Z.ai quota +`src/providers/quota/vendor-probes-key.ts` uses one exact normalized-base mapping for both Z.ai quota eligibility and monitor selection. International root, coding Chat, Anthropic and Responses bases use `api.z.ai` with Bearer authentication. Existing BigModel CN root, coding Chat and Responses bases use `open.bigmodel.cn` with the raw key. Unsupported diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index a1824746dc..7a36c2414b 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -448,7 +448,7 @@ Listener startup diagnostics follow [the runtime lifecycle contract](../runtime. ## Automatic pool plan exclusions -`src/codex/routing.ts` applies optional `codexPool.excludedPlans` to both candidate selection and existing active/affined accounts. An all-excluded pool returns no automatic candidate, including preview and configured-account fallback. Native main remains exempt and unknown plans remain eligible. Explicit account-qualified routes retain pause, credential and entitlement checks while bypassing only this automatic policy. +`src/codex/routing/selection.ts` applies optional `codexPool.excludedPlans` to both candidate selection and existing active/affined accounts. An all-excluded pool returns no automatic candidate, including preview and configured-account fallback. Native main remains exempt and unknown plans remain eligible. Explicit account-qualified routes retain pause, credential and entitlement checks while bypassing only this automatic policy. `src/codex/auth-api.ts` projects `selectionExcludedReason: "plan_excluded"` and `selectionExcludedPlan` from the routing config, even when a newer display-only WHAM plan could not be persisted. The dashboard and account CLI show the policy reason separately from credential health; renewal clears the derived fields. The automatic next-session action and badge are omitted for excluded rows. ## Paginated history writer boundary @@ -515,7 +515,7 @@ The history read API reports a median effective token estimate and interval samp ## Reset-first account ordering -`src/codex/routing.ts` supports Codex-only `accountPoolStrategy: "reset-first"`. For new shared-quota assignments it chooses the earliest future short/weekly reset after existing eligibility, priority and usage-threshold filtering; ties and absent/elapsed deadlines use the existing usage order. Seconds and milliseconds are normalized with `resetAtToMs`. Threshold zero disables usage filtering while retaining reset ordering. Monthly deadlines do not order this strategy. +`src/codex/routing/selection.ts` supports Codex-only `accountPoolStrategy: "reset-first"`. For new shared-quota assignments it chooses the earliest future short/weekly reset after existing eligibility, priority and usage-threshold filtering; ties and absent/elapsed deadlines use the existing usage order. Seconds and milliseconds are normalized with `resetAtToMs`. Threshold zero disables usage filtering while retaining reset ordering. Monthly deadlines do not order this strategy. Live bindings obey the cache-affinity release policy: `pool.cacheAffinity` is on by default, so threshold crossing alone retains a healthy account. A bound thread that does leave may move only onto an account with genuine quota headroom and strictly lower usage. Manual preference, scoped health and shared-cursor guards remain authoritative. Set the flag false to restore threshold rebinding of bound tasks. Independent `spark`/`reserve` quota scopes resolve reset-first to existing quota selection because shared reset timestamps do not describe those windows. The configured value stays unchanged. diff --git a/structure/runtime.md b/structure/runtime.md index e4a50ba3f8..49a2fc3f2f 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -339,7 +339,7 @@ Automatic Codex pool selection and account status share the [plan exclusion cont `src/web-search/loop.ts` makes at most one extra answer attempt after a clean forced-answer terminal with no visible output or tool call. The recovery has no tools and reuses gathered search results. Malformed calls fail before refusal/truncation passthrough, and well-formed recognized refusal/truncation terminals pass through unchanged, including empty or partial answers. The extra generation may incur provider usage. ## Scoped provider quota for Combo selection -`src/providers/quota.ts` publishes routing evidence only when a producer explicitly supplies its +`src/providers/quota/report-cache.ts` publishes routing evidence only when a producer explicitly supplies its inference-wide projection. A matching credential alone does not grant veto authority. Display-only account, model-group, search and legacy MCP windows remain visible but cannot exclude a provider. The private WeakMap binds provider name, adapter, destination and captured credential; neither diff --git a/structure/subagents.md b/structure/subagents.md index 69047b076b..b88dd48369 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -68,7 +68,7 @@ v2. An explicit attempt to enable the global flag while the hybrid pin is active ### What the five-model `spawn_agent` window is, and how V1 differs from V2 -`MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5` (mirrored in `src/codex/catalog/sync.ts`) is **not** a +`MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5` (mirrored in `src/codex/catalog/subagent-roster.ts`) is **not** a subagent concurrency limit and **not** an eligibility limit. Upstream uses it in exactly two places: the model list rendered into the `spawn_agent` tool description (`multi_agents_spec.rs:789`) and the "Available models:" suggestions in an unknown-model error diff --git a/tests/codex-integration/codex-history-reachability.test.ts b/tests/codex-integration/codex-history-reachability.test.ts index 553049da4d..156709aefb 100644 --- a/tests/codex-integration/codex-history-reachability.test.ts +++ b/tests/codex-integration/codex-history-reachability.test.ts @@ -34,7 +34,7 @@ const PERMITTED_ROOTS = new Set(["codex/history-worker.ts"]); */ const INLINE_ALLOWED = new Set([ "codex/history-provider.ts", - "codex/inject.ts", + "codex/inject/restore.ts", "codex/internal/history-writer.ts", ]); diff --git a/tests/codex-integration/codex-inject-history-wording.test.ts b/tests/codex-integration/codex-inject-history-wording.test.ts index 9a681f120f..1c7584e6cc 100644 --- a/tests/codex-integration/codex-inject-history-wording.test.ts +++ b/tests/codex-integration/codex-inject-history-wording.test.ts @@ -8,7 +8,8 @@ import { } from "../../src/codex/inject"; import { repoPath } from "../helpers/repo-root"; -const injectSource = readFileSync(repoPath("src/codex/inject.ts"), "utf8"); +const injectSource = readFileSync(repoPath("src/codex/inject.ts"), "utf8") + + readFileSync(repoPath("src/codex/inject/restore.ts"), "utf8"); const doctorSource = readFileSync(repoPath("src/cli/doctor.ts"), "utf8"); const cliSource = readFileSync(repoPath("src/cli/index.ts"), "utf8"); const integrationGuide = readFileSync( diff --git a/tests/codex-integration/codex-retained-root-serialization.test.ts b/tests/codex-integration/codex-retained-root-serialization.test.ts index f97877ed90..ee03e51f4d 100644 --- a/tests/codex-integration/codex-retained-root-serialization.test.ts +++ b/tests/codex-integration/codex-retained-root-serialization.test.ts @@ -320,7 +320,7 @@ test("native restore cannot read-transform-write the catalog while another proce `); expect(restored.exitCode).toBe(0); expect(readFileSync(catalogPath, "utf8")).toBe(before); - const source = readFileSync(join(repoRoot, "src/codex/inject.ts"), "utf8"); + const source = readFileSync(join(repoRoot, "src/codex/inject/restore.ts"), "utf8"); const restoreRoot = source.slice(source.indexOf("const owningCodexHome"), source.indexOf("// Design B", source.indexOf("const owningCodexHome"))); expect(restoreRoot).toContain("withCatalogWriteSerialization(owningCodexHome"); expect(restoreRoot).toContain("restoreCodexCatalogWithPermit"); diff --git a/tests/config/config-save-boundary.test.ts b/tests/config/config-save-boundary.test.ts index 36dac68303..5b51e8182c 100644 --- a/tests/config/config-save-boundary.test.ts +++ b/tests/config/config-save-boundary.test.ts @@ -20,6 +20,7 @@ const GUARDED_FILES = [ "providers/api-keys.ts", // request-path + management key pool "providers/key-failover.ts", // 429 rotation, reached mid-turn with no user action "codex/routing.ts", // account auto-switch during a turn + "codex/routing/active-account.ts", // setActiveCodexAccount moved here in the routing split "codex/auth-api.ts", // runtime account/quota persistence "cli/claude-desktop.ts", // CLI against a running service "server/management-api.ts", diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index b388889a94..0c9c8b210c 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -119,8 +119,8 @@ afterEach(() => { describe("fetchProviderQuotaReports", () => { test("provider quota probes have no direct Response.json calls", () => { - const source = readFileSync(repoPath("src/providers/quota.ts"), "utf8"); - expect(source).not.toMatch(/\.\s*json\s*\(/); + // Probes live in leaves now; the facade alone no longer holds one. + for (const p of ["quota.ts", "quota/vendor-probes-key.ts", "quota/vendor-probes-oauth.ts", "quota/antigravity.ts"]) expect(readFileSync(repoPath(`src/providers/${p}`), "utf8")).not.toMatch(/\.\s*json\s*\(/); }); test("quota JSON reading cancels a body that stalls before its first byte", async () => { diff --git a/tests/usage/quota-reset-detector.test.ts b/tests/usage/quota-reset-detector.test.ts index 3ce61d7b7b..f369db1a26 100644 --- a/tests/usage/quota-reset-detector.test.ts +++ b/tests/usage/quota-reset-detector.test.ts @@ -117,7 +117,7 @@ describe("quota reset detection", () => { }); test("sentinel reset clocks are ignored rather than read as 1970", () => { - // src/providers/quota.ts:279 and src/codex/quota.ts:192 disagree on whether 0 survives, + // src/providers/quota/account-cache.ts and src/codex/quota.ts disagree on whether 0 survives, // so the detector re-checks: a 0 deadline must not read as a long-passed one. expect(detect({ percent: 90, resetAt: 0 }, { percent: 88, resetAt: 0 })).toBeNull(); });