Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs-site/src/content/docs/reference/configuration/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,40 @@ 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 <bytes>`.

## Catalog auto-refresh (`catalogAutoRefresh`)

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
{
"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
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
71 changes: 71 additions & 0 deletions src/codex/catalog-auto-refresh-sources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
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. 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<unknown>): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined;
let active = true;
const controller = new AbortController();
try {
await Promise.race([
Promise.resolve().then(() => step(() => active, controller.signal)),
new Promise<void>(resolve => {
timer = setTimeout(resolve, SOURCE_WAIT_MS);
timer.unref?.();
}),
]);
} catch {
// Convergence can still use the last confirmed source snapshots.
} finally {
active = false;
controller.abort();
clearTimeout(timer);
}
}

/** Settle source observations before admission captures them; gather itself never probes. */
export async function refreshCatalogAutoRefreshSources(
config: OcxConfig,
current: () => boolean,
): Promise<void> {
// 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, signal) => {
const { discoverCodexNativeRoster } = await import("./model-entitlements");
// A stopped scheduler generation aborts too, so a stale tick cannot record discoveries.
if (active() && current()) await discoverCodexNativeRoster(config, { signal, isCurrent: () => active() && current() });
});
}
Comment thread
lidge-jun marked this conversation as resolved.

/** Observation only: running sessions own static copies and require an operator restart. */
export async function catalogAutoRefreshReloadRequired(changed = true): Promise<boolean> {
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;
}
}
48 changes: 42 additions & 6 deletions src/codex/catalog-auto-refresh.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -37,6 +38,7 @@ const MAX_JOURNAL_BYTES = 1024 * 1024;
const MAX_CATALOG_BYTES = 64 * 1024 * 1024;

let timer: ReturnType<typeof setInterval> | null = null;
let initialTimer: ReturnType<typeof setTimeout> | 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;
Expand Down Expand Up @@ -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<void> {
Expand Down Expand Up @@ -199,6 +203,13 @@ async function tick(): Promise<void> {
// 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;
Expand All @@ -218,6 +229,12 @@ async function tick(): Promise<void> {
} 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;
// 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"),
import("./catalog-admission"),
Expand All @@ -231,11 +248,16 @@ async function tick(): Promise<void> {
// 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.
Expand All @@ -253,8 +275,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,
Expand All @@ -266,6 +296,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;
Expand All @@ -290,7 +324,9 @@ export function isCatalogAutoRefreshRunning(): boolean {
* microtasks before this settles.
*/
export async function syncCatalogAutoRefreshCadence(): Promise<void> {
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;
Expand Down
4 changes: 4 additions & 0 deletions src/codex/catalog-refresh-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -158,6 +159,7 @@ function freezeCatalogAutoRefreshOutcome(
at: outcome.at,
disposition: freezeCatalogDisposition(outcome.disposition),
changed: outcome.changed,
reloadRequired: outcome.reloadRequired,
consecutiveFailures: outcome.consecutiveFailures,
});
}
Expand All @@ -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;
Expand All @@ -181,6 +184,7 @@ export function recordCatalogAutoRefreshOutcome(
at: Date.now(),
disposition: normalized,
changed: changed === true,
reloadRequired: reloadRequired === true,
consecutiveFailures,
});
lastAutoRefreshOutcome = outcome;
Expand Down
Loading
Loading