From c7cdebebd517c75a3966564588952a57e65f6d4d Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 00:27:11 +0900 Subject: [PATCH] fix(models): project drifted discovery reads without mutating live policy File-backed inventory drift rejected ordinary model reads with catalog_busy. Identical synthetic inventory succeeds on the same cached roster, ruling out row replacement and cache revision churn in the reproduction. Allow read callers to receive a detached new-arrival policy projection. Management renders disabled arrivals; other shared-fetch consumers receive only visible projected rows. Keep retained-sync and direct stale-writer refusal contracts, inventory/revision checks, and persisted merge baselines intact. Verification: eight authorized files, 80 passed / 0 failed; after adding save-safety coverage, runtime-policy and ratchet files, 24 passed / 0 failed. Typecheck and structure checks pass. Full suite and docs build were excluded by the delegated command restrictions. --- .../src/content/docs/guides/model-routing.md | 2 + src/providers/new-model-policy-runtime.ts | 11 ++++ src/server/management/model-rows.ts | 2 +- src/server/management/shared.ts | 11 +++- structure/catalog.md | 2 +- structure/config.md | 2 +- structure/gui-and-management-api.md | 2 +- structure/providers-and-adapters.md | 2 +- structure/runtime.md | 2 +- .../model-visibility-management-api.test.ts | 53 ++++++++++++++++++- .../new-model-policy-runtime.test.ts | 24 +++++++++ 11 files changed, 105 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/guides/model-routing.md b/docs-site/src/content/docs/guides/model-routing.md index f8e66dee0f3..acb84ddd43e 100644 --- a/docs-site/src/content/docs/guides/model-routing.md +++ b/docs-site/src/content/docs/guides/model-routing.md @@ -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 ` 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 diff --git a/src/providers/new-model-policy-runtime.ts b/src/providers/new-model-policy-runtime.ts index 57e1c86d45a..c9b0f37ca59 100644 --- a/src/providers/new-model-policy-runtime.ts +++ b/src/providers/new-model-policy-runtime.ts @@ -34,6 +34,8 @@ export function finalizeModelDiscovery( models: Iterable<{ provider: string; id: string; custom?: boolean }>, authoritativeProviders: string[], revisions: Map, + /** Read consumers may publish detached policy state; writers require persisted decisions. */ + publishProjection?: (projection: OcxConfig) => void, ): boolean { const filePath = fileBackedConfigPath(config); const rows = [...models]; @@ -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 { diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 971b91fc72a..78cdc0416fe 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -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, }), diff --git a/src/server/management/shared.ts b/src/server/management/shared.ts index 2244d510beb..2e1c88a374a 100644 --- a/src/server/management/shared.ts +++ b/src/server/management/shared.ts @@ -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, + /** Management renders disabled rows using this detached state after inventory drift. */ + publishProjection?: (projection: OcxConfig) => void, ): Promise { const { gatherRoutedModels } = await import("../../codex/catalog"); const baseline = captureInitialSelectionBaseline(config); @@ -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); + } return models; } diff --git a/structure/catalog.md b/structure/catalog.md index f26cad389c5..cf1faa8b4fa 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -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 diff --git a/structure/config.md b/structure/config.md index 41b5a4e4d74..2b92b202384 100644 --- a/structure/config.md +++ b/structure/config.md @@ -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 diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 019e62da729..675755e3af3 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -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=` 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. | diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 3cc742e6821..991a3723faa 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -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 diff --git a/structure/runtime.md b/structure/runtime.md index b9681900ad1..85e0a73cb16 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -28,7 +28,7 @@ Native steering follows [the shared WebSocket contract](transports/streaming-hea Responses admission and finalization are composed through the [core module ownership](transports/responses.md#core-module-ownership). Kiro's optional account-load admission is process-local and request-owned; its slot ends with the response body or cancellation. Other providers retain their admission path. -Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). CLI model lists expose shared catalog estimates as `price`; `models price` retains the saved override in `cost` and adds `effectiveCost`. Explicit zero overrides win; unknown prices remain null, and automatic defaults are never persisted to `modelCosts`. Covered by `tests/cli/cli-models.test.ts` and `tests/cli/cli-models-price.test.ts`. Ordinary model discovery, startup sync, and explicit sync apply the [new-arrival catalog contract](catalog.md#shared-catalog) before publishing rows. The scoped persisted decision also updates the live config merge baseline, so subsequent visibility writes preserve manual re-enablement. A file-loaded inventory that differs from disk refuses publication without adding unsaved live disables; its load provenance follows the [config contract](config.md). +Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). CLI model lists expose shared catalog estimates as `price`; `models price` retains the saved override in `cost` and adds `effectiveCost`. Explicit zero overrides win; unknown prices remain null, and automatic defaults are never persisted to `modelCosts`. Covered by `tests/cli/cli-models.test.ts` and `tests/cli/cli-models-price.test.ts`. Ordinary model discovery, startup sync, and explicit sync apply the [new-arrival catalog contract](catalog.md#shared-catalog) before publishing rows. The scoped persisted decision also updates the live config merge baseline, so subsequent visibility writes preserve manual re-enablement. A file-loaded inventory that already differs from disk can publish HTTP rows through a detached policy projection without adding unsaved live disables; retained catalog writes still refuse stale inventory, and its load provenance follows the [config contract](config.md). OAuth refresh coordination follows the [refresh-lock identity contract](catalog.md#accounts-namespaces-and-pool-rotation): a fresh unreadable lock remains held, and release requires matching descriptor identity. A failed path-identity probe preserves the refresh callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. diff --git a/tests/codex-integration/model-visibility-management-api.test.ts b/tests/codex-integration/model-visibility-management-api.test.ts index e730b144bc8..709779f5722 100644 --- a/tests/codex-integration/model-visibility-management-api.test.ts +++ b/tests/codex-integration/model-visibility-management-api.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { nativeModelRows } from "../../src/codex/catalog"; import { loadConfig, saveConfig } from "../../src/config"; @@ -10,6 +10,8 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; import { ManagementRequest as Request } from "../helpers/management-auth"; import { listManagementModelRows, type ManagementModelRow } from "../../src/server/management/model-rows"; import { routedSlug } from "../../src/providers/slug-codec"; +import { clearModelCache } from "../../src/codex/model-cache"; +import { fetchAllModels } from "../../src/server/management/shared"; const TEST_DIR = join(import.meta.dir, `.tmp-model-visibility-management-${process.pid}`); const previousOpencodexHome = process.env.OPENCODEX_HOME; @@ -488,6 +490,55 @@ test("manual models replace management rows with the same provider/id and deleti ]); }); +test("drifted file-backed model reads project arrival disables without changing live config or disk", async () => { + const provider = "fixture-drift"; + const upstream = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => + Response.json({ data: ["a", "b", "c"].map(id => ({ id })) }), + }); + clearModelCache(provider); + try { + saveConfig({ + port: 0, defaultProvider: provider, + providers: { [provider]: { + adapter: "openai-chat", baseUrl: new URL("/v1", upstream.url).href, + apiKey: "fixture-key", allowPrivateNetwork: true, liveModels: true, + models: ["a", "b"], newModelPolicy: "off", + } }, + disabledModels: [`${provider}/b`], + modelDiscovery: { knownModels: { + [provider]: { ids: ["a", "b"], removed: [], updatedAt: "2026-01-01T00:00:00Z" }, + } }, + }); + const config = loadConfig(); + config.providers[provider]!.models!.push("local-extra"); + const before = structuredClone(config); + const diskBefore = readFileSync(join(TEST_DIR, "config.json"), "utf8"); + // The same inventory is publishable as a synthetic caller: provenance is the difference. + const syntheticRows = await listManagementModelRows(structuredClone(config), { entitlementWaitMs: 0 }); + expect(syntheticRows.find(row => row.namespaced === `${provider}/c`)?.disabled).toBe(true); + + for (let read = 0; read < 2; read++) { + const url = new URL("http://localhost/api/models"); + const response = await handleManagementAPI(new Request(url), url, config); + expect(response?.status).toBe(200); + const rows = await response!.json() as ManagementModelRow[]; + expect(rows.find(row => row.namespaced === `${provider}/a`)?.disabled).toBe(false); + expect(rows.find(row => row.namespaced === `${provider}/b`)?.disabled).toBe(true); + expect(rows.find(row => row.namespaced === `${provider}/c`)).toMatchObject({ disabled: true, fastRowAvailable: false }); + expect(config).toEqual(before); + expect(readFileSync(join(TEST_DIR, "config.json"), "utf8")).toBe(diskBefore); + } + // Shared fetch consumers that cannot render the projection must never expose the arrival. + const publicRows = await fetchAllModels(config); + expect(publicRows.filter(row => row.provider === provider).map(row => row.id)).toEqual(["a"]); + expect(config).toEqual(before); + expect(readFileSync(join(TEST_DIR, "config.json"), "utf8")).toBe(diskBefore); + } finally { + clearModelCache(provider); + upstream.stop(true); + } +}); + test("manual OpenAI visibility preserves the pending-selection error contract", async () => { const config = loadConfig(); config.providers.openai = { diff --git a/tests/providers/new-model-policy-runtime.test.ts b/tests/providers/new-model-policy-runtime.test.ts index 65ceb74f9b4..fbc3fca4eb1 100644 --- a/tests/providers/new-model-policy-runtime.test.ts +++ b/tests/providers/new-model-policy-runtime.test.ts @@ -158,6 +158,30 @@ test("a stale file-backed discovery preserves another writer's enabled model thr expect(published).toBe(false); }); +test("a read projection preserves a concurrent manual enable through the live writer's later save", () => { + saveConfig(fixture()); + const config = loadConfig(); + armClaudeCodeBaseline(config); + const concurrent = loadConfig(); + concurrent.providers.vendor.models!.push("c"); + concurrent.modelDiscovery!.knownModels!.vendor.ids.push("c"); + saveConfig(concurrent); + const before = bytes(); + const liveBefore = structuredClone(config); + let projection: OcxConfig | undefined; + expect(finalizeModelDiscovery(config, captureModelDiscoveryBaseline(config), rows, ["vendor"], + new Map(), projected => { projection = projected; })).toBe(true); + expect(projection?.disabledModels).toEqual(["vendor/b", "vendor/c"]); + expect(projection?.modelDiscovery?.knownModels?.vendor.ids).toEqual(["a", "b", "c"]); + expect(config).toEqual(liveBefore); + expect(bytes()).toBe(before); + config.contextCapValue = 320000; + saveConfigPreservingClaudeCode(config); + expect(loadConfig().disabledModels).toEqual(["vendor/b"]); + expect(loadConfig().providers.vendor.models).toEqual(["a", "b", "c"]); + expect(loadConfig().contextCapValue).toBe(320000); +}); + test.each([false, true])("an unarmed file-backed caller rejects stale discovery (detached baseline: %s)", detached => { saveConfig(fixture());