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
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/guides/model-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ Routing and catalog visibility are separate controls:
The policy applies before publishing newly discovered models through `/v1/models`, the dashboard,
client configuration exports, or a Codex catalog sync (including service startup).
Enabling an arrival manually keeps it enabled on later refreshes and exports.
If the running provider configuration differs from disk, model lists still apply the policy
using a temporary projection; they leave the running configuration and saved choices untouched.
Use `ocx models new-policy off` globally, add `--provider <name>` for an override, and inspect
`ocx models new-arrivals [--json]`. Failed/degraded fetches never change the baseline. Providers
with a non-empty `selectedModels` (including preset mode) are already curated, so this policy is
Expand Down
11 changes: 11 additions & 0 deletions src/providers/new-model-policy-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export function finalizeModelDiscovery(
models: Iterable<{ provider: string; id: string; custom?: boolean }>,
authoritativeProviders: string[],
revisions: Map<string, string>,
/** Read consumers may publish detached policy state; writers require persisted decisions. */
publishProjection?: (projection: OcxConfig) => void,
): boolean {
const filePath = fileBackedConfigPath(config);
const rows = [...models];
Expand All @@ -50,6 +52,15 @@ export function finalizeModelDiscovery(
reconcile(config);
return true;
}
if (!baseline.persisted && publishProjection) {
// Drift already existed at admission. Do not add unsaved disables or advance the live
// merge baseline: a later rebase-save must preserve the operator's on-disk choices.
const projection = { ...config, modelDiscovery: structuredClone(config.modelDiscovery),
disabledModels: config.disabledModels === undefined ? undefined : [...config.disabledModels] };
reconcile(projection);
publishProjection(projection);
return true;
}
// A file-loaded instance remains file-backed after inventory drift. The locked
// freshness gate below refuses stale rows without creating unsaved live disables.
try {
Expand Down
2 changes: 1 addition & 1 deletion src/server/management/model-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ export async function listManagementModelRows(
*/
const models = options.models === undefined
? (await Promise.all([
fetchAllModels(config, options.providerContentRevisions),
fetchAllModels(config, options.providerContentRevisions, projection => { config = projection; }),
ensureCodexEntitlementFreshness(config, {
waitMs: options.entitlementWaitMs ?? 3_000,
}),
Expand Down
11 changes: 10 additions & 1 deletion src/server/management/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ export async function fetchAllModels(
config: OcxConfig,
/** Filled with each provider's content revision as of the moment its rows were chosen. */
providerContentRevisions?: Map<string, string>,
/** Management renders disabled rows using this detached state after inventory drift. */
publishProjection?: (projection: OcxConfig) => void,
): Promise<CatalogModel[]> {
const { gatherRoutedModels } = await import("../../codex/catalog");
const baseline = captureInitialSelectionBaseline(config);
Expand All @@ -280,10 +282,17 @@ export async function fetchAllModels(
});
finalizeInitialModelSelection(config, baseline, uniqueCatalogModelsForPublicList(models),
outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider));
let publicationConfig = config;
if (!finalizeModelDiscovery(config, discoveryBaseline, models,
outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider), revisions)) {
outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider), revisions,
projection => { publicationConfig = projection; publishProjection?.(projection); })) {
throw new CatalogGatherBusyError();
}
if (publicationConfig !== config && !publishProjection) {
// Other consumers keep their live config, so only return rows visible under the projection.
const { filterCatalogVisibleModels } = await import("../../codex/catalog");
return filterCatalogVisibleModels(models, publicationConfig);
Comment on lines +291 to +294

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve unfiltered rosters for configuration endpoints

When a file-backed configuration already differs from disk, this filters the shared fetch result using every disabledModels entry and selectedModels allowlist, not only the newly projected arrivals. However, /api/selected-models explicitly uses fetchAllModels to construct its full unfiltered available roster, and the model-preset routes use it to calculate complete candidate sets. During persistent live/disk drift, those endpoints therefore omit models the user needs to re-enable and can apply a preset from a truncated roster; pass the detached config separately to visibility consumers while retaining raw rows for configuration callers.

AGENTS.md reference: src/AGENTS.md:L10-L10

Useful? React with 👍 / 👎.

}
return models;
}

Expand Down
2 changes: 1 addition & 1 deletion structure/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ this rule, and uninstall keeps the [manifest validation and residual reporting c

Cache invalidation reports an unchanged derived cache separately from a failed rewrite. `ocx sync-cache` treats identical bytes as a successful no-op, preserving the cache mtime and avoiding a needless app-server restart; malformed catalogs and write failures remain errors.

`src/codex/catalog/model-visibility.ts` excludes disabled providers, including custom rows; `src/codex/catalog/routed-gather.ts` does not inherit their configuration into custom rows. HTTP discovery, startup/explicit sync (`src/codex/catalog/retained-sync.ts`), and client exports (`src/server/management/model-rows.ts`) apply new-model policy before publication. `src/providers/new-model-policy-runtime.ts` commits authoritative arrivals and automatic disables together under the config mutation lock, without rewriting an unchanged roster. It rechecks provider inventory and cache revisions and refuses publication on failed persistence. Retained sync reconciles after catalog evidence revalidation, preserving K-to-C lock order. Exports recapture their snapshot after committing discovery; superseded gathers only project policy onto a detached copy and retain no preview. Supplied rosters and read-only previews do not reconcile. Synthetic configurations only project their own state. File-backed configs retain their load provenance when their inventory diverges from disk; the locked fresh-config comparison refuses stale publication without mutating live policy or its merge baseline. A reload or explicit adoption is required before that inventory can publish again. Discovery absorbs arrivals/reappearances without advancing removal grace; convergence retains removal accounting. Bootstrap, custom rows, explicit selections, and degraded discovery retain the pure policy rules. Coverage: `tests/server/server-new-model-policy-arrival.test.ts` (HTTP), `tests/codex-integration/codex-sync-new-model-policy.test.ts` (startup/sync), `tests/server/model-export-new-model-policy.test.ts` (exports/previews), and `tests/providers/new-model-policy-runtime.test.ts` (persistence/concurrency).
`src/codex/catalog/model-visibility.ts` excludes disabled providers, including custom rows; `src/codex/catalog/routed-gather.ts` does not inherit their configuration into custom rows. HTTP discovery, startup/explicit sync (`src/codex/catalog/retained-sync.ts`), and client exports (`src/server/management/model-rows.ts`) apply new-model policy before publication. `src/providers/new-model-policy-runtime.ts` commits authoritative arrivals and automatic disables together under the config mutation lock, without rewriting an unchanged roster. It rechecks provider inventory and cache revisions and refuses publication on failed persistence. Retained sync reconciles after catalog evidence revalidation, preserving K-to-C lock order. Exports recapture their snapshot after committing discovery; superseded gathers only project policy onto a detached copy and retain no preview. Supplied rosters and read-only previews do not reconcile. Synthetic configurations only project their own state. File-backed configs retain their load provenance when their inventory diverges from disk. Read callers can opt into a detached policy projection when drift predates discovery: management renders its disabled rows, while other shared-fetch consumers receive only its visible rows. The projection copies discovery state and disables, leaving live config, disk, and the live merge baseline untouched. Writer callers still refuse stale publication; reload or explicit adoption is required before retained sync can commit. Inventory changes during discovery, superseded cache revisions, changed config homes, and failed persistence still refuse publication. Discovery absorbs arrivals/reappearances without advancing removal grace; convergence retains removal accounting. Bootstrap, custom rows, explicit selections, and degraded discovery retain the pure policy rules. Coverage: `tests/server/server-new-model-policy-arrival.test.ts` (HTTP), `tests/codex-integration/codex-sync-new-model-policy.test.ts` (startup/sync), `tests/server/model-export-new-model-policy.test.ts` (exports/previews), and `tests/providers/new-model-policy-runtime.test.ts` (persistence/concurrency), and `tests/codex-integration/model-visibility-management-api.test.ts` (drifted read projection).

On the default `opencodex-catalog.json` path, sync deliberately uses two catalog sources: Codex's
bundled catalog supplies a current native entry template, while the actual on-disk catalog supplies
Expand Down
2 changes: 1 addition & 1 deletion structure/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ and fresh-location examples. Diagnostics do not introduce a fallback or alter fi
`src/config/persisted-mutation.ts` owns schema-valid on-disk mutations under the shared lock.
It rechecks the file before committing, retries a changed snapshot up to three times, and
returns unavailable for missing, invalid, or persistently conflicting config. Its one-shot
test seam and the mutation types remain re-exported through `src/config.ts`. Successful, repaired, and salvaged file loads record their source path in the private WeakMap owned by `src/config/rebase-provenance.ts`; defaults and synthetic objects do not acquire it. This metadata is never serialized. Inventory drift cannot turn a file-backed instance into a synthetic discovery caller, and changing the resolved config home refuses publication.
test seam and the mutation types remain re-exported through `src/config.ts`. Successful, repaired, and salvaged file loads record their source path in the private WeakMap owned by `src/config/rebase-provenance.ts`; defaults and synthetic objects do not acquire it. This metadata is never serialized. Inventory drift cannot turn a file-backed instance into a synthetic discovery writer. Read publication may use the detached policy projection described by the [catalog contract](catalog.md#shared-catalog), without changing the live merge baseline; changing the resolved config home refuses publication.

`src/config/paths.ts` is the single owner of `OPENCODEX_HOME` expansion and resolution. It exposes
the config directory and `config.json` path and retains the existing cache rule: a relative home is
Expand Down
2 changes: 1 addition & 1 deletion structure/gui-and-management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ per-request first-party callback reads that live object; a failed write leaves i
| Windows tray | `GET/POST /api/windows-tray` controls an owned, per-user HKCU login tray. The tray delegates fixed actions to the CLI and is never a proxy supervisor or restart-protection signal. |
| Updates | `GET /api/update/check`, `POST /api/update/run`, and `GET /api/update/status` own dashboard self-update state. A launched worker PID is persisted in `update-job.json`; dead PIDs recover immediately, while legacy active records without a PID recover only after ten minutes. Live PIDs remain exclusive regardless of record age. `GET /api/update/check` and `POST /api/update/run` await one per-channel asynchronous registry lookup and write successful results through to the package cache; run passes that result to the job starter. `GET /api/update/badge` only reads the cache and reports unknown after 40 hours, on missing cache, or on channel mismatch. The badge links to the update surface rather than gating other actions. `GET /api/update/badge?surface=desktop&session=<id>` projects only that process-local Tauri session; missing or expired state is unknown and never falls back to the package cache. `POST /api/update/desktop-snapshot` is a 1 KiB bounded display-state mutation with no install permission. It accepts the existing admin-token principal or a dedicated single-use, ten-second snapshot capability bound to nonce, method, path, PID, port and the SHA-256 digest of the exact body bytes; that capability cannot authorize another route. |
| Providers | Create/update/delete ordinary provider configs and enrich registry metadata. A `POST /api/providers` overwrite of an existing name keeps the eight operator compatibility settings (`PROVIDER_COMPAT_CARRY_FIELDS` in `src/server/management/provider-overwrite-carry.ts`) and the stored key pool only while the destination (adapter, normalized base URL, auth mode when named) is unchanged. It keeps omitted `hideRawReasoning` on every overwrite of the same name, including destination moves, because it is a display policy; a submitted boolean wins. It never merges the rest of the old row. `PATCH` is a field mask and keeps every field it does not name; `hideRawReasoning` accepts a boolean or `null` to clear it. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. |
| Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. Ordinary model reads also apply the [new-arrival catalog contract](catalog.md#shared-catalog) before returning models. With a matching persisted configuration, discovery records the baseline, NEW badges, and automatic disables together; HTTP listing does not wait for a separate Codex sync. |
| Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. Ordinary model reads also apply the [new-arrival catalog contract](catalog.md#shared-catalog) before returning models. With a matching persisted configuration, discovery records the baseline, NEW badges, and automatic disables together; HTTP listing does not wait for a separate Codex sync. Inventory already drifted from disk uses a detached policy projection: arrivals remain disabled in management rows without changing live config or disk. |
| OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `PUT /api/oauth/accounts/pause`, and `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, pause/resume a generic OAuth account, and remove one. Paused generic OAuth accounts are excluded from request selection, 429 failover, and proactive token refresh; pausing an active account selects the next usable account when available, and resuming an account restores an active selection if the current one remains paused. With no unpaused account, requests return 403 rather than a login error. Kiro account-list rows include the current automatic-selection projection and closed exclusion reason; an active singleton may still send. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Meta Muse login start and manual-code continuation require the server-resolved `gui-session` principal before credential acquisition or code submission (including reauth); see the [provider contract](providers-and-adapters.md). Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. |
| Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Machine links live in `src/server/management/link-routes.ts`: dashboard sessions reach `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host`, `POST /api/link/apply` and `POST /api/link/join`, meaning a paired session or, on a standalone runtime, the current loopback-issued session on trusted loopback ingress; join also refuses a runtime that is not standalone (`409 standalone_required`) or not listening on its configured port (`409 join_port_mismatch`) before any SSH, then restarts this runtime as a client; `GET /api/link/status` reports `joinAvailable` to GUI-session callers so the dashboard enables the Child role only when a join can succeed; `GET /api/link/status` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. |
| OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`openai-tiers.md`](providers/openai-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. |
Expand Down
2 changes: 1 addition & 1 deletion structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ changing its capability or adapter wire mapping.
The image/video loop bounds each hidden iteration before replay or fulfillment; see
[media iteration retention](transports/inventory.md#media-iteration-retention).

Live model discovery is bounded and registry-driven through `src/providers/model-discovery.ts`. Ordinary discovery records the new-arrival policy before exposing its rows through the shared management fetcher; see the [catalog contract](catalog.md#shared-catalog). Only authoritative provider results advance the baseline, and manual choices survive subsequent refreshes.
Live model discovery is bounded and registry-driven through `src/providers/model-discovery.ts`. Ordinary discovery applies the new-arrival policy before exposing its rows through the shared management fetcher, persisting matching inventory or projecting drifted reads; see the [catalog contract](catalog.md#shared-catalog). Only authoritative provider results advance the baseline, and manual choices survive subsequent refreshes.
Custom providers keep the conventional `${baseUrl}/models` request, normalized by
`providerModelsUrl` the same way `openaiChatCompletionsUrl` normalizes the send path: outer
whitespace and trailing slashes are trimmed and an already-pasted `/models` is not doubled, so a
Expand Down
Loading
Loading