From a3a39426201c21b9274ef26d1513e73f76366d39 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 30 Sep 2026 11:50:51 +0900 Subject: [PATCH 1/3] fix(catalog): discover unpinned native models and refresh the catalog by default GPT-6.1 Sol never reached an install without a release. Four gaps stacked: - catalogAutoRefresh was opt-in, so an absent section left the scheduler dormant. - The authenticated Codex /models roster was read only for entitlement; full rows for models this build does not pin were discarded, so discovery could not add them. - That roster is asked under the installed client version, and upstream's rollout gate served gpt-6.1-sol only from client_version 0.159.0 (its row says 0.153.0) while the installed Codex was 0.158.0-alpha. - The periodic converge read an observe-only bundled memo that expires after 60s, so a Codex binary upgrade's newly bundled rows never reached it. Now an absent section refreshes hourly with a first pass three minutes after start. Each tick re-reads the bundled runtime catalog, warms the entitlement roster, and runs a discovery-only roster request as a newer client (with If-None-Match) that feeds a bounded discovered-native store without touching the entitlement cache. Discovered rows register as self-described natives with their own name, reasoning ladder and context, persist for 14 days since last seen, and go inert once a release pins them. A changed served set records reloadRequired and logs a restart hint when Codex app-servers are running, since they keep a static in-memory list. --- .../docs/reference/configuration/server.md | 31 ++ scripts/test-layout/layout.json | 1 + src/codex/catalog-auto-refresh-sources.ts | 65 ++++ src/codex/catalog-auto-refresh.ts | 40 ++- src/codex/catalog-refresh-status.ts | 4 + src/codex/catalog/discovered-natives.ts | 144 +++++++++ src/codex/catalog/metadata.ts | 31 +- src/codex/catalog/native-models.ts | 65 ++-- src/codex/model-entitlements.ts | 107 ++++++- src/config/derived-registries.ts | 6 +- src/config/feature-flags.ts | 10 +- src/config/schema/leaf-validators.ts | 1 + src/server/background-lifecycle.ts | 4 +- src/types/config.ts | 21 +- structure/catalog.md | 24 +- structure/config.md | 2 +- .../catalog-auto-refresh-scheduler.test.ts | 162 +++++++++- .../codex-catalog-refresh-status.test.ts | 9 + .../discovered-native-models.test.ts | 283 ++++++++++++++++++ .../config-catalog-auto-refresh.test.ts | 11 +- tests/fixtures/test-layout-expected.json | 1 + 21 files changed, 949 insertions(+), 73 deletions(-) create mode 100644 src/codex/catalog-auto-refresh-sources.ts create mode 100644 src/codex/catalog/discovered-natives.ts create mode 100644 tests/codex-integration/discovered-native-models.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 8bbc6894c18..48bef6333aa 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -424,6 +424,37 @@ never lost; the next append tries again. Trimming also refreshes what the dashbo There is no dashboard control for this yet; set it in `config.json` or with `ocx config set usageLedgerMaxBytes `. +## Catalog auto-refresh (`catalogAutoRefresh`) + +Enabled by default, even when the section or `enabled` is absent. The proxy refreshes its +model catalog every 60 minutes, with one initial refresh about three minutes after startup. + +```json +{ + "catalogAutoRefresh": { "enabled": true, "intervalMinutes": 60 } +} +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `enabled` | `true` | Set to `false` to disable automatic refresh. | +| `intervalMinutes` | `60` | Refresh cadence; positive values below 15 are clamped to 15. `0` disables refresh. | + +Each refresh observes the selected Codex runtime's bundled catalog, then warms authenticated +Codex model rosters before converging the served list. Source failures use existing evidence +and retry on a later tick. Cadence and enablement edits take effect on a subsequent tick. + +A native OpenAI model that your ChatGPT account's Codex roster lists, but that this OpenCodex +release does not know yet, is added with the metadata upstream publishes for it (name, +reasoning levels, context window). OpenCodex remembers such models in +`discovered-native-models.json` in its home directory and forgets one that has not been seen +for 14 days. A later release that ships the model takes over its row. + +Running Codex sessions retain an in-memory model list. When the served set changes while +Codex app-servers are running, the proxy logs a restart hint and records `reloadRequired` +in its auto-refresh status. Run `ocx sync --restart-codex` when ready to restart those sessions. +Automatic refresh never restarts them. + ## Quota-reset notifications (`quotaResetNotify`) Off by default. When the section is absent, no detection runs, no timer starts, and no state diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 11bbc26b507..ee362095e86 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -5,6 +5,7 @@ "service-desktop-startup-health.test.ts": "service", "service-desktop-startup.test.ts": "service", "startup-health-packaged-probe.test.ts": "server", + "discovered-native-models.test.ts": "codex-integration", "cursor-local-installer.test.ts": "providers/cursor", "codex-quota-query-backoff.test.ts": "codex-integration", "pnpm-command-isolation.test.ts": "update", "provider-antigravity-quota-retry.test.ts": "providers", "project-config-warning-snapshot.test.ts": "codex-integration", "codex-quota-auto-refresh-generation.test.ts": "codex-integration", "codex-account-clear-paused.test.ts": "codex-integration", diff --git a/src/codex/catalog-auto-refresh-sources.ts b/src/codex/catalog-auto-refresh-sources.ts new file mode 100644 index 00000000000..e45e3a66d2f --- /dev/null +++ b/src/codex/catalog-auto-refresh-sources.ts @@ -0,0 +1,65 @@ +import type { OcxConfig } from "../types"; + +const SOURCE_WAIT_MS = 15_000; +const PROCESS_WAIT_MS = 1_000; + +/** A source failure or slow roster must not prevent publication from existing evidence. */ +async function bestEffortSource(step: (active: () => boolean) => Promise): Promise { + let timer: ReturnType | undefined; + let active = true; + try { + await Promise.race([ + Promise.resolve().then(() => step(() => active)), + new Promise(resolve => { + timer = setTimeout(resolve, SOURCE_WAIT_MS); + timer.unref?.(); + }), + ]); + } catch { + // Convergence can still use the last confirmed source snapshots. + } finally { + active = false; + clearTimeout(timer); + } +} + +/** Settle source observations before admission captures them; gather itself never probes. */ +export async function refreshCatalogAutoRefreshSources( + config: OcxConfig, + current: () => boolean, +): Promise { + // Runtime selection also supplies the roster's trusted client version after an upgrade. + await bestEffortSource(async active => { + const { loadBundledCodexCatalog } = await import("./catalog/bundled"); + if (active() && current()) loadBundledCodexCatalog(); + // The synchronous loader bounds its selected-runtime subprocess probes itself. + }); + if (!current()) return; + await bestEffortSource(async active => { + const { ensureCodexEntitlementFreshness } = await import("./model-entitlements"); + if (active() && current()) await ensureCodexEntitlementFreshness(config, { waitMs: SOURCE_WAIT_MS }); + }); + if (!current()) return; + // The entitlement roster above is asked under the installed client version, which upstream's + // rollout gate can hide a new model from (GPT-6.1 Sol was invisible to 0.158 on release day). + // Discovery asks as a newer client so an unpinned native reaches the converge below. + await bestEffortSource(async active => { + const { discoverCodexNativeRoster } = await import("./model-entitlements"); + if (active() && current()) await discoverCodexNativeRoster(config); + }); +} + +/** Observation only: running sessions own static copies and require an operator restart. */ +export async function catalogAutoRefreshReloadRequired(changed = true): Promise { + try { + const { listCodexAppServerProcesses, collectCodexAppServerCatalogStateWithin } = await import("./app-server-processes"); + // A cold Windows start-time walk can miss a short deadline. The changed-set hint + // needs only the bounded process listing, so it does not depend on that walk. + if (changed) return listCodexAppServerProcesses().length > 0; + const status = await collectCodexAppServerCatalogStateWithin(PROCESS_WAIT_MS); + return status.state === "stale" || status.state === "unknown"; + } catch { + // Failure to observe a restart cannot clear a previously recorded requirement. + return !changed; + } +} diff --git a/src/codex/catalog-auto-refresh.ts b/src/codex/catalog-auto-refresh.ts index 8aa1a6d28f5..b2c9fe4cb85 100644 --- a/src/codex/catalog-auto-refresh.ts +++ b/src/codex/catalog-auto-refresh.ts @@ -1,5 +1,5 @@ /** - * Opt-in periodic catalog refresh so newly released models appear without a + * Default-on periodic catalog refresh so newly released models appear without a * manual `ocx sync` (issue #3630). * * This is load-bearing, not a convenience. The served model set is otherwise @@ -24,6 +24,7 @@ */ const DEFAULT_INTERVAL_MS = 60 * 60_000; const MIN_INTERVAL_MS = 15 * 60_000; +const INITIAL_DELAY_MS = 3 * 60_000; /** * Commit-lock wait only. Gather already has per-provider timeouts, and automatic * callers fail fast and defer (ConvergeRequest.mode) rather than holding the @@ -37,6 +38,7 @@ const MAX_JOURNAL_BYTES = 1024 * 1024; const MAX_CATALOG_BYTES = 64 * 1024 * 1024; let timer: ReturnType | null = null; +let initialTimer: ReturnType | null = null; let detachShutdownHook: (() => void) | null = null; /** The bounded cadence the live timer was created with, so a tick can notice config drift. */ let liveIntervalMs: number | null = null; @@ -170,8 +172,10 @@ function boundedInterval(value: number): number { /** Re-arm the timer when the operator changed the cadence since it was created. */ function restartIfCadenceChanged(configured: number): void { if (timer === null || boundedInterval(configured) === liveIntervalMs) return; - stopCatalogAutoRefresh(); - startCatalogAutoRefresh(configured); + clearInterval(timer); + liveIntervalMs = boundedInterval(configured); + timer = setInterval(() => void tick(), liveIntervalMs); + timer.unref?.(); } async function tick(): Promise { @@ -218,6 +222,11 @@ async function tick(): Promise { } else if (heal === "not-healed") { console.info("[catalog-auto-refresh] injected Codex config keys were rewritten externally; not re-injected this tick"); } + const { refreshCatalogAutoRefreshSources, catalogAutoRefreshReloadRequired } = + await import("./catalog-auto-refresh-sources"); + if (entryGeneration !== generation) return; + await refreshCatalogAutoRefreshSources(config, () => entryGeneration === generation); + if (entryGeneration !== generation) return; const [{ createManagementConvergeCodex }, { createCatalogConvergeRequest }] = await Promise.all([ import("./management-convergence"), import("./catalog-admission"), @@ -231,11 +240,16 @@ async function tick(): Promise { // createManagementConvergeCodex always projects catalog-only. Any other kind is a // funnel contract break, not something this scheduler should re-classify. if (outcome.kind !== "catalog-only") return; - const { recordCatalogAutoRefreshOutcome } = await import("./catalog-refresh-status"); - recordCatalogAutoRefreshOutcome(outcome.catalogRefresh, outcome.changed); + const { recordCatalogAutoRefreshOutcome, lastCatalogAutoRefreshOutcome } = await import("./catalog-refresh-status"); + const reloadRequired = (outcome.changed || lastCatalogAutoRefreshOutcome()?.reloadRequired === true) + ? await catalogAutoRefreshReloadRequired(outcome.changed) : false; + if (entryGeneration !== generation) return; + recordCatalogAutoRefreshOutcome(outcome.catalogRefresh, outcome.changed, reloadRequired); if (outcome.changed) { // Privacy scan: no provider names, model ids, paths, or account identifiers. - console.info("[catalog-auto-refresh] served model set changed"); + console.info(reloadRequired + ? "[catalog-auto-refresh] served model set changed; running Codex sessions keep the old list until restarted (ocx sync --restart-codex)" + : "[catalog-auto-refresh] served model set changed"); } } catch { // A failed refresh is not an error worth surfacing: the next tick tries again. @@ -253,8 +267,16 @@ export function startCatalogAutoRefresh(intervalMs = DEFAULT_INTERVAL_MS): void timer = setInterval(() => void tick(), bounded); // Never keep the process alive for a catalog refresh. timer.unref?.(); + const startedGeneration = generation; + initialTimer = setTimeout(() => { + if (startedGeneration !== generation) return; + initialTimer = null; + return tick(); + }, INITIAL_DELAY_MS); + initialTimer.unref?.(); void import("../lib/optional-shutdown-hooks") .then(hooks => { + if (startedGeneration !== generation) return; detachShutdownHook = hooks.registerOptionalShutdownHook( "catalog-auto-refresh", stopCatalogAutoRefresh, @@ -266,6 +288,10 @@ export function startCatalogAutoRefresh(intervalMs = DEFAULT_INTERVAL_MS): void } export function stopCatalogAutoRefresh(): void { + if (initialTimer) { + clearTimeout(initialTimer); + initialTimer = null; + } if (timer) { clearInterval(timer); timer = null; @@ -290,7 +316,9 @@ export function isCatalogAutoRefreshRunning(): boolean { * microtasks before this settles. */ export async function syncCatalogAutoRefreshCadence(): Promise { + const entryGeneration = generation; const { loadConfig, resolveCatalogAutoRefreshIntervalMs } = await import("../config"); + if (entryGeneration !== generation) return; const configured = resolveCatalogAutoRefreshIntervalMs(loadConfig()); // 0 is dormant: tick() already returns before converging, and the timer stays unref'd. if (configured === 0) return; diff --git a/src/codex/catalog-refresh-status.ts b/src/codex/catalog-refresh-status.ts index 719318698ba..fe9b4edeb3e 100644 --- a/src/codex/catalog-refresh-status.ts +++ b/src/codex/catalog-refresh-status.ts @@ -108,6 +108,7 @@ export interface CatalogAutoRefreshOutcome { readonly at: number; readonly disposition: CatalogDisposition; readonly changed: boolean; + readonly reloadRequired: boolean; /** * A refresh that has failed repeatedly is the signal an operator needs, and the * boolean disposition alone cannot express it: skipped and failed look the same @@ -158,6 +159,7 @@ function freezeCatalogAutoRefreshOutcome( at: outcome.at, disposition: freezeCatalogDisposition(outcome.disposition), changed: outcome.changed, + reloadRequired: outcome.reloadRequired, consecutiveFailures: outcome.consecutiveFailures, }); } @@ -171,6 +173,7 @@ function freezeCatalogAutoRefreshOutcome( export function recordCatalogAutoRefreshOutcome( disposition: CatalogDisposition, changed: boolean, + reloadRequired = false, ): CatalogAutoRefreshOutcome | null { const normalized = normalizeCatalogDisposition(disposition); if (normalized === null) return null; @@ -181,6 +184,7 @@ export function recordCatalogAutoRefreshOutcome( at: Date.now(), disposition: normalized, changed: changed === true, + reloadRequired: reloadRequired === true, consecutiveFailures, }); lastAutoRefreshOutcome = outcome; diff --git a/src/codex/catalog/discovered-natives.ts b/src/codex/catalog/discovered-natives.ts new file mode 100644 index 00000000000..e2850c5c858 --- /dev/null +++ b/src/codex/catalog/discovered-natives.ts @@ -0,0 +1,144 @@ +import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWriteFile } from "../../config/atomic-write"; +import { getConfigDir } from "../../config/paths"; +import { assertNotRealHomeUnderTest } from "../../lib/test-home-guard"; +import { isEligibleConfiguredNativeOpenAiModel, setDiscoveredNativeOpenAiModels } from "./native-models"; + +export const DISCOVERED_NATIVE_MAX_ROWS = 32; +/** + * A real row carries its full base instructions twice (`base_instructions` and the + * `model_messages` template): GPT-6.1 Sol's live row was 87,183 bytes on 2026-09-30, so a + * 64 KiB bound silently rejected exactly the model this store exists to discover. + */ +export const DISCOVERED_NATIVE_MAX_ROW_BYTES = 256 * 1024; +export const DISCOVERED_NATIVE_MAX_FILE_BYTES = DISCOVERED_NATIVE_MAX_ROWS * (DISCOVERED_NATIVE_MAX_ROW_BYTES + 1024); +export const DISCOVERED_NATIVE_RETENTION_MS = 14 * 24 * 60 * 60 * 1000; +const FILE_NAME = "discovered-native-models.json"; + +export interface DiscoveredNativeModel { + slug: string; + row: Record; + firstSeenAt: number; + lastSeenAt: number; + clientVersion: string; +} + +let loadedPath: string | undefined; +let models: DiscoveredNativeModel[] = []; +let fingerprint = "[]"; +let generation = 0; + +/** Validate untrusted roster rows without filesystem effects; keep upstream capability metadata. */ +export function validateDiscoveredNativeRows(rows: unknown): Record[] { + if (!Array.isArray(rows)) return []; + const accepted = new Map>(); + for (const candidate of rows) { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue; + const row = candidate as Record; + if (typeof row.slug !== "string" || row.slug.length > 128 + || !isEligibleConfiguredNativeOpenAiModel(row.slug) + || row.supported_in_api !== true || row.visibility === "hide" + || typeof row.display_name !== "string" || !row.display_name.trim() + || !Array.isArray(row.supported_reasoning_levels) + || row.supported_reasoning_levels.length > 16 + || !row.supported_reasoning_levels.every(level => level && typeof level === "object" + && !Array.isArray(level) && typeof level.effort === "string" && level.effort.length > 0 + && level.effort.length <= 32 && typeof level.description === "string")) continue; + if ([row.context_window, row.max_context_window].some(value => value !== undefined && value !== null + && (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0))) continue; + try { + const serialized = JSON.stringify(row); + if (Buffer.byteLength(serialized, "utf8") > DISCOVERED_NATIVE_MAX_ROW_BYTES) continue; + if (accepted.size >= DISCOVERED_NATIVE_MAX_ROWS && !accepted.has(row.slug)) continue; + accepted.set(row.slug, JSON.parse(serialized) as Record); + } catch { /* Cyclic/non-JSON rows cannot be persisted or sent to Codex. */ } + } + return [...accepted.values()]; +} + +function boundedModels(entries: DiscoveredNativeModel[], now: number): DiscoveredNativeModel[] { + return entries.filter(entry => entry.lastSeenAt >= now - DISCOVERED_NATIVE_RETENTION_MS) + .sort((a, b) => b.lastSeenAt - a.lastSeenAt || a.slug.localeCompare(b.slug)) + .slice(0, DISCOVERED_NATIVE_MAX_ROWS); +} + +function readModels(path: string, now: number): DiscoveredNativeModel[] { + try { + if (statSync(path).size > DISCOVERED_NATIVE_MAX_FILE_BYTES) return []; + const bytes = readFileSync(path, "utf8"); + if (Buffer.byteLength(bytes, "utf8") > DISCOVERED_NATIVE_MAX_FILE_BYTES) return []; + const data = JSON.parse(bytes) as { version?: unknown; models?: unknown }; + if (data.version !== 1 || !Array.isArray(data.models) || data.models.length > DISCOVERED_NATIVE_MAX_ROWS) return []; + const valid = new Map(); + for (const entry of data.models) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; + const row = validateDiscoveredNativeRows([entry.row])[0]; + if (!row || entry.slug !== row.slug + || !Number.isSafeInteger(entry.firstSeenAt) || entry.firstSeenAt < 0 + || !Number.isSafeInteger(entry.lastSeenAt) || entry.lastSeenAt < entry.firstSeenAt + || entry.lastSeenAt > now + || typeof entry.clientVersion !== "string" || entry.clientVersion.length > 64) continue; + valid.set(entry.slug, { slug: entry.slug, row, firstSeenAt: entry.firstSeenAt, + lastSeenAt: entry.lastSeenAt, clientVersion: entry.clientVersion }); + } + return boundedModels([...valid.values()], now); + } catch { return []; } +} + +function publish(entries: DiscoveredNativeModel[]): void { + models = entries; + // Seen-at timestamps extend retention but do not invalidate an otherwise identical catalog. + const next = JSON.stringify(entries.map(({ slug, row }) => ({ slug, row })).sort((a, b) => a.slug.localeCompare(b.slug))); + if (next === fingerprint) return; + fingerprint = next; + generation += 1; + setDiscoveredNativeOpenAiModels(entries.map(entry => entry.row)); +} + +/** Load on config activation; switching OpenCodex homes never carries the previous home's rows. */ +export function loadDiscoveredNativeModels(now = Date.now()): void { + const path = join(getConfigDir(), FILE_NAME); + loadedPath = path; + publish(readModels(path, now)); +} + +/** Monotonic process-local catalog evidence generation, independent of account identity. */ +export function discoveredNativeModelsGeneration(): number { + return generation; +} + +/** A confirmed nonempty roster owns discovery. Optional persistence must never fail entitlement. */ +export function recordDiscoveredNativeModels(rows: unknown, clientVersion: string, now = Date.now()): void { + try { + if (!Number.isSafeInteger(now) || now < 0 || clientVersion.length > 64) return; + const dir = getConfigDir(); + const path = join(dir, FILE_NAME); + if (loadedPath !== path) loadDiscoveredNativeModels(now); + const merged = new Map(models.map(entry => [entry.slug, entry])); + for (const row of validateDiscoveredNativeRows(rows)) { + const slug = row.slug as string; + const previous = merged.get(slug); + if (previous && previous.lastSeenAt > now) continue; + merged.set(slug, { slug, row, firstSeenAt: previous?.firstSeenAt ?? now, lastSeenAt: now, clientVersion }); + } + const next = boundedModels([...merged.values()], now); + const serialized = JSON.stringify({ version: 1, models: next }); + if (Buffer.byteLength(serialized, "utf8") > DISCOVERED_NATIVE_MAX_FILE_BYTES) return; + const hadModels = models.length > 0; + publish(next); + // Avoid filesystem work for unchanged empty rosters (including existing entitlement fixtures). + if (next.length === 0 && !hadModels && !existsSync(path)) return; + assertNotRealHomeUnderTest(dir); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + atomicWriteFile(path, serialized + "\n"); + } catch { /* Discovery is optional; current in-memory metadata survives a disk write failure. */ } +} + +export function resetDiscoveredNativeModelsForTests(): void { + loadedPath = undefined; + models = []; + fingerprint = "[]"; + generation += 1; + setDiscoveredNativeOpenAiModels([]); +} diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index ab75b7e52bc..da14be4cb6c 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -55,6 +55,8 @@ import { SUPPORTED_NATIVE_OPENAI_SLUGS, RETIRED_NATIVE_OPENAI_MODELS, configuredNativeOpenAiModels, + discoveredNativeOpenAiModels, + discoveredNativeOpenAiRow, hasNativeOpenAiCapabilityMetadata, isConfiguredNativeOpenAiModel, isNativeOpenAiCapabilityAliasModel, @@ -219,7 +221,9 @@ const PINNED_UPSTREAM_MODELS: Map = new Map( ); function pinnedNativeCapabilityEntry(slug: string): RawEntry | undefined { - return PINNED_UPSTREAM_MODELS.get(nativeOpenAiCapabilitySourceSlug(slug)); + const discovered = discoveredNativeOpenAiRow(slug); + return discovered ? discoveredNativeCapabilityEntry(discovered) + : PINNED_UPSTREAM_MODELS.get(nativeOpenAiCapabilitySourceSlug(slug)); } /** @@ -419,7 +423,7 @@ export function nativeReasoningEfforts(slug: string): string[] { const levels = Array.isArray(upstream?.supported_reasoning_levels) ? upstream!.supported_reasoning_levels as Array<{ effort?: string }> : []; - if (levels.length > 0) { + if (levels.length > 0 || discoveredNativeOpenAiRow(slug)) { // Preserve the exact pinned per-model ladder. In particular, GPT-5.6 Sol and Terra // include ultra while Luna intentionally ends at max. return levels.flatMap(l => typeof l.effort === "string" ? [l.effort] : []); @@ -565,6 +569,8 @@ export function applyNativeVisibility( } function upstreamNativeEntryForSlug(slug: string): RawEntry | undefined { + const discovered = discoveredNativeOpenAiRow(slug); + if (discovered) return discoveredNativeCapabilityEntry(discovered); const sourceSlug = nativeOpenAiCapabilitySourceSlug(slug); // A self-described native returns its OWN pinned row; the alias-cloning branch below stays // reserved for slugs that genuinely borrow another model's identity. The allowlist is explicit @@ -605,6 +611,14 @@ function upstreamNativeEntryForSlug(slug: string): RawEntry | undefined { return alias; } +/** Account-specific grants and prompts are projected from entitlement evidence, never a shared row. */ +function discoveredNativeCapabilityEntry(row: RawEntry): RawEntry { + const entry = withDerivedBaseInstructions(structuredClone(row)); + delete entry.available_access_programs; + delete entry.availability_nux; + return entry; +} + /** * Backfill `base_instructions` from `model_messages.instructions_template` when upstream ships * only the latter. @@ -633,7 +647,7 @@ export const UPSTREAM_NATIVE_ENTRIES: Map = new Map( }), ); -// Configured natives join the three per-slug tables in place: other modules hold these exact +// Configured and discovered natives join the per-slug tables in place: modules hold these exact // objects, so a replacement would go unseen. Built-in ids are never configured, so a removal // cannot delete a built-in row. subscribeConfiguredNativeOpenAiModels((current, removed) => { @@ -647,7 +661,11 @@ subscribeConfiguredNativeOpenAiModels((current, removed) => { if (pinned) PINNED_NATIVE_CAPABILITY_ENTRIES.set(slug, pinned); const upstream = upstreamNativeEntryForSlug(slug); if (upstream) UPSTREAM_NATIVE_ENTRIES.set(slug, upstream); - NATIVE_OPENAI_CONTEXT_OVERRIDES[slug] = { ...NATIVE_GPT6_CONTEXT }; + const discovered = discoveredNativeOpenAiRow(slug); + const contextWindow = positiveInt(discovered?.context_window) ?? NATIVE_GPT6_CONTEXT.contextWindow; + const maxContextWindow = Math.max(contextWindow, + positiveInt(discovered?.max_context_window) ?? NATIVE_GPT6_CONTEXT.maxContextWindow); + NATIVE_OPENAI_CONTEXT_OVERRIDES[slug] = { contextWindow, maxContextWindow, maxInputTokens: maxContextWindow }; } }); @@ -692,6 +710,7 @@ const SELF_AUTHORED_NATIVE_ROWS: ReadonlySet = new Set([NATIVE_GPT6_ASTR export function shouldUpgradeToUpstreamEntry(entry: RawEntry): boolean { if (typeof entry.slug !== "string" || !UPSTREAM_NATIVE_ENTRIES.has(entry.slug)) return false; + if (discoveredNativeOpenAiRow(entry.slug)) return true; if (entry.display_name === entry.slug) return true; // A row this project authored from a guess is not evidence of upstream truth, however genuine // its display name looks. Replace it once, from the pin. @@ -703,7 +722,7 @@ export function nativeOpenAiSlugs(): string[] { const live = catalogNativeSlugs(); const availableGated = cachedAvailableAccountGatedNativeModels(); const candidates = live.length > 0 - ? unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS, ...configuredNativeOpenAiModels()]) + ? unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS, ...configuredNativeOpenAiModels(), ...discoveredNativeOpenAiModels()]) : NATIVE_OPENAI_MODELS; return candidates.filter(slug => ( !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableGated.has(slug) @@ -913,5 +932,5 @@ function catalogNativeSlugs(): string[] { export function listCatalogNativeSlugs(): string[] { // Ensure documented additions (e.g. gpt-6-astra) appear even when the bundled catalog // predates the slug — mirrors nativeOpenAiSlugs() which already merges them for /v1/models. - return unique([...catalogNativeSlugs(), ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS, ...configuredNativeOpenAiModels()]); + return unique([...catalogNativeSlugs(), ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS, ...configuredNativeOpenAiModels(), ...discoveredNativeOpenAiModels()]); } diff --git a/src/codex/catalog/native-models.ts b/src/codex/catalog/native-models.ts index a7090a6bee7..395342a228f 100644 --- a/src/codex/catalog/native-models.ts +++ b/src/codex/catalog/native-models.ts @@ -129,7 +129,7 @@ const NATIVE_OPENAI_CAPABILITY_SOURCES: Readonly> = Objec * keeps rows this runtime does not expose, which is exactly why presence in the pin cannot be * the predicate: `gpt-5.4` is still pinned (hidden, with an upgrade to Terra) after its retirement. */ -export const SELF_DESCRIBED_NATIVE_OPENAI_MODELS: ReadonlySet = new Set([ +const selfDescribedNativeModels = new Set([ NATIVE_GPT6_ASTRA_MODEL, // Rows come from roster-pinned-models.json via pinnedNativeModelRows(), not the codex-rs pin. NATIVE_GPT6_SOL_MODEL, @@ -137,6 +137,8 @@ export const SELF_DESCRIBED_NATIVE_OPENAI_MODELS: ReadonlySet = new Set( NATIVE_GPT61_SOL_MODEL, ]); +export const SELF_DESCRIBED_NATIVE_OPENAI_MODELS: ReadonlySet = selfDescribedNativeModels; + /** * Native ids whose capability metadata is inherited from another pinned native row. * @@ -155,7 +157,7 @@ export const NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS = Object.freeze( ); export function isNativeOpenAiCapabilityAliasModel(slug: string): boolean { - return Object.hasOwn(NATIVE_OPENAI_CAPABILITY_SOURCES, slug) || configuredNativeSlugs.has(slug); + return Object.hasOwn(NATIVE_OPENAI_CAPABILITY_SOURCES, slug) || (configuredNativeSlugs.has(slug) && !discoveredNativeRows.has(slug)); } /** @@ -175,7 +177,7 @@ export function hasNativeOpenAiCapabilityMetadata(slug: string): boolean { export function nativeOpenAiCapabilitySourceSlug(slug: string): string { return NATIVE_OPENAI_CAPABILITY_SOURCES[slug] - ?? (configuredNativeSlugs.has(slug) ? CONFIGURED_NATIVE_OPENAI_TEMPLATE_MODEL : slug); + ?? (configuredNativeSlugs.has(slug) && !discoveredNativeRows.has(slug) ? CONFIGURED_NATIVE_OPENAI_TEMPLATE_MODEL : slug); } /** @@ -196,7 +198,7 @@ export const NATIVE_OPENAI_ALIAS_PRESENTATION: Readonly `GPT-6-Nova`, the same casing upstream uses for its own GPT-6 rows. */ @@ -240,8 +242,8 @@ const BUILT_IN_NATIVE_OPENAI_MODELS: readonly string[] = Object.freeze([ ]); /** - * The built-in list plus every configured native, appended in config order. The array and the Set - * below are shared by reference across the catalog, `/v1/models` and the dashboard, so + * The built-in list plus configured and discovered natives in registration order. The array and + * Set below are shared by reference across the catalog, `/v1/models` and the dashboard, so * registration edits them in place rather than replacing them. */ export const NATIVE_OPENAI_MODELS: string[] = [...BUILT_IN_NATIVE_OPENAI_MODELS]; @@ -260,6 +262,7 @@ export const SUPPORTED_NATIVE_OPENAI_SLUGS = new Set(NATIVE_OPENAI_MODELS); * persist/reconcile path, so any process that loads config — `ocx ensure` included — sees it. */ const configuredNativeSlugs = new Set(); +const discoveredNativeRows = new Map>(); type ConfiguredNativeListener = (current: readonly string[], removed: readonly string[]) => void; const configuredNativeListeners: ConfiguredNativeListener[] = []; @@ -286,25 +289,49 @@ export function setConfiguredNativeOpenAiModels(ids: readonly string[]): void { const next = [...new Set(ids.filter(isEligibleConfiguredNativeOpenAiModel))]; const previous = [...configuredNativeSlugs]; if (next.length === previous.length && next.every((id, index) => id === previous[index])) return; - const removed = previous.filter(id => !next.includes(id)); - for (const id of previous) { - configuredNativeSlugs.delete(id); - SUPPORTED_NATIVE_OPENAI_SLUGS.delete(id); - const index = NATIVE_OPENAI_MODELS.indexOf(id); - if (index >= 0) NATIVE_OPENAI_MODELS.splice(index, 1); - } - for (const id of next) { - configuredNativeSlugs.add(id); - SUPPORTED_NATIVE_OPENAI_SLUGS.add(id); - NATIVE_OPENAI_MODELS.push(id); + configuredNativeSlugs.clear(); + for (const id of next) configuredNativeSlugs.add(id); + refreshDynamicNativeOpenAiModels(); +} + +export function discoveredNativeOpenAiModels(): readonly string[] { + return [...discoveredNativeRows.keys()]; +} + +export function discoveredNativeOpenAiRow(slug: string): Record | undefined { + const row = discoveredNativeRows.get(slug); + return row ? structuredClone(row) : undefined; +} + +/** Validated roster rows stay self-described; a later built-in registration wins automatically. */ +export function setDiscoveredNativeOpenAiModels(rows: readonly Record[]): void { + for (const slug of discoveredNativeRows.keys()) selfDescribedNativeModels.delete(slug); + discoveredNativeRows.clear(); + for (const row of rows) { + if (typeof row.slug !== "string" || !isEligibleConfiguredNativeOpenAiModel(row.slug)) continue; + discoveredNativeRows.set(row.slug, structuredClone(row)); + selfDescribedNativeModels.add(row.slug); } + refreshDynamicNativeOpenAiModels(); +} + +function dynamicNativeOpenAiModels(): string[] { + return [...new Set([...configuredNativeSlugs, ...discoveredNativeRows.keys()])]; +} + +function refreshDynamicNativeOpenAiModels(): void { + const next = dynamicNativeOpenAiModels(); + const removed = NATIVE_OPENAI_MODELS.filter(id => !BUILT_IN_NATIVE_OPENAI_MODELS.includes(id) && !next.includes(id)); + NATIVE_OPENAI_MODELS.splice(0, NATIVE_OPENAI_MODELS.length, ...BUILT_IN_NATIVE_OPENAI_MODELS, ...next); + SUPPORTED_NATIVE_OPENAI_SLUGS.clear(); + for (const id of NATIVE_OPENAI_MODELS) SUPPORTED_NATIVE_OPENAI_SLUGS.add(id); for (const listener of configuredNativeListeners) listener(next, removed); } -/** Keep derived tables in step; the listener runs immediately with the current set. */ +/** Keep derived tables in step with the configured/discovered union, including metadata updates. */ export function subscribeConfiguredNativeOpenAiModels(listener: ConfiguredNativeListener): void { configuredNativeListeners.push(listener); - listener(configuredNativeOpenAiModels(), []); + listener(dynamicNativeOpenAiModels(), []); } export function resetConfiguredNativeOpenAiModelsForTests(): void { diff --git a/src/codex/model-entitlements.ts b/src/codex/model-entitlements.ts index d9d7b5d3622..2d294e0f044 100644 --- a/src/codex/model-entitlements.ts +++ b/src/codex/model-entitlements.ts @@ -20,6 +20,7 @@ import { } from "./catalog/native-models"; import { loadPersistedCodexRuntime } from "./runtime"; import { codexRuntimeStateEpoch } from "./runtime"; +import { recordDiscoveredNativeModels, validateDiscoveredNativeRows } from "./catalog/discovered-natives"; import { pinnedNativeModelRows } from "./catalog/pinned-models"; import { codexCredentialMutationEpoch } from "./credential-mutation-epoch"; import { @@ -650,6 +651,7 @@ function boundedAvailabilityMessage(message: string): string { /** Keep valid roster slugs while dropping malformed program and availability metadata. */ function parseAccountModels(text: string): { + discoveredRows: Record[]; models: ReadonlySet; accessProgramsByModel: ReadonlyMap; availabilityNuxByModel: ReadonlyMap; @@ -681,7 +683,8 @@ function parseAccountModels(text: string): { } return [row.slug]; }); - return { models: new Set(models), accessProgramsByModel, availabilityNuxByModel }; + return { models: new Set(models), accessProgramsByModel, availabilityNuxByModel, + discoveredRows: validateDiscoveredNativeRows(payload.models) }; } catch { return null; } @@ -757,6 +760,7 @@ async function fetchAccountModels( if (!usable) { return unconfirmedAccountModels(credential, clientVersion, now, { kind: "parsed-empty" }); } + recordDiscoveredNativeModels(parsed.discoveredRows, clientVersion, now); const hasUnknownGatedAbsence = [...ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS] // Reachable only through tier 1, an inbound client_version below the floor. Every other // resolution is now structurally >= every recorded minimum, because the floor is the max @@ -789,6 +793,107 @@ async function fetchAccountModels( } } +/** + * Client version the discovery-only roster request claims. + * + * Upstream filters `/models` by client version AND by a rollout gate the row's own + * `minimal_client_version` does not describe: on 2026-09-30 `gpt-6.1-sol` carried + * `minimal_client_version: "0.153.0"` yet was served only to `client_version >= 0.159.0`, while the + * installed Codex was 0.158.0-alpha. Asking under the installed version (what the entitlement path + * must do, #2886) therefore could not see the model at all. Discovery asks as a client newer than + * any release so it sees the whole roster; this proxy already serves pinned rows to older + * clients, so the version only widens what we learn, never what an account may call. + */ +export const CODEX_ROSTER_DISCOVERY_CLIENT_VERSION = "99.0.0"; + +export type CodexNativeRosterDiscoveryOutcome = "recorded" | "not-modified" | "unavailable"; + +/** Last ETag per credential identity, so an unchanged roster costs a 304 when upstream honours it. */ +const discoveryEtags = new Map(); + +/** + * Background discovery of native rows this build does not pin, independent of entitlement. + * + * It never writes the entitlement cache: a roster fetched under the discovery version answers a + * different question than "may this client call this model", and letting it satisfy entitlement + * reads would reintroduce the cross-version leak #2548/#2886 closed. The first account that + * answers is enough to learn a row; whether a given account may call it stays with the + * entitlement path and, for ungated rows, with the upstream status the request receives. + */ +export async function discoverCodexNativeRoster( + config: Pick, + options: Pick = {}, +): Promise { + const fetcher = options.fetcher ?? fetch; + const run = async (excluded: ReadonlySet, releaseMainLease?: () => void) => { + const credentials: CodexModelEntitlementCredentialSnapshot[] = [...(options.credentials ?? [])]; + for (const accountId of options.credentials ? [] : normalizedCandidateAccountIds(config)) { + if (excluded.has(accountId)) continue; + const credential = await accountCredentialSnapshot(accountId, { + nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, + signal: options.signal, + }).catch(() => null); + if (credential) credentials.push(credential); + } + releaseMainLease?.(); + for (const credential of credentials) { + const outcome = await fetchDiscoveryRoster(credential, fetcher, options).catch(() => "unavailable" as const); + if (outcome !== "unavailable") return outcome; + } + return "unavailable" as const; + }; + try { + return await withNativeMainCredentialAdmission(run); + } catch { + return "unavailable"; + } +} + +async function fetchDiscoveryRoster( + credential: CodexModelEntitlementCredentialSnapshot, + fetcher: typeof fetch, + options: Pick, +): Promise { + const controller = new AbortController(); + const abort = () => controller.abort(options.signal?.reason); + options.signal?.addEventListener("abort", abort, { once: true }); + const timer = setTimeout(() => controller.abort(new DOMException("Codex model discovery timed out", "TimeoutError")), MODEL_ROSTER_TIMEOUT_MS); + try { + const headers = new Headers({ Authorization: `Bearer ${credential.accessToken}`, Accept: "application/json" }); + if (credential.chatgptAccountId) headers.set("ChatGPT-Account-Id", credential.chatgptAccountId); + const etag = discoveryEtags.get(credential.credentialIdentity); + if (etag) headers.set("If-None-Match", etag); + const response = await fetcher(codexModelsUrl(CODEX_ROSTER_DISCOVERY_CLIENT_VERSION), { + headers, + redirect: "error", + signal: controller.signal, + }); + if (response.status === 304) return "not-modified"; + if (!response.ok) return "unavailable"; + const body = await readBoundedResponseBody(response, { + signal: controller.signal, + maxBytes: MODEL_ROSTER_MAX_BYTES, + fatalUtf8: true, + }); + if (!body.displaySafe || body.truncated) return "unavailable"; + const parsed = parseAccountModels(body.text); + if (parsed === null || parsed.models.size === 0) return "unavailable"; + recordDiscoveredNativeModels(parsed.discoveredRows, CODEX_ROSTER_DISCOVERY_CLIENT_VERSION, options.now ?? Date.now()); + const nextEtag = response.headers.get("etag"); + if (nextEtag && nextEtag.length <= 256) discoveryEtags.set(credential.credentialIdentity, nextEtag); + else discoveryEtags.delete(credential.credentialIdentity); + if (discoveryEtags.size > 64) discoveryEtags.delete(discoveryEtags.keys().next().value!); + return "recorded"; + } finally { + clearTimeout(timer); + options.signal?.removeEventListener("abort", abort); + } +} + +export function resetCodexNativeRosterDiscoveryForTests(): void { + discoveryEtags.clear(); +} + function directCallerCredential(headers: Headers): CodexModelEntitlementCredentialSnapshot | null { const match = /^Bearer\s+(\S+)$/i.exec(headers.get("authorization")?.trim() ?? ""); if (!match) return null; diff --git a/src/config/derived-registries.ts b/src/config/derived-registries.ts index 2cac50232e1..9e43002c554 100644 --- a/src/config/derived-registries.ts +++ b/src/config/derived-registries.ts @@ -1,3 +1,4 @@ +import { loadDiscoveredNativeModels } from "../codex/catalog/discovered-natives"; import { setConfiguredNativeOpenAiModels } from "../codex/catalog/native-models"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers-destination"; import type { OcxConfig, OcxProviderConfig } from "../types"; @@ -20,10 +21,11 @@ export function configuredNativeOpenAiModelIds(config: Pick): } /** - * Default cadence for the opt-in catalog auto-refresh (issue #3630): one converge pass + * Default cadence for catalog auto-refresh (issue #3630): one converge pass * per hour. Each pass spends a live /models call against every enabled provider, and * provider catalogs are themselves cached upstream for minutes, so an hour is fresh * enough for newly released models to appear without an `ocx sync`. @@ -33,15 +33,17 @@ export const CATALOG_AUTO_REFRESH_DEFAULT_INTERVAL_MS: number = 60 * 60_000; export const CATALOG_AUTO_REFRESH_MIN_INTERVAL_MS: number = 15 * 60_000; /** - * Opt-in master switch, read with the house `=== true` idiom so an absent key and a - * malformed one both mean off. Pure on purpose: the scheduler calls this from a + * Enabled by default; explicit false or a zero cadence disables background work. + * Malformed enabled values still mean off. Pure on purpose: the scheduler calls this from a * dynamically imported context, so it takes an explicit config slice and reads nothing * global. */ export function isCatalogAutoRefreshEnabled( config: Pick, ): boolean { - return config.catalogAutoRefresh?.enabled === true; + const section = config.catalogAutoRefresh; + return (section?.enabled === undefined || section.enabled === true) + && section?.intervalMinutes !== 0; } /** diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 16c8e0a23c7..bce878456d4 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -1058,6 +1058,7 @@ export const quotaResetNotifySchema = z.object({ /** * Catalog auto-refresh section (issue #3630). + * Missing section or enabled flag uses the hourly default-on scheduler. * * `.strict()` like its neighbour: a typo in an optional feature section should surface as a * rejected write rather than a silently ignored key that leaves the operator believing they diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index d12a366ef31..8b524454101 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -79,8 +79,8 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { .catch(() => { // The next tick adopts it. }); - // Opt-in: the tick is a no-op unless catalogAutoRefresh.enabled is true, and the - // interval is unref'd, so a default install pays one dormant timer. The scheduler + // Default-on: explicit false or a zero cadence makes ticks dormant. Both startup + // and interval timers are unref'd. The scheduler // module keeps every heavy import inside its tick, so naming it statically here // costs a module record and nothing else. startCatalogAutoRefresh(); diff --git a/src/types/config.ts b/src/types/config.ts index 0a3c82cd128..0086a88ed0f 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -853,13 +853,14 @@ export interface OcxConfig { */ quotaResetNotify?: OcxQuotaResetNotifyConfig; /** - * Periodic provider model-catalog refresh (issue #3630). Absent means off: no timer, no - * refresh pass, no outcome record. + * Periodic provider model-catalog refresh (issue #3630). Absent means on at the hourly + * default; `enabled: false` or `intervalMinutes: 0` turns it off. * - * Off by default for the same reason every optional subsystem here is: a refresh spends a - * live /models call against every enabled provider, and this repository's rule is that a - * default install runs no detection code and starts no live timer work. Not in - * `getDefaultConfig()` — absence is the only default state this feature has. + * It started opt-in, and that is why GPT-6.1 Sol never reached an install without a + * release: nobody had turned the section on, so the authenticated Codex roster that + * announces a new model was never re-read. One hourly pass costs a live /models call per + * enabled provider, which is the price of new models appearing on their own. Not in + * `getDefaultConfig()` — absence is the default state. */ catalogAutoRefresh?: OcxCatalogAutoRefreshConfig; /** Active provider context limits; native long windows remain within their supported ceilings. */ @@ -1645,14 +1646,12 @@ export interface OcxQuotaResetNotifyConfig { /** * Periodic model-catalog auto-refresh settings (issue #3630). * - * Every field is optional and the whole section defaults to off. Each tick converges the + * Every field is optional and an absent section runs hourly. Each tick converges the * served catalog the same way `ocx sync` does, which costs a live /models call against - * every enabled provider — so an install that never asked for this must run no refresh - * code and start no timer, matching the optional-subsystem rule the rest of this file - * follows. + * every enabled provider; set `enabled: false` to keep the catalog to explicit syncs. */ export interface OcxCatalogAutoRefreshConfig { - /** Master switch. Default false — no scheduler, no tick, no upstream calls. */ + /** Master switch. Default true; false leaves the timer dormant with no upstream calls. */ enabled?: boolean; /** * Minutes between refresh ticks. Default 60, floor 15, and 0 keeps the timer dormant diff --git a/structure/catalog.md b/structure/catalog.md index d592bd3a249..db269a9215b 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -102,7 +102,7 @@ explicit observed-state merge policy and restore native priorities from the once backup rather than from a catalog whose priorities may already have been rewritten. A configured custom catalog remains the native metadata/template authority even when a bundled-catalog memo is warm. Both paths may use an admitted matching bundled memo only as installed-runtime capability -evidence to remove unsupported reasoning efforts; convergence never probes Codex itself. +evidence to remove unsupported reasoning efforts; convergence never probes Codex itself. The default-on scheduler in `src/codex/catalog-auto-refresh.ts` settles bundled runtime and authenticated Codex roster observations before admission, using dynamic imports, a 15-second source wait and the loader's bounded synchronous probes. Source failure uses existing evidence, while a stopped generation cannot start the next source or converge. A changed set records `reloadRequired` through `src/codex/catalog-refresh-status.ts` for observed running app-servers and logs one content-free restart hint; a no-op keeps it while processes remain stale or the restart observation is unknown and clears it when they are fresh or gone. Automatic refresh never restarts processes. `tests/codex-integration/catalog-auto-refresh-scheduler.test.ts` covers these boundaries. Custom Astra and Daybreak rows acquire native identity -- Responses Lite, multi-agent, context windows, display names -- only through the canonical `openai` forward destination and explicit @@ -143,19 +143,15 @@ are emitted only as selector-qualified rows whose account provenance matches. Th the bare native or API-key model list. This keeps account-scoped upstream ids such as `gpt-daybreak-blue-latest` callable without treating them as a static release allowlist. -Configured natives are the operator's way to widen that bare list without a release. A bare -`gpt-*` id under `providers.openai.models` on the canonical Codex forward provider joins -`NATIVE_OPENAI_MODELS` / `SUPPORTED_NATIVE_OPENAI_SLUGS` in place (`src/codex/catalog/native-models.ts`), -and `metadata.ts` keeps its pinned-capability, upstream-entry and context tables in step through -a subscription. Each borrows the pinned `gpt-6.1-sol` row under a name generated from its slug, takes -the GPT-6 272,000 / 872,000 context pair (`NATIVE_GPT6_CONTEXT`, also used by the built-in GPT-6 -rows), and is never account-gated. Built-in, retired and reserve ids never register. The filter -lives in `src/config/derived-registries.ts`, whose `refreshConfigDerivedRegistries` runs on every -load, persist and reconcile path, so every process that loads config sees the same set; removing -the id unregisters it and the next canonical write drops the row. Configured natives are not in -`ENTITLEMENT_PREFERRED_NATIVE_OPENAI_MODELS` or `NATIVE_MAIN_DRAIN_SENTINEL_MODELS` (they behave -like `gpt-5.5` there), and a combo `nativeAlias` cannot target one because schema validation runs -before registration. Covered by `tests/codex-integration/configured-native-models.test.ts`. +Configured natives widen the bare list without a release: eligible bare `gpt-*` ids under `providers.openai.models` on the canonical Codex forward provider join the shared registry in place (`src/codex/catalog/native-models.ts`). Each borrows the pinned `gpt-6.1-sol` row under its generated name, takes `NATIVE_GPT6_CONTEXT` (272,000 / 872,000), and is ungated. Built-in, retired and reserve ids never register. `src/config/derived-registries.ts` filters the config and refreshes registration on load, persist and reconcile; removing a configured id unregisters it unless it is also discovered. Configured natives have no entitlement routing preference or main-drain sentinel and cannot be combo `nativeAlias` targets because schema validation precedes registration. Covered by `tests/codex-integration/configured-native-models.test.ts`. + +Authenticated discovery also widens the set: after a successful nonempty roster fetch, `src/codex/model-entitlements.ts` passes full eligible rows to `src/codex/catalog/discovered-natives.ts`. Rows require a visible, API-supported bare `gpt-*` slug, display name and sane reasoning-level array; built-in, retired, reserve, malformed and oversized rows are rejected. Existing discoveries and configured natives remain eligible for refresh, so an already-supported slug does not freeze its metadata or keep borrowing a template. + +The store keeps version-1 `discovered-native-models.json` under the resolved OpenCodex home using the private atomic writer (0600 on POSIX). It merges by slug with first/last observation times and client version, expires entries unseen for 14 days, and bounds rows to 32, each to 256 KiB (a live row carries its instructions twice; GPT-6.1 Sol's was 87 KB), plus a file-size bound. Missing/corrupt files read as empty; persistence failures never fail entitlement. Config activation loads the file through derived registries; observations register immediately. A process-local monotonic generation changes for metadata or membership changes, independently of timestamp refreshes. + +The entitlement roster asks under the installed client version, and upstream's rollout gate can hide a new model from it regardless of the row's `minimal_client_version` (GPT-6.1 Sol reported 0.153.0 but was served only from 0.159.0 while the installed Codex was 0.158). So each scheduler tick also runs `discoverCodexNativeRoster` in `src/codex/model-entitlements.ts`, which asks as `CODEX_ROSTER_DISCOVERY_CLIENT_VERSION` with `If-None-Match`, feeds only the discovery store, and never writes or satisfies the entitlement cache. + +The import-free registry unions configured and discovered membership in place; removing either retains the other. Discoveries join the self-described set, use their own label and exact reasoning ladder, and derive context from their row with `NATIVE_GPT6_CONTEXT` for omitted values. Real metadata outranks a configured template; later built-in registration makes old discoveries inert. They are ungated under the flagship owner policy, without granting account entitlement or routing preference. Shared capability projection omits access programs and availability prompts, which remain scoped entitlement evidence. The next catalog merge replaces a discovered row with its latest metadata. Covered by `tests/codex-integration/discovered-native-models.test.ts`. Retirement is a catalog/evidence policy, not a universal request denylist. Manually supplied model ids still follow generic routing. User-selected config and historical usage remain stored. diff --git a/structure/config.md b/structure/config.md index 0535f765a97..a1b3b9d064f 100644 --- a/structure/config.md +++ b/structure/config.md @@ -583,7 +583,7 @@ being treated as a text model by one and an image target by the other. ## Catalog auto-refresh -`catalogAutoRefresh` on `src/types/config.ts` stores an optional `enabled` / `intervalMinutes` section that defaults off: an absent key, an explicit false, and a malformed value all leave the scheduler dormant. `src/config/feature-flags.ts` resolves the cadence; an explicit `intervalMinutes: 0` keeps the unref'd timer idle, and any other value is clamped up to 15 minutes because upstream `/models` caches have not moved below that and a shorter tick only multiplies rate-limit exposure. `src/codex/catalog-auto-refresh.ts` is the module-singleton interval `src/server/background-lifecycle.ts` starts beside the quota reset poller; a tick that is enabled and non-dormant drives the same catalog-only converge funnel management mutations drive. Each tick arms its independently loaded config snapshot as a detached baseline before provider work, so the discovery save rebases every field — the listener binding and sections absent from the snapshot included — against the disk state at save time and concurrent hand edits survive the tick — `disabledModels` merges by member, so an overlapping visibility edit survives alongside the discovery additions. Detached saves capture explicit persisted top-level deletion intent before reconciliation, so a changed discovery snapshot cannot erase a current disk tombstone by temporarily restoring its key. A defined value reintroduced on disk removes stale deletion authority; ordinary live-config conflict precedence remains unchanged. The last-outcome record lives in `src/codex/catalog-refresh-status.ts` (when the tick finished, the normalized `CatalogDisposition`, whether the served model set changed, consecutive failures) and carries no provider or account detail. +`catalogAutoRefresh` on `src/types/config.ts` stores an optional `enabled` / `intervalMinutes` section that defaults on at a 60-minute cadence: an absent section or absent `enabled` enables refresh, while explicit false or `intervalMinutes: 0` disables it. A malformed section is dropped by the load schema and therefore uses the default. `src/config/feature-flags.ts` resolves the cadence; an explicit `intervalMinutes: 0` keeps the unref'd timer idle, and any other value is clamped up to 15 minutes because upstream `/models` caches have not moved below that and a shorter tick only multiplies rate-limit exposure. `src/codex/catalog-auto-refresh.ts` owns the unref'd interval and a single unref'd three-minute startup tick that `src/server/background-lifecycle.ts` starts beside the quota reset poller; stopping cancels both timers, and adopting a new cadence re-arms only the interval; a tick that is enabled and non-dormant drives the same catalog-only converge funnel management mutations drive. Each tick arms its independently loaded config snapshot as a detached baseline before provider work, so the discovery save rebases every field — the listener binding and sections absent from the snapshot included — against the disk state at save time and concurrent hand edits survive the tick — `disabledModels` merges by member, so an overlapping visibility edit survives alongside the discovery additions. Detached saves capture explicit persisted top-level deletion intent before reconciliation, so a changed discovery snapshot cannot erase a current disk tombstone by temporarily restoring its key. A defined value reintroduced on disk removes stale deletion authority; ordinary live-config conflict precedence remains unchanged. The last-outcome record lives in `src/codex/catalog-refresh-status.ts` (when the tick finished, the normalized `CatalogDisposition`, whether the served model set changed, consecutive failures, and `reloadRequired` for running Codex sessions) and carries no provider or account detail. ## Aggregate request metrics export diff --git a/tests/codex-integration/catalog-auto-refresh-scheduler.test.ts b/tests/codex-integration/catalog-auto-refresh-scheduler.test.ts index 38953a32854..973fb23a430 100644 --- a/tests/codex-integration/catalog-auto-refresh-scheduler.test.ts +++ b/tests/codex-integration/catalog-auto-refresh-scheduler.test.ts @@ -16,6 +16,9 @@ import { } from "../../src/codex/catalog-auto-refresh"; import { lastCatalogAutoRefreshOutcome, resetCatalogAutoRefreshStatusForTests } from "../../src/codex/catalog-refresh-status"; import type { CatalogOnlyOutcome } from "../../src/codex/convergence-types"; +import * as bundled from "../../src/codex/catalog/bundled"; +import * as entitlements from "../../src/codex/model-entitlements"; +import * as appServerProcesses from "../../src/codex/app-server-processes"; import * as managementConvergence from "../../src/codex/management-convergence"; import { DEFAULT_CATALOG_PATH } from "../../src/codex/paths"; import { @@ -44,6 +47,7 @@ let openCodexHome = ""; let isolatedCodexHome: IsolatedCodexHome | null = null; let convergeFactoryCalls = 0; let convergeImpl: (config: OcxConfig) => Promise = async () => COMMITTED_CATALOG_ONLY; +let sourceSpies: Array<{ mockRestore(): void }> = []; let convergeSpy: { mockRestore(): void } | null = null; let releaseHanging: ((outcome: CatalogOnlyOutcome) => void) | null = null; let pendingTick: Promise | null = null; @@ -71,6 +75,14 @@ beforeEach(() => { writeFileSync(DEFAULT_CATALOG_PATH, JSON.stringify({ models: [] }), "utf8"); resetCatalogAutoRefreshForTests(); resetCatalogAutoRefreshStatusForTests(); + sourceSpies = [ + spyOn(bundled, "loadBundledCodexCatalog").mockReturnValue(null), + spyOn(entitlements, "ensureCodexEntitlementFreshness").mockResolvedValue(undefined), + spyOn(entitlements, "discoverCodexNativeRoster").mockResolvedValue("unavailable"), + spyOn(appServerProcesses, "listCodexAppServerProcesses").mockReturnValue([]), + spyOn(appServerProcesses, "collectCodexAppServerCatalogStateWithin") + .mockResolvedValue({ state: "not_running", processes: [], catalogMtimeMs: null }), + ]; convergeFactoryCalls = 0; convergeImpl = async () => COMMITTED_CATALOG_ONLY; releaseHanging = null; @@ -93,6 +105,8 @@ afterEach(async () => { stopCatalogAutoRefresh(); resetCatalogAutoRefreshForTests(); resetCatalogAutoRefreshStatusForTests(); + for (const spy of sourceSpies) spy.mockRestore(); + sourceSpies = []; convergeSpy?.mockRestore(); convergeSpy = null; isolatedCodexHome?.restore(); @@ -153,17 +167,157 @@ describe("catalog auto-refresh scheduler", () => { expect(catalogAutoRefreshTickCountForTests()).toBe(0); }); - test("a tick with catalogAutoRefresh absent or enabled:false performs no converge", async () => { + test("the unref'd startup tick fires once, survives cadence changes, and stop cancels it", async () => { + writeCatalogAutoRefreshConfig({ intervalMinutes: 30 }); + const delayed: Array<{ callback: () => unknown; unrefs: number }> = []; + const original = globalThis.setTimeout; + const set = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => unknown, delay?: number) => { + if (delay !== 3 * 60_000) return original(callback, delay); + const handle = { callback, unrefs: 0, unref() { this.unrefs += 1; return this; } }; + delayed.push(handle); + return handle; + }) as typeof setTimeout); + const clear = spyOn(globalThis, "clearTimeout").mockImplementation(() => {}); + try { + startCatalogAutoRefresh(); + startCatalogAutoRefresh(); + expect(delayed).toHaveLength(1); + expect(delayed[0]!.unrefs).toBe(1); + await delayed[0]!.callback(); + expect(convergeFactoryCalls).toBe(1); + expect(catalogAutoRefreshIntervalForTests()).toBe(30 * 60_000); + expect(delayed).toHaveLength(1); + stopCatalogAutoRefresh(); + startCatalogAutoRefresh(); + stopCatalogAutoRefresh(); + expect(clear).toHaveBeenCalledWith(delayed[1]); + // Even an already queued callback loses publication authority after stop. + await delayed[1]!.callback(); + expect(convergeFactoryCalls).toBe(1); + } finally { + stopCatalogAutoRefresh(); + set.mockRestore(); + clear.mockRestore(); + } + }); + + test.each(["none", "bundled", "roster", "discovery"])("sources settle before converge despite %s failure", async failure => { + writeCatalogAutoRefreshConfig(); + const steps: string[] = []; + spyOn(bundled, "loadBundledCodexCatalog").mockImplementation(() => { + steps.push("bundled"); + if (failure === "bundled") throw new Error("private source failure"); + return null; + }); + spyOn(entitlements, "ensureCodexEntitlementFreshness").mockImplementation(async (_config, options) => { + steps.push("roster"); + expect(options?.waitMs).toBe(15_000); + if (failure === "roster") throw new Error("private roster failure"); + }); + spyOn(entitlements, "discoverCodexNativeRoster").mockImplementation(async () => { + steps.push("discovery"); + if (failure === "discovery") throw new Error("private discovery failure"); + return "recorded"; + }); + convergeImpl = async () => { steps.push("converge"); return COMMITTED_CATALOG_ONLY; }; + await runCatalogAutoRefreshTickForTests(); + expect(steps).toEqual(["bundled", "roster", "discovery", "converge"]); + expect(lastCatalogAutoRefreshOutcome()?.disposition.status).toBe("committed"); + }); + + test("stopping during source refresh prevents roster warm and convergence", async () => { writeCatalogAutoRefreshConfig(); + spyOn(bundled, "loadBundledCodexCatalog").mockImplementation(() => { + stopCatalogAutoRefresh(); + return null; + }); await runCatalogAutoRefreshTickForTests(); - expect(catalogAutoRefreshTickCountForTests()).toBe(0); + expect(entitlements.ensureCodexEntitlementFreshness).not.toHaveBeenCalled(); + expect(entitlements.discoverCodexNativeRoster).not.toHaveBeenCalled(); expect(convergeFactoryCalls).toBe(0); expect(lastCatalogAutoRefreshOutcome()).toBeNull(); + }); + + test("a roster wait that exceeds its bound still allows convergence", async () => { + writeCatalogAutoRefreshConfig(); + let entered!: () => void; + const rosterEntered = new Promise(resolve => { entered = resolve; }); + spyOn(entitlements, "ensureCodexEntitlementFreshness").mockImplementation(() => { + entered(); + return new Promise(() => {}); + }); + const deadlines: Array<() => void> = []; + const original = globalThis.setTimeout; + const timeout = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void, delay?: number) => { + if (delay !== 15_000) return original(callback, delay); + deadlines.push(callback); + return { unref() { return this; } }; + }) as typeof setTimeout); + const clear = spyOn(globalThis, "clearTimeout").mockImplementation(() => {}); + try { + const pending = runCatalogAutoRefreshTickForTests(); + await rosterEntered; + expect(deadlines).toHaveLength(2); + deadlines[1]!(); + await pending; + expect(convergeFactoryCalls).toBe(1); + } finally { + timeout.mockRestore(); + clear.mockRestore(); + } + }); + + test("a changed set records reloadRequired and logs one safe restart hint", async () => { + writeCatalogAutoRefreshConfig(); + spyOn(appServerProcesses, "listCodexAppServerProcesses").mockReturnValue([{ pid: 123, commandLine: "private fixture" }]); + spyOn(appServerProcesses, "collectCodexAppServerCatalogStateWithin").mockResolvedValue({ + state: "stale", processes: [{ pid: 123, startedAtMs: 1 }], + catalogMtimeMs: 2, + }); + convergeImpl = async () => ({ + kind: "catalog-only", changed: true, + catalogRefresh: { status: "committed", changed: true, degraded: false, notices: [] }, + }); + const info = spyOn(console, "info").mockImplementation(() => {}); + try { + await runCatalogAutoRefreshTickForTests(); + expect(lastCatalogAutoRefreshOutcome()?.reloadRequired).toBe(true); + expect(info.mock.calls).toEqual([[ + "[catalog-auto-refresh] served model set changed; running Codex sessions keep the old list until restarted (ocx sync --restart-codex)", + ]]); + convergeImpl = async () => COMMITTED_CATALOG_ONLY; + await runCatalogAutoRefreshTickForTests(); + expect(lastCatalogAutoRefreshOutcome()?.reloadRequired).toBe(true); + expect(info).toHaveBeenCalledTimes(1); + spyOn(appServerProcesses, "collectCodexAppServerCatalogStateWithin").mockResolvedValue({ + state: "unknown", processes: [], catalogMtimeMs: null, + }); + await runCatalogAutoRefreshTickForTests(); + expect(lastCatalogAutoRefreshOutcome()?.reloadRequired).toBe(true); + spyOn(appServerProcesses, "collectCodexAppServerCatalogStateWithin").mockResolvedValue({ + state: "fresh", processes: [{ pid: 124, startedAtMs: 3 }], + catalogMtimeMs: 2, + }); + await runCatalogAutoRefreshTickForTests(); + expect(lastCatalogAutoRefreshOutcome()?.reloadRequired).toBe(false); + } finally { info.mockRestore(); } + }); + + test("a tick with catalogAutoRefresh absent performs a converge", async () => { + writeCatalogAutoRefreshConfig(); + await runCatalogAutoRefreshTickForTests(); + expect(catalogAutoRefreshTickCountForTests()).toBe(1); + expect(convergeFactoryCalls).toBe(1); + expect(lastCatalogAutoRefreshOutcome()?.disposition.status).toBe("committed"); + }); + test("explicit enabled:false performs no converge", async () => { writeCatalogAutoRefreshConfig({ enabled: false, intervalMinutes: 60 }); await runCatalogAutoRefreshTickForTests(); expect(catalogAutoRefreshTickCountForTests()).toBe(0); expect(convergeFactoryCalls).toBe(0); + expect(bundled.loadBundledCodexCatalog).not.toHaveBeenCalled(); + expect(entitlements.ensureCodexEntitlementFreshness).not.toHaveBeenCalled(); expect(lastCatalogAutoRefreshOutcome()).toBeNull(); }); @@ -554,6 +708,8 @@ describe("catalog auto-refresh drift heal", () => { const fs = require("node:fs"); const path = require("node:path"); const configModule = require(${JSON.stringify(configPath)}); + const sources = require(${JSON.stringify(fileURLToPath(new URL("../../src/codex/catalog-auto-refresh-sources.ts", import.meta.url)))}); + spyOn(sources, "refreshCatalogAutoRefreshSources").mockResolvedValue(undefined); const scheduler = require(${JSON.stringify(schedulerPath)}); const drift = require(${JSON.stringify(driftPath)}); const desired = require(${JSON.stringify(desiredPath)}); @@ -606,6 +762,8 @@ describe("catalog auto-refresh drift heal", () => { const fs = require("node:fs"); const path = require("node:path"); const config = require(${JSON.stringify(source("config.ts"))}); + const sources = require(${JSON.stringify(fileURLToPath(new URL("../../src/codex/catalog-auto-refresh-sources.ts", import.meta.url)))}); + spyOn(sources, "refreshCatalogAutoRefreshSources").mockResolvedValue(undefined); const scheduler = require(${JSON.stringify(source("codex/catalog-auto-refresh.ts"))}); const drift = require(${JSON.stringify(source("codex/config-drift-heal.ts"))}); const desired = require(${JSON.stringify(source("codex/desired-state.ts"))}); diff --git a/tests/codex-integration/codex-catalog-refresh-status.test.ts b/tests/codex-integration/codex-catalog-refresh-status.test.ts index 5a728b05b8e..d9a1a910937 100644 --- a/tests/codex-integration/codex-catalog-refresh-status.test.ts +++ b/tests/codex-integration/codex-catalog-refresh-status.test.ts @@ -12,6 +12,15 @@ afterEach(() => { resetCatalogAutoRefreshStatusForTests(); }); +test("reloadRequired is exposed as a frozen boolean without copying caller details", () => { + const committed: CatalogDisposition = { status: "committed", changed: true, degraded: false, notices: [] }; + expect(recordCatalogAutoRefreshOutcome(committed, true)?.reloadRequired).toBe(false); + expect(recordCatalogAutoRefreshOutcome(committed, true, true)?.reloadRequired).toBe(true); + expect(lastCatalogAutoRefreshOutcome()?.reloadRequired).toBe(true); + expect(Object.isFrozen(lastCatalogAutoRefreshOutcome())).toBe(true); + expect(recordCatalogAutoRefreshOutcome(committed, true, "private detail" as never)?.reloadRequired).toBe(false); +}); + describe("catalogRefreshIsPending", () => { test("only committed catalog state is complete", () => { const committed: CatalogDisposition = { diff --git a/tests/codex-integration/discovered-native-models.test.ts b/tests/codex-integration/discovered-native-models.test.ts new file mode 100644 index 00000000000..15c6bddcda2 --- /dev/null +++ b/tests/codex-integration/discovered-native-models.test.ts @@ -0,0 +1,283 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildCatalogEntries } from "../../src/codex/catalog"; +import { + DISCOVERED_NATIVE_MAX_FILE_BYTES, + DISCOVERED_NATIVE_MAX_ROW_BYTES, + DISCOVERED_NATIVE_MAX_ROWS, + DISCOVERED_NATIVE_RETENTION_MS, + discoveredNativeModelsGeneration, + loadDiscoveredNativeModels, + recordDiscoveredNativeModels, + resetDiscoveredNativeModelsForTests, + validateDiscoveredNativeRows, +} from "../../src/codex/catalog/discovered-natives"; +import { + ACCOUNT_GATED_NATIVE_OPENAI_MODELS, + NATIVE_OPENAI_MODELS, + SUPPORTED_NATIVE_OPENAI_SLUGS, + configuredNativeOpenAiModels, + discoveredNativeOpenAiModels, + resetConfiguredNativeOpenAiModelsForTests, + setConfiguredNativeOpenAiModels, +} from "../../src/codex/catalog/native-models"; +import { + nativeOpenAiContextTier, + nativeOpenAiContextWindow, + nativeOpenAiSlugs, + nativeReasoningEfforts, + upstreamNativeEntry, + mergeCatalogEntriesForSync, +} from "../../src/codex/catalog"; +import { refreshConfigDerivedRegistries } from "../../src/config/derived-registries"; +import { + CODEX_ROSTER_DISCOVERY_CLIENT_VERSION, + discoverCodexNativeRoster, + getCodexModelEntitlementStatus, + resetCodexModelEntitlementCacheForTests, + resetCodexNativeRosterDiscoveryForTests, + resolveCodexModelEntitlements, +} from "../../src/codex/model-entitlements"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const FUTURE = "gpt-9-test"; +let home: string; +let previousHome: string | undefined; +let path: string; + +function row(slug = FUTURE, extra: Record = {}): Record { + return { + slug, display_name: "Future native's own name", description: "Its own upstream description", + supported_in_api: true, visibility: "list", context_window: 123_000, max_context_window: 456_000, + supported_reasoning_levels: [{ effort: "low", description: "Quick" }, { effort: "high", description: "Deep" }], + default_reasoning_level: "high", model_messages: { instructions_template: "Upstream future instructions" }, + ...extra, + }; +} + +function persisted(): { version: number; models: Array<{ slug: string; row: Record; firstSeenAt: number; lastSeenAt: number; clientVersion: string }> } { + return JSON.parse(readFileSync(path, "utf8")); +} + +async function fetchRoster(models: unknown, now = Date.now(), status = 200) { + resetCodexModelEntitlementCacheForTests(); + return resolveCodexModelEntitlements({}, { + now, clientVersion: "0.160.0", + credentials: [{ accountId: "future-test", accessToken: "fixture-token", credentialIdentity: "fixture-generation" }], + fetcher: (() => Promise.resolve(Response.json({ models }, { status }))) as typeof fetch, + }); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-discovered-native-")); + process.env.OPENCODEX_HOME = home; + path = join(home, "discovered-native-models.json"); + resetDiscoveredNativeModelsForTests(); + resetConfiguredNativeOpenAiModelsForTests(); + resetCodexModelEntitlementCacheForTests(); +}); + +afterEach(() => { + resetDiscoveredNativeModelsForTests(); + resetConfiguredNativeOpenAiModelsForTests(); + resetCodexModelEntitlementCacheForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +describe("authenticated native discovery", () => { + test("a future model returned only by the roster persists and builds with its own capabilities", async () => { + const now = Date.now(); + const snapshot = await fetchRoster([row("gpt-6.1-sol"), row()], now); + expect(snapshot.modelsByAccount.get("future-test")?.has(FUTURE)).toBe(true); + expect(persisted()).toMatchObject({ version: 1, models: [{ slug: FUTURE, row: row(), firstSeenAt: now, lastSeenAt: now, clientVersion: "0.160.0" }] }); + if (process.platform !== "win32") expect(statSync(path).mode & 0o777).toBe(0o600); + expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(true); + expect(ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(FUTURE)).toBe(false); + expect(nativeOpenAiSlugs()).toContain(FUTURE); + expect(nativeReasoningEfforts(FUTURE)).toEqual(["low", "high"]); + const built = buildCatalogEntries(null, [...NATIVE_OPENAI_MODELS], []); + const future = built.find(entry => entry.slug === FUTURE)!; + expect(future).toMatchObject({ display_name: row().display_name, description: row().description, + supported_reasoning_levels: row().supported_reasoning_levels, default_reasoning_level: "high", + context_window: 123_000, max_context_window: 123_000, base_instructions: "Upstream future instructions" }); + expect(built.filter(entry => entry.slug === FUTURE)).toHaveLength(1); + expect(nativeOpenAiContextTier(FUTURE)).toEqual({ defaultWindow: 123_000, longWindow: 456_000 }); + expect(nativeOpenAiContextWindow(FUTURE, { modelWindows: { [FUTURE]: 1_000_000 } })).toBe(456_000); + }); + + test("built-in, retired, reserve, hidden, unsupported and malformed rows are rejected", () => { + const invalid = [row("gpt-6.1-sol"), row("gpt-5.4"), row("gpt-5.3-codex-spark"), row("gpt-reserve"), + row("openai/gpt-9-test"), row(FUTURE, { visibility: "hide" }), row(FUTURE, { supported_in_api: false }), + row(FUTURE, { display_name: 9 }), row(FUTURE, { supported_reasoning_levels: null }), + row(FUTURE, { supported_reasoning_levels: [null] }), row(FUTURE, { supported_reasoning_levels: ["high"] }), + row(FUTURE, { context_window: -1 }), row(FUTURE, { description: "x".repeat(DISCOVERED_NATIVE_MAX_ROW_BYTES) }), null]; + expect(validateDiscoveredNativeRows(invalid)).toEqual([]); + expect(validateDiscoveredNativeRows({ models: [row()] })).toEqual([]); + recordDiscoveredNativeModels(invalid, "0.160.0"); + expect(discoveredNativeOpenAiModels()).toEqual([]); + expect(NATIVE_OPENAI_MODELS.filter(slug => slug === "gpt-6.1-sol")).toHaveLength(1); + }); + + test("failed or empty roster fetches cannot register models", async () => { + await fetchRoster([row()], Date.now(), 500); + expect(discoveredNativeOpenAiModels()).toEqual([]); + await fetchRoster([]); + expect(discoveredNativeOpenAiModels()).toEqual([]); + }); + + test("config activation reloads persisted rows; corrupt and oversized files are empty", () => { + recordDiscoveredNativeModels([row()], "0.160.0"); + resetDiscoveredNativeModelsForTests(); + expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(false); + refreshConfigDerivedRegistries({ providers: {} } as OcxConfig); + expect(upstreamNativeEntry(FUTURE)?.display_name).toBe(row().display_name); + writeFileSync(path, "{corrupt"); + loadDiscoveredNativeModels(); + expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(false); + expect(upstreamNativeEntry(FUTURE)).toBeNull(); + writeFileSync(path, " ".repeat(DISCOVERED_NATIVE_MAX_FILE_BYTES + 1)); + loadDiscoveredNativeModels(); + expect(discoveredNativeOpenAiModels()).toEqual([]); + }); + + test("latest row wins, preserves first seen, updates generation and refreshes an existing catalog row", () => { + const now = Date.now(); + recordDiscoveredNativeModels([row()], "0.160.0", now); + const before = discoveredNativeModelsGeneration(); + const changed = row(FUTURE, { display_name: "Updated upstream name", context_window: 200_000 }); + recordDiscoveredNativeModels([changed], "0.161.0", now + 1); + expect(persisted().models[0]).toMatchObject({ firstSeenAt: now, lastSeenAt: now + 1, clientVersion: "0.161.0", row: changed }); + expect(discoveredNativeModelsGeneration()).toBeGreaterThan(before); + expect(nativeOpenAiContextWindow(FUTURE)).toBe(200_000); + const entries = buildCatalogEntries(null, [FUTURE], []); + expect(entries[0]?.display_name).toBe("Updated upstream name"); + const merged = mergeCatalogEntriesForSync([row()], [], new Map(), [], false); + expect(merged.find(entry => entry.slug === FUTURE)?.display_name).toBe("Updated upstream name"); + const unchanged = discoveredNativeModelsGeneration(); + recordDiscoveredNativeModels([changed], "0.161.0", now + 2); + expect(discoveredNativeModelsGeneration()).toBe(unchanged); + }); + + test("pruning unregisters rows and cleans metadata, including the last persisted row", () => { + const now = Date.now(); + recordDiscoveredNativeModels([row()], "0.160.0", now); + recordDiscoveredNativeModels([], "0.160.0", now + DISCOVERED_NATIVE_RETENTION_MS + 1); + expect(persisted().models).toEqual([]); + expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(false); + expect(upstreamNativeEntry(FUTURE)).toBeNull(); + expect(nativeOpenAiContextWindow(FUTURE)).toBeUndefined(); + }); + + test("configured and discovered membership is unioned; real capabilities replace the configured template", () => { + setConfiguredNativeOpenAiModels([FUTURE]); + recordDiscoveredNativeModels([row()], "0.160.0"); + expect(NATIVE_OPENAI_MODELS.filter(slug => slug === FUTURE)).toHaveLength(1); + expect(nativeReasoningEfforts(FUTURE)).toEqual(["low", "high"]); + expect(upstreamNativeEntry(FUTURE)?.display_name).toBe(row().display_name); + setConfiguredNativeOpenAiModels([]); + expect(configuredNativeOpenAiModels()).toEqual([]); + expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(true); + setConfiguredNativeOpenAiModels([FUTURE]); + recordDiscoveredNativeModels([], "0.160.0", Date.now() + DISCOVERED_NATIVE_RETENTION_MS + 1); + expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(true); + expect(upstreamNativeEntry(FUTURE)?.display_name).toBe("GPT-9-Test"); + }); + + test("bounds discoveries and falls back only for omitted context metadata", () => { + const rows = Array.from({ length: 40 }, (_, i) => row(`gpt-9-test-${i}`, { context_window: undefined, max_context_window: undefined })); + recordDiscoveredNativeModels(rows, "0.160.0"); + expect(persisted().models).toHaveLength(DISCOVERED_NATIVE_MAX_ROWS); + expect(statSync(path).size).toBeLessThanOrEqual(DISCOVERED_NATIVE_MAX_FILE_BYTES); + expect(nativeOpenAiContextTier("gpt-9-test-0")).toEqual({ defaultWindow: 272_000, longWindow: 872_000 }); + }); + + test("an explicitly empty reasoning ladder stays empty rather than borrowing a family default", () => { + recordDiscoveredNativeModels([row(FUTURE, { supported_reasoning_levels: [], default_reasoning_level: null })], "0.160.0"); + expect(nativeReasoningEfforts(FUTURE)).toEqual([]); + expect(buildCatalogEntries(null, [FUTURE], [])[0]?.supported_reasoning_levels).toEqual([]); + }); + + test("expired persisted rows and built-in rows from an older store do not register on load", () => { + const now = Date.now(); + const entry = (slug: string, lastSeenAt: number) => ({ slug, row: row(slug), + firstSeenAt: 0, lastSeenAt, clientVersion: "0.160.0" }); + writeFileSync(path, JSON.stringify({ version: 1, models: [ + entry(FUTURE, now - DISCOVERED_NATIVE_RETENTION_MS - 1), entry("gpt-6.1-sol", now), + ] })); + loadDiscoveredNativeModels(now); + expect(discoveredNativeOpenAiModels()).toEqual([]); + expect(upstreamNativeEntry("gpt-6.1-sol")?.display_name).toBe("GPT-6.1-Sol"); + expect(NATIVE_OPENAI_MODELS.filter(slug => slug === "gpt-6.1-sol")).toHaveLength(1); + }); + + test("a persistence failure cannot turn a successful entitlement into a request failure", async () => { + mkdirSync(path); + const snapshot = await fetchRoster([row()]); + expect(snapshot.modelsByAccount.get("future-test")?.has(FUTURE)).toBe(true); + expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(true); + expect(upstreamNativeEntry(FUTURE)?.display_name).toBe(row().display_name); + }); + + test("account-specific grants and availability prompts never become shared capability metadata", () => { + recordDiscoveredNativeModels([row(FUTURE, { available_access_programs: { cyber: ["fixture"] }, availability_nux: { message: "pool-only prompt" } })], "0.160.0"); + const entry = upstreamNativeEntry(FUTURE)!; + expect(entry.available_access_programs).toBeUndefined(); + expect(entry.availability_nux).toBeUndefined(); + }); +}); + +describe("discovery-only roster", () => { + const credential = { accountId: "future-test", accessToken: "fixture-token", chatgptAccountId: "", credentialIdentity: "fixture-generation" }; + + test("asks as a newer client, records an unpinned row, and leaves the entitlement cache alone", async () => { + resetCodexNativeRosterDiscoveryForTests(); + const urls: string[] = []; + const fetcher = ((url: string | URL) => { + urls.push(String(url)); + return Promise.resolve(Response.json({ models: [row()] }, { headers: { etag: "W/\"roster-1\"" } })); + }) as typeof fetch; + expect(await discoverCodexNativeRoster({}, { credentials: [credential], fetcher })).toBe("recorded"); + expect(new URL(urls[0]!).searchParams.get("client_version")).toBe(CODEX_ROSTER_DISCOVERY_CLIENT_VERSION); + expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(true); + expect(persisted().models.map(model => model.slug)).toEqual([FUTURE]); + // A discovery roster must never answer an entitlement question for another client version. + expect(getCodexModelEntitlementStatus("future-test")).toEqual({ status: "unavailable" }); + }); + + test("revalidates with the last ETag and treats 304 as unchanged", async () => { + resetCodexNativeRosterDiscoveryForTests(); + const seen: Array = []; + let calls = 0; + const fetcher = ((_url: string | URL, init?: RequestInit) => { + seen.push(new Headers(init?.headers).get("if-none-match")); + calls += 1; + return Promise.resolve(calls === 1 + ? Response.json({ models: [row()] }, { headers: { etag: "\"roster-2\"" } }) + : new Response(null, { status: 304 })); + }) as typeof fetch; + expect(await discoverCodexNativeRoster({}, { credentials: [credential], fetcher })).toBe("recorded"); + expect(await discoverCodexNativeRoster({}, { credentials: [credential], fetcher })).toBe("not-modified"); + expect(seen).toEqual([null, "\"roster-2\""]); + }); + + test("a real-sized row carrying its instructions twice is admitted", () => { + // GPT-6.1 Sol's live row was 87,183 bytes: base_instructions plus the same text as a template. + const instructions = "x".repeat(44_000); + const large = row(FUTURE, { base_instructions: instructions, model_messages: { instructions_template: instructions } }); + expect(Buffer.byteLength(JSON.stringify(large))).toBeGreaterThan(64 * 1024); + expect(validateDiscoveredNativeRows([large]).map(model => model.slug)).toEqual([FUTURE]); + }); + + test("an upstream failure is unavailable and registers nothing", async () => { + resetCodexNativeRosterDiscoveryForTests(); + const fetcher = (() => Promise.resolve(new Response("nope", { status: 503 }))) as typeof fetch; + expect(await discoverCodexNativeRoster({}, { credentials: [credential], fetcher })).toBe("unavailable"); + expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(false); + }); +}); diff --git a/tests/config/config-catalog-auto-refresh.test.ts b/tests/config/config-catalog-auto-refresh.test.ts index dc142d2e29d..1d96e4cd29e 100644 --- a/tests/config/config-catalog-auto-refresh.test.ts +++ b/tests/config/config-catalog-auto-refresh.test.ts @@ -75,12 +75,13 @@ test("resolveCatalogAutoRefreshIntervalMs clamps below the floor and honours val .toBe(120 * 60_000); }); -test("isCatalogAutoRefreshEnabled reads true only for an explicit enabled:true", () => { - // The house === true idiom keeps an absent key, an explicit false, and a hand-edited - // truthy string all reading off, so a malformed edit cannot start a live timer. - expect(isCatalogAutoRefreshEnabled({})).toBe(false); - expect(isCatalogAutoRefreshEnabled({ catalogAutoRefresh: {} })).toBe(false); +test("isCatalogAutoRefreshEnabled defaults on while explicit false or zero disables", () => { + // Missing settings enable discovery; malformed enabled values still fail closed. + expect(isCatalogAutoRefreshEnabled({})).toBe(true); + expect(isCatalogAutoRefreshEnabled({ catalogAutoRefresh: {} })).toBe(true); expect(isCatalogAutoRefreshEnabled({ catalogAutoRefresh: { enabled: false } })).toBe(false); + expect(isCatalogAutoRefreshEnabled({ catalogAutoRefresh: { intervalMinutes: 0 } })).toBe(false); + expect(isCatalogAutoRefreshEnabled({ catalogAutoRefresh: { intervalMinutes: 30 } })).toBe(true); expect(isCatalogAutoRefreshEnabled({ catalogAutoRefresh: { enabled: "yes" as never } })).toBe(false); expect(isCatalogAutoRefreshEnabled({ catalogAutoRefresh: { enabled: true } })).toBe(true); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f4556e433ba..afe185f7104 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -2,6 +2,7 @@ "service-desktop-startup-health.test.ts": "service", "service-desktop-startup.test.ts": "service", "startup-health-packaged-probe.test.ts": "server", + "discovered-native-models.test.ts": "codex-integration", "cursor-local-installer.test.ts": "providers/cursor", "codex-quota-query-backoff.test.ts": "codex-integration", "link-relay-bound-transport.test.ts": "clients", From 69a54f2cbf60897eb3a67184061aba86f49e853d Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 30 Sep 2026 11:58:13 +0900 Subject: [PATCH 2/3] fix(catalog): keep default refresh to managed Codex installs and harden the discovery store Review follow-up. An absent catalogAutoRefresh section now refreshes only where this proxy manages the local Codex client; with the integration off it keeps the opt-in meaning, and Codex sources are never read there. The discovery store re-reads and merges the file before each write so another process's rows survive, renews an unchanged row on disk at most hourly so request-time roster fetches rarely write, and the discovery ETag expires after a day so a 304 cannot let a live model age out. --- src/codex/catalog-auto-refresh.ts | 10 ++++- src/codex/catalog/discovered-natives.ts | 42 ++++++++++++++----- src/codex/model-entitlements.ts | 19 ++++++--- structure/catalog.md | 2 +- structure/config.md | 2 +- .../catalog-auto-refresh-scheduler.test.ts | 32 ++++++++++++++ .../discovered-native-models.test.ts | 36 ++++++++++++++++ 7 files changed, 124 insertions(+), 19 deletions(-) diff --git a/src/codex/catalog-auto-refresh.ts b/src/codex/catalog-auto-refresh.ts index b2c9fe4cb85..b3362054df3 100644 --- a/src/codex/catalog-auto-refresh.ts +++ b/src/codex/catalog-auto-refresh.ts @@ -203,6 +203,13 @@ async function tick(): Promise { // and the unref'd timer is left running so flipping the minutes back on is // picked up without a process restart. if (configured === 0) return; + // The default-on case belongs to installs whose local Codex client this proxy manages. With + // the integration off (or on a hub or sibling), an absent section keeps the old opt-in + // meaning: no background converge may write the native Codex home unasked. An explicit + // `enabled: true` still refreshes, as it did before the default flipped. + const { shouldSyncCodexOnStart } = await import("./desired-state"); + const codexManaged = shouldSyncCodexOnStart(config); + if (!codexManaged && config.catalogAutoRefresh?.enabled !== true) return; // A stop or restart landed while the config resolved: this tick no longer owns the timer, // so it must neither count as a refresh nor adopt a cadence for a generation that is gone. if (entryGeneration !== generation) return; @@ -225,7 +232,8 @@ async function tick(): Promise { const { refreshCatalogAutoRefreshSources, catalogAutoRefreshReloadRequired } = await import("./catalog-auto-refresh-sources"); if (entryGeneration !== generation) return; - await refreshCatalogAutoRefreshSources(config, () => entryGeneration === generation); + // Codex sources read Codex credentials and probe its binary; only a managed client needs them. + if (codexManaged) await refreshCatalogAutoRefreshSources(config, () => entryGeneration === generation); if (entryGeneration !== generation) return; const [{ createManagementConvergeCodex }, { createCatalogConvergeRequest }] = await Promise.all([ import("./management-convergence"), diff --git a/src/codex/catalog/discovered-natives.ts b/src/codex/catalog/discovered-natives.ts index e2850c5c858..67a3b2b096b 100644 --- a/src/codex/catalog/discovered-natives.ts +++ b/src/codex/catalog/discovered-natives.ts @@ -14,6 +14,12 @@ export const DISCOVERED_NATIVE_MAX_ROWS = 32; export const DISCOVERED_NATIVE_MAX_ROW_BYTES = 256 * 1024; export const DISCOVERED_NATIVE_MAX_FILE_BYTES = DISCOVERED_NATIVE_MAX_ROWS * (DISCOVERED_NATIVE_MAX_ROW_BYTES + 1024); export const DISCOVERED_NATIVE_RETENTION_MS = 14 * 24 * 60 * 60 * 1000; +/** + * An unchanged row renews its last-seen time on disk at most this often. The entitlement path + * records on every successful roster fetch, including request-time ones, so writing each renewal + * would put a synchronous file replace (and on Windows an ACL pass) on ordinary traffic. + */ +export const DISCOVERED_NATIVE_RENEW_INTERVAL_MS = 60 * 60 * 1000; const FILE_NAME = "discovered-native-models.json"; export interface DiscoveredNativeModel { @@ -64,12 +70,18 @@ function boundedModels(entries: DiscoveredNativeModel[], now: number): Discovere } function readModels(path: string, now: number): DiscoveredNativeModel[] { + return readModelsWithCount(path, now).models; +} + +/** `storedCount` is the raw entry count, so a caller can tell pruning from an unchanged store. */ +function readModelsWithCount(path: string, now: number): { models: DiscoveredNativeModel[]; storedCount: number } { + const empty = { models: [], storedCount: 0 }; try { - if (statSync(path).size > DISCOVERED_NATIVE_MAX_FILE_BYTES) return []; + if (statSync(path).size > DISCOVERED_NATIVE_MAX_FILE_BYTES) return empty; const bytes = readFileSync(path, "utf8"); - if (Buffer.byteLength(bytes, "utf8") > DISCOVERED_NATIVE_MAX_FILE_BYTES) return []; + if (Buffer.byteLength(bytes, "utf8") > DISCOVERED_NATIVE_MAX_FILE_BYTES) return empty; const data = JSON.parse(bytes) as { version?: unknown; models?: unknown }; - if (data.version !== 1 || !Array.isArray(data.models) || data.models.length > DISCOVERED_NATIVE_MAX_ROWS) return []; + if (data.version !== 1 || !Array.isArray(data.models) || data.models.length > DISCOVERED_NATIVE_MAX_ROWS) return empty; const valid = new Map(); for (const entry of data.models) { if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; @@ -82,8 +94,8 @@ function readModels(path: string, now: number): DiscoveredNativeModel[] { valid.set(entry.slug, { slug: entry.slug, row, firstSeenAt: entry.firstSeenAt, lastSeenAt: entry.lastSeenAt, clientVersion: entry.clientVersion }); } - return boundedModels([...valid.values()], now); - } catch { return []; } + return { models: boundedModels([...valid.values()], now), storedCount: data.models.length }; + } catch { return empty; } } function publish(entries: DiscoveredNativeModel[]): void { @@ -114,21 +126,31 @@ export function recordDiscoveredNativeModels(rows: unknown, clientVersion: strin if (!Number.isSafeInteger(now) || now < 0 || clientVersion.length > 64) return; const dir = getConfigDir(); const path = join(dir, FILE_NAME); - if (loadedPath !== path) loadDiscoveredNativeModels(now); - const merged = new Map(models.map(entry => [entry.slug, entry])); + // Re-read the store rather than trusting this process's snapshot: another OpenCodex process + // sharing the home may have recorded a model since, and merging onto a stale copy would drop + // it at the replace below. The write is atomic, not locked, so only a same-instant race remains. + const { models: onDisk, storedCount } = readModelsWithCount(path, now); + const merged = new Map(loadedPath === path ? models.map(entry => [entry.slug, entry]) : []); + for (const entry of onDisk) { + const known = merged.get(entry.slug); + if (!known || entry.lastSeenAt >= known.lastSeenAt) merged.set(entry.slug, entry); + } + loadedPath = path; for (const row of validateDiscoveredNativeRows(rows)) { const slug = row.slug as string; const previous = merged.get(slug); if (previous && previous.lastSeenAt > now) continue; + if (previous && JSON.stringify(previous.row) === JSON.stringify(row) + && now - previous.lastSeenAt < DISCOVERED_NATIVE_RENEW_INTERVAL_MS) continue; merged.set(slug, { slug, row, firstSeenAt: previous?.firstSeenAt ?? now, lastSeenAt: now, clientVersion }); } const next = boundedModels([...merged.values()], now); const serialized = JSON.stringify({ version: 1, models: next }); if (Buffer.byteLength(serialized, "utf8") > DISCOVERED_NATIVE_MAX_FILE_BYTES) return; - const hadModels = models.length > 0; publish(next); - // Avoid filesystem work for unchanged empty rosters (including existing entitlement fixtures). - if (next.length === 0 && !hadModels && !existsSync(path)) return; + // Nothing new to say: skip the write (and, for empty rosters, never create the file). + if (storedCount === onDisk.length && serialized === JSON.stringify({ version: 1, models: onDisk })) return; + if (next.length === 0 && !existsSync(path)) return; assertNotRealHomeUnderTest(dir); mkdirSync(dir, { recursive: true, mode: 0o700 }); atomicWriteFile(path, serialized + "\n"); diff --git a/src/codex/model-entitlements.ts b/src/codex/model-entitlements.ts index 2d294e0f044..2a89cb50d66 100644 --- a/src/codex/model-entitlements.ts +++ b/src/codex/model-entitlements.ts @@ -808,8 +808,14 @@ export const CODEX_ROSTER_DISCOVERY_CLIENT_VERSION = "99.0.0"; export type CodexNativeRosterDiscoveryOutcome = "recorded" | "not-modified" | "unavailable"; -/** Last ETag per credential identity, so an unchanged roster costs a 304 when upstream honours it. */ -const discoveryEtags = new Map(); +/** + * Last ETag per credential identity, so an unchanged roster costs a 304 when upstream honours it. + * A 304 carries no rows and so cannot renew a discovery's last-seen time; a model visible only + * under the discovery version would then expire after its retention while still being served. + * The ETag is therefore used only within a day of the full fetch that earned it. + */ +const discoveryEtags = new Map(); +const DISCOVERY_ETAG_MAX_AGE_MS = 24 * 60 * 60 * 1000; /** * Background discovery of native rows this build does not pin, independent of entitlement. @@ -861,8 +867,9 @@ async function fetchDiscoveryRoster( try { const headers = new Headers({ Authorization: `Bearer ${credential.accessToken}`, Accept: "application/json" }); if (credential.chatgptAccountId) headers.set("ChatGPT-Account-Id", credential.chatgptAccountId); - const etag = discoveryEtags.get(credential.credentialIdentity); - if (etag) headers.set("If-None-Match", etag); + const now = options.now ?? Date.now(); + const cached = discoveryEtags.get(credential.credentialIdentity); + if (cached && now - cached.fetchedAt < DISCOVERY_ETAG_MAX_AGE_MS) headers.set("If-None-Match", cached.etag); const response = await fetcher(codexModelsUrl(CODEX_ROSTER_DISCOVERY_CLIENT_VERSION), { headers, redirect: "error", @@ -878,9 +885,9 @@ async function fetchDiscoveryRoster( if (!body.displaySafe || body.truncated) return "unavailable"; const parsed = parseAccountModels(body.text); if (parsed === null || parsed.models.size === 0) return "unavailable"; - recordDiscoveredNativeModels(parsed.discoveredRows, CODEX_ROSTER_DISCOVERY_CLIENT_VERSION, options.now ?? Date.now()); + recordDiscoveredNativeModels(parsed.discoveredRows, CODEX_ROSTER_DISCOVERY_CLIENT_VERSION, now); const nextEtag = response.headers.get("etag"); - if (nextEtag && nextEtag.length <= 256) discoveryEtags.set(credential.credentialIdentity, nextEtag); + if (nextEtag && nextEtag.length <= 256) discoveryEtags.set(credential.credentialIdentity, { etag: nextEtag, fetchedAt: now }); else discoveryEtags.delete(credential.credentialIdentity); if (discoveryEtags.size > 64) discoveryEtags.delete(discoveryEtags.keys().next().value!); return "recorded"; diff --git a/structure/catalog.md b/structure/catalog.md index db269a9215b..2ce2a2f73fa 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -147,7 +147,7 @@ Configured natives widen the bare list without a release: eligible bare `gpt-*` Authenticated discovery also widens the set: after a successful nonempty roster fetch, `src/codex/model-entitlements.ts` passes full eligible rows to `src/codex/catalog/discovered-natives.ts`. Rows require a visible, API-supported bare `gpt-*` slug, display name and sane reasoning-level array; built-in, retired, reserve, malformed and oversized rows are rejected. Existing discoveries and configured natives remain eligible for refresh, so an already-supported slug does not freeze its metadata or keep borrowing a template. -The store keeps version-1 `discovered-native-models.json` under the resolved OpenCodex home using the private atomic writer (0600 on POSIX). It merges by slug with first/last observation times and client version, expires entries unseen for 14 days, and bounds rows to 32, each to 256 KiB (a live row carries its instructions twice; GPT-6.1 Sol's was 87 KB), plus a file-size bound. Missing/corrupt files read as empty; persistence failures never fail entitlement. Config activation loads the file through derived registries; observations register immediately. A process-local monotonic generation changes for metadata or membership changes, independently of timestamp refreshes. +The store keeps version-1 `discovered-native-models.json` under the resolved OpenCodex home using the private atomic writer (0600 on POSIX). It merges by slug with first/last observation times and client version, expires entries unseen for 14 days, and bounds rows to 32, each to 256 KiB (a live row carries its instructions twice; GPT-6.1 Sol's was 87 KB), plus a file-size bound. Each record re-reads the file and merges onto it, so another process's discovery survives (the replace is atomic, not locked), and an unchanged row renews its last-seen time on disk at most hourly so request-time roster fetches rarely write. The discovery ETag is sent only within 24 hours of the full fetch that earned it, because a 304 cannot renew retention. Missing/corrupt files read as empty; persistence failures never fail entitlement. Config activation loads the file through derived registries; observations register immediately. A process-local monotonic generation changes for metadata or membership changes, independently of timestamp refreshes. The entitlement roster asks under the installed client version, and upstream's rollout gate can hide a new model from it regardless of the row's `minimal_client_version` (GPT-6.1 Sol reported 0.153.0 but was served only from 0.159.0 while the installed Codex was 0.158). So each scheduler tick also runs `discoverCodexNativeRoster` in `src/codex/model-entitlements.ts`, which asks as `CODEX_ROSTER_DISCOVERY_CLIENT_VERSION` with `If-None-Match`, feeds only the discovery store, and never writes or satisfies the entitlement cache. diff --git a/structure/config.md b/structure/config.md index a1b3b9d064f..0a3e313ec3a 100644 --- a/structure/config.md +++ b/structure/config.md @@ -583,7 +583,7 @@ being treated as a text model by one and an image target by the other. ## Catalog auto-refresh -`catalogAutoRefresh` on `src/types/config.ts` stores an optional `enabled` / `intervalMinutes` section that defaults on at a 60-minute cadence: an absent section or absent `enabled` enables refresh, while explicit false or `intervalMinutes: 0` disables it. A malformed section is dropped by the load schema and therefore uses the default. `src/config/feature-flags.ts` resolves the cadence; an explicit `intervalMinutes: 0` keeps the unref'd timer idle, and any other value is clamped up to 15 minutes because upstream `/models` caches have not moved below that and a shorter tick only multiplies rate-limit exposure. `src/codex/catalog-auto-refresh.ts` owns the unref'd interval and a single unref'd three-minute startup tick that `src/server/background-lifecycle.ts` starts beside the quota reset poller; stopping cancels both timers, and adopting a new cadence re-arms only the interval; a tick that is enabled and non-dormant drives the same catalog-only converge funnel management mutations drive. Each tick arms its independently loaded config snapshot as a detached baseline before provider work, so the discovery save rebases every field — the listener binding and sections absent from the snapshot included — against the disk state at save time and concurrent hand edits survive the tick — `disabledModels` merges by member, so an overlapping visibility edit survives alongside the discovery additions. Detached saves capture explicit persisted top-level deletion intent before reconciliation, so a changed discovery snapshot cannot erase a current disk tombstone by temporarily restoring its key. A defined value reintroduced on disk removes stale deletion authority; ordinary live-config conflict precedence remains unchanged. The last-outcome record lives in `src/codex/catalog-refresh-status.ts` (when the tick finished, the normalized `CatalogDisposition`, whether the served model set changed, consecutive failures, and `reloadRequired` for running Codex sessions) and carries no provider or account detail. +`catalogAutoRefresh` on `src/types/config.ts` stores an optional `enabled` / `intervalMinutes` section that defaults on at a 60-minute cadence: an absent section or absent `enabled` enables refresh, while explicit false or `intervalMinutes: 0` disables it. The default applies only where `shouldSyncCodexOnStart` holds (this proxy manages the local Codex client); with the integration off, on a hub or on a sibling, an absent section keeps the old opt-in meaning and only an explicit `enabled: true` converges, without reading Codex sources. A malformed section is dropped by the load schema and therefore uses the default. `src/config/feature-flags.ts` resolves the cadence; an explicit `intervalMinutes: 0` keeps the unref'd timer idle, and any other value is clamped up to 15 minutes because upstream `/models` caches have not moved below that and a shorter tick only multiplies rate-limit exposure. `src/codex/catalog-auto-refresh.ts` owns the unref'd interval and a single unref'd three-minute startup tick that `src/server/background-lifecycle.ts` starts beside the quota reset poller; stopping cancels both timers, and adopting a new cadence re-arms only the interval; a tick that is enabled and non-dormant drives the same catalog-only converge funnel management mutations drive. Each tick arms its independently loaded config snapshot as a detached baseline before provider work, so the discovery save rebases every field — the listener binding and sections absent from the snapshot included — against the disk state at save time and concurrent hand edits survive the tick — `disabledModels` merges by member, so an overlapping visibility edit survives alongside the discovery additions. Detached saves capture explicit persisted top-level deletion intent before reconciliation, so a changed discovery snapshot cannot erase a current disk tombstone by temporarily restoring its key. A defined value reintroduced on disk removes stale deletion authority; ordinary live-config conflict precedence remains unchanged. The last-outcome record lives in `src/codex/catalog-refresh-status.ts` (when the tick finished, the normalized `CatalogDisposition`, whether the served model set changed, consecutive failures, and `reloadRequired` for running Codex sessions) and carries no provider or account detail. ## Aggregate request metrics export diff --git a/tests/codex-integration/catalog-auto-refresh-scheduler.test.ts b/tests/codex-integration/catalog-auto-refresh-scheduler.test.ts index 973fb23a430..5eccb998edb 100644 --- a/tests/codex-integration/catalog-auto-refresh-scheduler.test.ts +++ b/tests/codex-integration/catalog-auto-refresh-scheduler.test.ts @@ -827,3 +827,35 @@ describe("catalog auto-refresh drift heal", () => { expect(selectDriftHealCatalogPath(journalPath, defaultPath, path => join(openCodexHome, path))).toBeNull(); }); }); + +describe("catalog auto-refresh without a managed Codex client", () => { + function writeIntegrationOffConfig(catalogAutoRefresh?: unknown): void { + const config = { + ...getDefaultConfig(), + defaultProvider: "xai", + providers: { xai: { adapter: "openai-responses", baseUrl: "https://api.x.ai/v1" } }, + clientIntegrations: { codex: false }, + ...(catalogAutoRefresh === undefined ? {} : { catalogAutoRefresh }), + }; + writeFileSync(getConfigPath(), JSON.stringify(config), "utf8"); + } + + test("an absent section stays dormant: no Codex sources and no converge", async () => { + writeIntegrationOffConfig(); + await runCatalogAutoRefreshTickForTests(); + expect(convergeFactoryCalls).toBe(0); + expect(bundled.loadBundledCodexCatalog).not.toHaveBeenCalled(); + expect(entitlements.ensureCodexEntitlementFreshness).not.toHaveBeenCalled(); + expect(entitlements.discoverCodexNativeRoster).not.toHaveBeenCalled(); + expect(catalogAutoRefreshTickCountForTests()).toBe(0); + }); + + test("an explicit enabled:true still converges but never reads Codex sources", async () => { + writeIntegrationOffConfig({ enabled: true, intervalMinutes: 60 }); + await runCatalogAutoRefreshTickForTests(); + expect(convergeFactoryCalls).toBe(1); + expect(bundled.loadBundledCodexCatalog).not.toHaveBeenCalled(); + expect(entitlements.ensureCodexEntitlementFreshness).not.toHaveBeenCalled(); + expect(entitlements.discoverCodexNativeRoster).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/codex-integration/discovered-native-models.test.ts b/tests/codex-integration/discovered-native-models.test.ts index 15c6bddcda2..a390fee7b5c 100644 --- a/tests/codex-integration/discovered-native-models.test.ts +++ b/tests/codex-integration/discovered-native-models.test.ts @@ -7,6 +7,7 @@ import { DISCOVERED_NATIVE_MAX_FILE_BYTES, DISCOVERED_NATIVE_MAX_ROW_BYTES, DISCOVERED_NATIVE_MAX_ROWS, + DISCOVERED_NATIVE_RENEW_INTERVAL_MS, DISCOVERED_NATIVE_RETENTION_MS, discoveredNativeModelsGeneration, loadDiscoveredNativeModels, @@ -281,3 +282,38 @@ describe("discovery-only roster", () => { expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(false); }); }); + +describe("discovery store under concurrency and repeated fetches", () => { + test("a model another process recorded survives this process's next write", () => { + const now = Date.now(); + recordDiscoveredNativeModels([row()], "0.160.0", now); + const other = persisted(); + other.models.push({ slug: "gpt-9-other", row: row("gpt-9-other"), firstSeenAt: now, lastSeenAt: now, clientVersion: "0.160.0" }); + writeFileSync(path, JSON.stringify(other)); + recordDiscoveredNativeModels([row("gpt-9-third")], "0.160.0", now + 1); + expect(persisted().models.map(model => model.slug).sort()).toEqual(["gpt-9-other", FUTURE, "gpt-9-third"]); + }); + + test("an unchanged row renews on disk at most hourly", () => { + const now = Date.now() - 3 * DISCOVERED_NATIVE_RENEW_INTERVAL_MS; + recordDiscoveredNativeModels([row()], "0.160.0", now); + recordDiscoveredNativeModels([row()], "0.160.0", now + 60_000); + expect(persisted().models[0]!.lastSeenAt).toBe(now); + recordDiscoveredNativeModels([row()], "0.160.0", now + DISCOVERED_NATIVE_RENEW_INTERVAL_MS); + expect(persisted().models[0]!.lastSeenAt).toBe(now + DISCOVERED_NATIVE_RENEW_INTERVAL_MS); + }); + + test("a day-old ETag is not sent, so a full fetch renews what a 304 cannot", async () => { + resetCodexNativeRosterDiscoveryForTests(); + const credential = { accountId: "future-test", accessToken: "fixture-token", chatgptAccountId: "", credentialIdentity: "fixture-generation" }; + const seen: Array = []; + const fetcher = ((_url: string | URL, init?: RequestInit) => { + seen.push(new Headers(init?.headers).get("if-none-match")); + return Promise.resolve(Response.json({ models: [row()] }, { headers: { etag: "\"roster-3\"" } })); + }) as typeof fetch; + const start = Date.now() - 2 * 24 * 60 * 60 * 1000; + await discoverCodexNativeRoster({}, { credentials: [credential], fetcher, now: start }); + await discoverCodexNativeRoster({}, { credentials: [credential], fetcher, now: start + 25 * 60 * 60 * 1000 }); + expect(seen).toEqual([null, null]); + }); +}); From 396d876af2d12cf8de60fc1e9c9de07fe6517592 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 30 Sep 2026 12:09:39 +0900 Subject: [PATCH 3/3] fix(catalog): guard late discovery publication and document the conditional default CodeRabbit follow-up. The discovery step now gets an abort signal tied to its source window and a publication guard tied to the scheduler generation, so a timed-out or stopped tick cannot record rows. The English server reference states that the default applies only to managed Codex installs, and the entitlement-isolation test now proves a cache miss under the discovery version instead of calling the status reader with the wrong argument. --- .../docs/reference/configuration/server.md | 7 +++++-- src/codex/catalog-auto-refresh-sources.ts | 16 ++++++++++----- src/codex/model-entitlements.ts | 13 ++++++++++-- .../discovered-native-models.test.ts | 20 +++++++++++++++++-- 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 48bef6333aa..50c7369ab07 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -426,8 +426,11 @@ There is no dashboard control for this yet; set it in `config.json` or with ## Catalog auto-refresh (`catalogAutoRefresh`) -Enabled by default, even when the section or `enabled` is absent. The proxy refreshes its -model catalog every 60 minutes, with one initial refresh about three minutes after startup. +Enabled by default when OpenCodex manages your local Codex client: the section or `enabled` +may be absent. The proxy refreshes its model catalog every 60 minutes, with one initial refresh +about three minutes after startup. When the Codex integration is turned off, or the instance +runs as a hub or beside another live proxy, background refresh runs only with an explicit +`"enabled": true`, and it never reads Codex sources. ```json { diff --git a/src/codex/catalog-auto-refresh-sources.ts b/src/codex/catalog-auto-refresh-sources.ts index e45e3a66d2f..d23a92c5d8c 100644 --- a/src/codex/catalog-auto-refresh-sources.ts +++ b/src/codex/catalog-auto-refresh-sources.ts @@ -3,13 +3,17 @@ import type { OcxConfig } from "../types"; const SOURCE_WAIT_MS = 15_000; const PROCESS_WAIT_MS = 1_000; -/** A source failure or slow roster must not prevent publication from existing evidence. */ -async function bestEffortSource(step: (active: () => boolean) => Promise): Promise { +/** + * A source failure or slow roster must not prevent publication from existing evidence. The signal + * aborts when the wait bound passes or the step ends, so a late source cannot publish afterwards. + */ +async function bestEffortSource(step: (active: () => boolean, signal: AbortSignal) => Promise): Promise { let timer: ReturnType | undefined; let active = true; + const controller = new AbortController(); try { await Promise.race([ - Promise.resolve().then(() => step(() => active)), + Promise.resolve().then(() => step(() => active, controller.signal)), new Promise(resolve => { timer = setTimeout(resolve, SOURCE_WAIT_MS); timer.unref?.(); @@ -19,6 +23,7 @@ async function bestEffortSource(step: (active: () => boolean) => Promise { + await bestEffortSource(async (active, signal) => { const { discoverCodexNativeRoster } = await import("./model-entitlements"); - if (active() && current()) await discoverCodexNativeRoster(config); + // A stopped scheduler generation aborts too, so a stale tick cannot record discoveries. + if (active() && current()) await discoverCodexNativeRoster(config, { signal, isCurrent: () => active() && current() }); }); } diff --git a/src/codex/model-entitlements.ts b/src/codex/model-entitlements.ts index 2a89cb50d66..fd0c99af41e 100644 --- a/src/codex/model-entitlements.ts +++ b/src/codex/model-entitlements.ts @@ -808,6 +808,14 @@ export const CODEX_ROSTER_DISCOVERY_CLIENT_VERSION = "99.0.0"; export type CodexNativeRosterDiscoveryOutcome = "recorded" | "not-modified" | "unavailable"; +export interface CodexNativeRosterDiscoveryOptions extends Pick< + CodexModelEntitlementResolveOptions, + "credentials" | "fetcher" | "signal" | "now" | "nativeMainRefreshDependencies" +> { + /** Checked right before publication; false means the caller's scheduler generation is gone. */ + readonly isCurrent?: () => boolean; +} + /** * Last ETag per credential identity, so an unchanged roster costs a 304 when upstream honours it. * A 304 carries no rows and so cannot renew a discovery's last-seen time; a model visible only @@ -828,7 +836,7 @@ const DISCOVERY_ETAG_MAX_AGE_MS = 24 * 60 * 60 * 1000; */ export async function discoverCodexNativeRoster( config: Pick, - options: Pick = {}, + options: CodexNativeRosterDiscoveryOptions = {}, ): Promise { const fetcher = options.fetcher ?? fetch; const run = async (excluded: ReadonlySet, releaseMainLease?: () => void) => { @@ -858,7 +866,7 @@ export async function discoverCodexNativeRoster( async function fetchDiscoveryRoster( credential: CodexModelEntitlementCredentialSnapshot, fetcher: typeof fetch, - options: Pick, + options: Pick, ): Promise { const controller = new AbortController(); const abort = () => controller.abort(options.signal?.reason); @@ -885,6 +893,7 @@ async function fetchDiscoveryRoster( if (!body.displaySafe || body.truncated) return "unavailable"; const parsed = parseAccountModels(body.text); if (parsed === null || parsed.models.size === 0) return "unavailable"; + if (controller.signal.aborted || options.isCurrent?.() === false) return "unavailable"; recordDiscoveredNativeModels(parsed.discoveredRows, CODEX_ROSTER_DISCOVERY_CLIENT_VERSION, now); const nextEtag = response.headers.get("etag"); if (nextEtag && nextEtag.length <= 256) discoveryEtags.set(credential.credentialIdentity, { etag: nextEtag, fetchedAt: now }); diff --git a/tests/codex-integration/discovered-native-models.test.ts b/tests/codex-integration/discovered-native-models.test.ts index a390fee7b5c..3f2cc4c42fe 100644 --- a/tests/codex-integration/discovered-native-models.test.ts +++ b/tests/codex-integration/discovered-native-models.test.ts @@ -36,7 +36,6 @@ import { refreshConfigDerivedRegistries } from "../../src/config/derived-registr import { CODEX_ROSTER_DISCOVERY_CLIENT_VERSION, discoverCodexNativeRoster, - getCodexModelEntitlementStatus, resetCodexModelEntitlementCacheForTests, resetCodexNativeRosterDiscoveryForTests, resolveCodexModelEntitlements, @@ -248,7 +247,24 @@ describe("discovery-only roster", () => { expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(true); expect(persisted().models.map(model => model.slug)).toEqual([FUTURE]); // A discovery roster must never answer an entitlement question for another client version. - expect(getCodexModelEntitlementStatus("future-test")).toEqual({ status: "unavailable" }); + // Resolving under the discovery version must still fetch, proving no entry was cached. + let entitlementFetches = 0; + await resolveCodexModelEntitlements({}, { + clientVersion: CODEX_ROSTER_DISCOVERY_CLIENT_VERSION, + credentials: [credential], + fetcher: (() => { + entitlementFetches += 1; + return Promise.resolve(Response.json({ models: [row()] })); + }) as typeof fetch, + }); + expect(entitlementFetches).toBe(1); + }); + + test("a discovery whose scheduler generation ended does not publish", async () => { + resetCodexNativeRosterDiscoveryForTests(); + const fetcher = (() => Promise.resolve(Response.json({ models: [row()] }))) as typeof fetch; + expect(await discoverCodexNativeRoster({}, { credentials: [credential], fetcher, isCurrent: () => false })).toBe("unavailable"); + expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(false); }); test("revalidates with the last ETag and treats 304 as unchanged", async () => {