From 13c7e9b21c133afe9a115764fd66a538c45172c0 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 13:50:02 +0900 Subject: [PATCH 1/6] feat(models): initialize large new provider catalogs with switches off --- .../010_initial_selection.md | 23 +- scripts/test-layout/layout.json | 1 + src/cli/init.ts | 2 + src/cli/provider.ts | 2 + src/codex/catalog/provider-fetch.ts | 2 + src/codex/catalog/sync.ts | 10 + src/codex/convergence.ts | 2 + src/codex/management-convergence.ts | 3 + src/config.ts | 6 + src/oauth/index.ts | 2 + src/oauth/login-cli.ts | 2 + .../initial-model-selection-runtime.ts | 88 ++++++ src/providers/initial-model-selection.ts | 102 ++++++ src/server/auth-cors.ts | 4 + .../management/agent-settings-routes.ts | 2 +- src/server/management/model-routes.ts | 4 + src/server/management/model-rows.ts | 7 +- src/server/management/provider-routes.ts | 2 + src/server/management/shared.ts | 15 +- src/types/provider.ts | 7 + tests/cli/cli-provider.test.ts | 19 ++ tests/codex-integration/codex-catalog.test.ts | 11 + tests/fixtures/test-layout-expected.json | 1 + .../providers/initial-model-selection.test.ts | 294 ++++++++++++++++++ 24 files changed, 599 insertions(+), 12 deletions(-) create mode 100644 src/providers/initial-model-selection-runtime.ts create mode 100644 src/providers/initial-model-selection.ts create mode 100644 tests/providers/initial-model-selection.test.ts diff --git a/devlog/_plan/260905_provider_registration_selection/010_initial_selection.md b/devlog/_plan/260905_provider_registration_selection/010_initial_selection.md index 25afb0a7c2..5aabfea701 100644 --- a/devlog/_plan/260905_provider_registration_selection/010_initial_selection.md +++ b/devlog/_plan/260905_provider_registration_selection/010_initial_selection.md @@ -14,6 +14,7 @@ Persisted provider field: ```ts initialModelSelection?: { version: 1; + registrationId: string; // new UUID on first creation only; preserved on overwrite status: "pending" | "ready" | "all-off"; modelCount?: number; }; @@ -83,6 +84,10 @@ No change to explicit model-ID routing. ### NEW src/providers/initial-model-selection-runtime.ts Own the ordinary-discovery completion write, independent of Codex integration. +Match registration UUID as well as normalized inventory-producing configuration +(including custom rows, combos and provider dependencies). Equal field values +after delete/re-add are not the same registration. Schema-default normalization +and order-independent comparison avoid spurious mismatches after load/save. Capture pending provider config and disabledModels before gather; use existing authoritative outcome metadata and the pure transition after discovery. Re-read under mutatePersistedConfig, compare the captured provider/selection identity, @@ -102,16 +107,16 @@ no pending provider keep the existing fast path, with no writes/new discovery. catalog evidence; never insert config writes inside an already sealed gather. The evidence-only gather entry point remains mutation-free. -### MODIFY src/codex/convergence.ts +### MODIFY src/codex/management-convergence.ts and src/codex/convergence.ts -Before prepareCatalog, clone snapshot config as now, run initial-selection -reconciliation using authoritative providerModelOutcomes (static included), then -run existing successful-discovery reconciliation; execute BOTH, do not short-circuit -one in an `a || b` call expression. Carry projected config if either changed. -After successful admitted commit, adopt state with disabledModels/modelDiscovery -and use existing coordinated save. A failed/stale/busy commit must not publish -state or OFF decisions. Snapshot identity already hashes complete config, so the -new provider field is covered without a second fingerprint implementation. +Implementation refinement: the management wrapper resolves pending initialization +BEFORE capturing catalog admission, just as retained sync does before its evidence +read. This avoids coupling durable initial selection to a later catalog-file write +or exposing an in-memory completed marker after a failed config save. The evidence +gather stays read-only; convergence only carries pending-provider names into final +visibility filtering. Existing later-arrival projection is untouched. Registration +choices commit independently of optional Codex catalog success. Snapshot identity +already hashes complete config, so no second fingerprint implementation is needed. ### MODIFY src/server/management/model-rows.ts and model-routes.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 5e579748ef..84d363b895 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -818,6 +818,7 @@ "native-profile-startup.test.ts": "codex-integration", "native-profile-store.test.ts": "codex-integration", "new-model-policy.test.ts": "providers", + "initial-model-selection.test.ts": "providers", "nous-oauth-live.test.ts": "providers", "nous-oauth.test.ts": "providers", "novita-provider.test.ts": "providers", diff --git a/src/cli/init.ts b/src/cli/init.ts index be3f0ac8b6..9f5551447b 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -1,4 +1,5 @@ import * as readline from "node:readline"; +import { initializeProviderModelSelection } from "../providers/initial-model-selection"; import { existsSync, readFileSync, unlinkSync } from "node:fs"; import { injectCodexConfig } from "../codex/inject"; import { classifyOpenAiTierBackup, getConfigPath, getDefaultConfig, isValidProviderName, preserveOpenAiTierRollbackSnapshot, saveConfig } from "../config"; @@ -160,6 +161,7 @@ export async function runInit(): Promise { const portStr = await prompt.ask("\nProxy port [10100]: "); const port = parseInt(portStr, 10) || 10100; + initializeProviderModelSelection(providerName, providerConfig); const config: OcxConfig = { ...getDefaultConfig(), port, diff --git a/src/cli/provider.ts b/src/cli/provider.ts index f9ac3b5b21..fb4c12a754 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -212,6 +212,8 @@ async function handleAdd(args: string[]): Promise { } const existingProvider = config.providers[name]; + const { initializeProviderModelSelection } = await import("../providers/initial-model-selection"); + initializeProviderModelSelection(name, provConfig, existingProvider); config.providers[name] = provConfig; // A --force overwrite rotates the key/endpoint but must not drop a // user-configured price overlay (same rule as the /api/providers path and diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index fa7e0b4b00..f810bfb422 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1,4 +1,5 @@ import { effectiveProviderAlias, effectiveProviderAliasDecision } from "../../providers/default-aliases"; +import { initialModelSelectionPending } from "../../providers/initial-model-selection"; import { execFileSync } from "node:child_process"; import { createHash, createHmac, randomBytes } from "node:crypto"; import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; @@ -2016,6 +2017,7 @@ export function filterCatalogVisibleModels( } } return models.filter(m => { + if (initialModelSelectionPending(config.providers[m.provider])) return false; const nativeAlias = m.provider === COMBO_NAMESPACE && m.nativeAlias === true; // disabledModels may be stored raw (canonical) or encoded (legacy UI writes). for (const stored of disabled) { diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 0c8b00a2cf..6288f9bf87 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -1,4 +1,5 @@ import { effectiveProviderAlias } from "../../providers/default-aliases"; +import { pendingModelSelectionProviders } from "../../providers/initial-model-selection"; import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; @@ -775,6 +776,7 @@ export interface ObservedCatalogMergeInput { readonly disabledModels: ReadonlySet; readonly selectedModelsByProvider: ReadonlyMap>; readonly gatheredProviderNames: ReadonlySet; + readonly pendingProviderNames?: ReadonlySet; readonly degradedProviderNames: ReadonlySet; readonly legacyCustomModelSlugs: ReadonlySet; readonly multiAgentMode: MultiAgentMode; @@ -806,6 +808,7 @@ export function mergeCatalogEntriesFromObservedState({ disabledModels, selectedModelsByProvider, gatheredProviderNames, + pendingProviderNames = new Set(), degradedProviderNames, legacyCustomModelSlugs, multiAgentMode, @@ -855,6 +858,7 @@ export function mergeCatalogEntriesFromObservedState({ if (disabledModelKeys.has(key)) return false; const slash = slug.indexOf("/"); const provider = slug.slice(0, slash); + if (pendingProviderNames.has(provider)) return false; const selected = selectedModelKeysByProvider.get(provider); if (selected !== undefined && !selected.has(key)) return false; return !gatheredProviderNames.has(provider) || degradedProviderNames.has(provider); @@ -1050,6 +1054,7 @@ export function mergeCatalogEntriesFromObservedState({ if (freshExactComboEntries.has(entry)) return true; const slash = slug.indexOf("/"); const provider = slug.slice(0, slash); + if (pendingProviderNames.has(provider)) return false; const selected = selectedModelKeysByProvider.get(provider); return selected === undefined || selected.has(slugEquivalenceKey(slug)); }); @@ -1718,6 +1723,7 @@ function writeRetainedCatalogSync({ disabledModels: new Set(config.disabledModels ?? []), selectedModelsByProvider, gatheredProviderNames, + pendingProviderNames: pendingModelSelectionProviders(config), degradedProviderNames, legacyCustomModelSlugs: legacyCustomModelCatalogSlugs(config), multiAgentMode, @@ -1820,6 +1826,10 @@ export async function syncCatalogModels( config: OcxConfig, options?: CodexCatalogSyncOptions, ): Promise { + if (pendingModelSelectionProviders(config).size) { + const { resolvePendingInitialModelSelection } = await import("../../providers/initial-model-selection-runtime"); + await resolvePendingInitialModelSelection(config); + } const owningCodexHome = getCodexHome(); const preflightRead = readRetainedCatalogSync(config); if (preflightRead === null) { diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index b84bbcb909..df765a7853 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import { getConfigDir, saveConfigPreservingClaudeCode, websocketsEnabled, withExpectedConfigGenerationSync } from "../config"; import { reconcileSuccessfulModelDiscoveries } from "../providers/new-model-policy"; +import { pendingModelSelectionProviders } from "../providers/initial-model-selection"; import { COMBO_NAMESPACE } from "../combos"; import { getAuthStorePath } from "../oauth/store"; import type { OcxConfig } from "../types"; @@ -351,6 +352,7 @@ function prepareCatalog( disabledModels: new Set(config.disabledModels ?? []), selectedModelsByProvider, gatheredProviderNames, + pendingProviderNames: pendingModelSelectionProviders(config), degradedProviderNames, legacyCustomModelSlugs: legacyCustomModelCatalogSlugs(config), multiAgentMode, diff --git a/src/codex/management-convergence.ts b/src/codex/management-convergence.ts index 9847e5dd97..1603523622 100644 --- a/src/codex/management-convergence.ts +++ b/src/codex/management-convergence.ts @@ -1,4 +1,5 @@ import type { OcxConfig } from "../types"; +import { resolvePendingInitialModelSelection } from "../providers/initial-model-selection-runtime"; import { captureCatalogAdmissionSnapshot } from "./catalog-admission"; import { convergeCodexCatalog } from "./convergence"; import type { @@ -152,6 +153,8 @@ export function createManagementConvergeCodex( catalogRefresh: unexpectedCatalogFailure(false), }); } + // Registration choices are committed independently, before sealing catalog authority. + await resolvePendingInitialModelSelection(retainedConfig as OcxConfig); const snapshot = captureCatalogAdmissionSnapshot(retainedConfig); const result = await convergeCodexCatalog(snapshot, request, { onCommitBegin: () => { commitBegan = true; }, diff --git a/src/config.ts b/src/config.ts index 68764d2f71..575d3cbc48 100644 --- a/src/config.ts +++ b/src/config.ts @@ -525,6 +525,12 @@ const providerConfigSchema = z.object({ modelAliases: z.record(z.string(), z.string()).optional(), modelDisplayNames: modelDisplayNamesSchema.optional(), defaultAliases: z.boolean().optional(), + initialModelSelection: z.object({ + version: z.literal(1), + registrationId: z.uuid(), + status: z.enum(["pending", "ready", "all-off"]), + modelCount: z.number().int().nonnegative().optional(), + }).optional().catch(undefined), requestPacing: requestPacingSchema.optional().catch(undefined), mcpMaxTools: z.number().int().positive().optional(), mcpMaxSchemaBytes: z.number().int().positive().optional(), diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 3623398309..15b094347c 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1,4 +1,5 @@ import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types"; +import { initializeProviderModelSelection } from "../providers/initial-model-selection"; import { parseCallbackInput } from "./callback-server"; import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types"; import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, saveConfig } from "../config"; @@ -1481,6 +1482,7 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { if (previousModeAllowsKey) next.authMode = "key"; } } + initializeProviderModelSelection(provider, next, existing); config.providers[provider] = next; } diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index 437b61e6d6..1952e81a51 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -1,4 +1,5 @@ import * as readline from "node:readline"; +import { initializeProviderModelSelection } from "../providers/initial-model-selection"; import { openUrl } from "../lib/open-url"; import { loadConfig, saveConfig } from "../config"; import { findLiveProxy } from "../server/proxy-liveness"; @@ -156,6 +157,7 @@ export async function commitKeyLoginProvider( onLiveReload?: (result: LocalProviderReloadResult | null) => void, ): Promise { const mergedProvider = mergeKeyLoginProviderRow(provider, config.providers[name]); + initializeProviderModelSelection(name, mergedProvider, config.providers[name]); config.providers[name] = mergedProvider; saveConfig(config); // Evaluate the reload BEFORE the optional call: `onLiveReload?.(await ...)` short-circuits diff --git a/src/providers/initial-model-selection-runtime.ts b/src/providers/initial-model-selection-runtime.ts new file mode 100644 index 0000000000..e0ab1b6e60 --- /dev/null +++ b/src/providers/initial-model-selection-runtime.ts @@ -0,0 +1,88 @@ +import { mutatePersistedConfig, validateConfigCandidate } from "../config"; +import { isDeepStrictEqual } from "node:util"; +import type { OcxConfig, OcxProviderConfig } from "../types"; +import type { CatalogModel } from "../codex/catalog"; +import { + adoptInitialModelSelections, + initialModelSelection, + initialModelSelectionPending, + reconcileInitialModelSelections, +} from "./initial-model-selection"; + +interface InitialSelectionBaseline { + providers: string[]; + inventory: unknown; + disabled: string; +} + +function inventoryIdentity(config: OcxConfig): unknown { + const validated = validateConfigCandidate(config); + if (!validated.ok) return null; + // Compare all inventory-producing configuration, including custom rows and combos. + // Normalize schema defaults and ignore only completed-selection state and switch values. + // The incarnation remains: identical delete/re-add is NOT the same registration. + const providers = Object.fromEntries(Object.entries(validated.config.providers).map(([name, provider]) => [name, { + ...provider, + initialModelSelection: initialModelSelection(provider)?.registrationId, + }])); + // Ephemeral only: never log this value, which may contain credentials. + return JSON.parse(JSON.stringify({ ...validated.config, providers, disabledModels: undefined })); +} + +export function captureInitialSelectionBaseline(config: OcxConfig): InitialSelectionBaseline | null { + const providers = Object.entries(config.providers) + .filter(([, provider]) => initialModelSelectionPending(provider)) + .map(([name]) => name); + if (!providers.length) return null; + const inventory = inventoryIdentity(config); + return inventory === null ? null : { providers, inventory, disabled: JSON.stringify(config.disabledModels ?? []) }; +} + +/** Commit only decisions whose provider and user-selection snapshot still match. */ +export function finalizeInitialModelSelection( + config: OcxConfig, + baseline: InitialSelectionBaseline | null, + models: readonly CatalogModel[], + authoritativeProviders: readonly string[], +): void { + if (!baseline || JSON.stringify(config.disabledModels ?? []) !== baseline.disabled) return; + if (!isDeepStrictEqual(inventoryIdentity(config), baseline.inventory)) return; + try { + const outcome = mutatePersistedConfig(fresh => { + if (!isDeepStrictEqual(inventoryIdentity(fresh), baseline.inventory)) return { changed: false, value: null }; + const providers: Record = {}; + for (const name of baseline.providers) { + const provider = fresh.providers[name]; + if (!provider || !initialModelSelection(provider)) continue; + // A concurrent successful initializer may already have committed its result. + // Adopt that result, including any later manual switch edits; never initialize twice. + if (initialModelSelectionPending(provider) && JSON.stringify(fresh.disabledModels ?? []) !== baseline.disabled) continue; + providers[name] = provider; + } + const projection = { ...fresh, providers }; + const changed = reconcileInitialModelSelections(projection, models, authoritativeProviders); + if (changed) fresh.disabledModels = projection.disabledModels; + return { changed, value: { ...projection, disabledModels: fresh.disabledModels } }; + }); + if (outcome.status === "unavailable" || !outcome.value) return; + adoptInitialModelSelections(config, outcome.value); + if (Object.keys(outcome.value.providers).length) { + config.disabledModels = outcome.value.disabledModels === undefined ? undefined : [...outcome.value.disabledModels]; + } + } catch { + // Keep pending publication fenced on contention or failed persistence. A later ordinary + // model refresh retries; no dedicated timer and no private exception/path output. + console.warn("[initial-model-selection] Could not save initial model choices; model exposure remains pending. Retry model discovery."); + } +} + +/** Ordinary discovery, before retained catalog evidence is captured. */ +export async function resolvePendingInitialModelSelection(config: OcxConfig): Promise { + const baseline = captureInitialSelectionBaseline(config); + if (!baseline) return; + const { gatherRoutedModels, uniqueCatalogModelsForPublicList } = await import("../codex/catalog"); + const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; + const models = await gatherRoutedModels(config, { providerModelOutcomes: outcomes }); + finalizeInitialModelSelection(config, baseline, uniqueCatalogModelsForPublicList(models), + outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider)); +} diff --git a/src/providers/initial-model-selection.ts b/src/providers/initial-model-selection.ts new file mode 100644 index 0000000000..0b1ba3541b --- /dev/null +++ b/src/providers/initial-model-selection.ts @@ -0,0 +1,102 @@ +import type { OcxConfig, OcxProviderConfig } from "../types"; +import { randomUUID } from "node:crypto"; +import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "./registry"; +import { routedSlug, slugEquivalenceKey } from "./slug-codec"; + +export const INITIAL_MODEL_SELECTION_THRESHOLD = 20; +type Selection = NonNullable; + +/** Read only the public, non-secret shape; editor input never owns this state. */ +export function initialModelSelection(provider: OcxProviderConfig | undefined): Selection | undefined { + const value = provider?.initialModelSelection; + if (!value || value.version !== 1 || typeof value.registrationId !== "string" + || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value.registrationId) + || !["pending", "ready", "all-off"].includes(value.status)) return undefined; + return { + version: 1, + registrationId: value.registrationId, + status: value.status, + ...(Number.isSafeInteger(value.modelCount) && value.modelCount! >= 0 ? { modelCount: value.modelCount } : {}), + }; +} + +export function initialModelSelectionPending(provider: OcxProviderConfig | undefined): boolean { + return initialModelSelection(provider)?.status === "pending"; +} + +function loginConnection(name: string, provider: OcxProviderConfig): boolean { + const entry = getProviderRegistryEntry(name); + if (entry && providerMatchesRegistryTransport(name, provider)) { + if (entry.authKind === "forward") return true; + if (entry.authKind === "oauth") { + return !(entry.allowKeyAuthOverride === true && provider.authMode === "key"); + } + } + return provider.authMode === "oauth" || provider.authMode === "forward"; +} + +/** Registration only: absence on an existing row is legacy/exempt, never a migration trigger. */ +export function initializeProviderModelSelection(name: string, next: OcxProviderConfig, existing?: OcxProviderConfig): void { + delete next.initialModelSelection; + if (existing) { + for (const key of ["selectedModels", "modelPreset", "newModelPolicy"] as const) { + if (next[key] === undefined && existing[key] !== undefined) { + Object.assign(next, { [key]: structuredClone(existing[key]) }); + } + } + if (existing.initialModelSelection !== undefined) next.initialModelSelection = structuredClone(existing.initialModelSelection); + } else if (!loginConnection(name, next)) { + next.initialModelSelection = { version: 1, registrationId: randomUUID(), status: "pending" }; + } +} + +/** Count the canonical switch identities that the Models inventory displays. */ +export function reconcileInitialModelSelections( + config: OcxConfig, + models: Iterable<{ provider: string; id: string }>, + authoritativeProviders: Iterable, +): boolean { + const selectors = new Map>(); + for (const model of models) { + const ids = selectors.get(model.provider) ?? new Set(); + ids.add(routedSlug(model.provider, model.id)); + selectors.set(model.provider, ids); + } + const authoritative = new Set(authoritativeProviders); + let changed = false; + for (const [name, provider] of Object.entries(config.providers)) { + const initial = initialModelSelection(provider); + if (initial?.status !== "pending") continue; + if (loginConnection(name, provider)) { + provider.initialModelSelection = { version: 1, registrationId: initial.registrationId, status: "ready" }; + changed = true; + continue; + } + if (!authoritative.has(name)) continue; + const ids = selectors.get(name) ?? new Set(); + const allOff = ids.size >= INITIAL_MODEL_SELECTION_THRESHOLD; + if (allOff) { + const disabled = config.disabledModels ??= []; + const keys = new Set(disabled.map(slugEquivalenceKey)); + for (const id of ids) { + const key = slugEquivalenceKey(id); + if (!keys.has(key)) { disabled.push(id); keys.add(key); } + } + } + provider.initialModelSelection = { version: 1, registrationId: initial.registrationId, status: allOff ? "all-off" : "ready", modelCount: ids.size }; + changed = true; + } + return changed; +} + +export function adoptInitialModelSelections(target: OcxConfig, source: OcxConfig): void { + for (const [name, provider] of Object.entries(source.providers)) { + if (target.providers[name] && provider.initialModelSelection !== undefined) { + target.providers[name].initialModelSelection = structuredClone(provider.initialModelSelection); + } + } +} + +export function pendingModelSelectionProviders(config: Pick): Set { + return new Set(Object.entries(config.providers).filter(([, provider]) => initialModelSelectionPending(provider)).map(([name]) => name)); +} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index c0c7b77fd1..ccc23c5ef5 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1,4 +1,5 @@ import { timingSafeEqual } from "node:crypto"; +import { initialModelSelection } from "../providers/initial-model-selection"; import { extractAccountId } from "../oauth/chatgpt"; import { formatErrorResponse } from "../bridge"; import { @@ -798,6 +799,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { models: "editor", liveModels: "editor", selectedModels: "editor", + initialModelSelection: "runtime", retainModels: "editor", newModelPolicy: "editor", modelPreset: "editor", @@ -1002,6 +1004,8 @@ export function safeConfigDTO(config: OcxConfig): unknown { if (name === "xai") { dto.xaiResponsesOptInState = xaiResponsesOptInState(provider); } + const selection = initialModelSelection(provider); + if (selection) dto.initialModelSelection = selection; providers[name] = dto; } return { diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 75bcaf36ae..51ebc746cb 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -70,7 +70,7 @@ import type { PersistedUsageAttempt } from "../../usage/log"; import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; import { applySystemEnvToggle } from "../system-env"; -import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels, fetchGrokCandidateModels, buildClaudeDesktopState } from "./shared"; +import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchInitializedModels as fetchAllModels, fetchGrokCandidateModels, buildClaudeDesktopState } from "./shared"; import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; import { readManagementJsonBody, readOptionalManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 601444a958..a364694b2c 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -154,6 +154,7 @@ import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostR import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; import type { ManagementContext } from "./context"; import { listManagementModelRows, loadExportModels } from "./model-rows"; +import { initialModelSelectionPending } from "../../providers/initial-model-selection"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import { hasModelPreset, @@ -536,6 +537,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise & { id: string; namespaced: string; disabled: boolean; + initialSelectionPending?: boolean; native?: boolean; custom?: boolean; customId?: string; @@ -164,7 +166,10 @@ export async function listManagementModelRows( ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}), }; }).filter((row): row is ManagementModelRow => row !== null); - return [...native, ...dedupedRouted, ...visibleCustomModels]; + return [...native, ...dedupedRouted, ...visibleCustomModels].map(row => + initialModelSelectionPending(config.providers[row.provider]) + ? { ...row, disabled: true, initialSelectionPending: true } + : row); } /** `/api/models` row → the narrower input the client-config serializers accept. */ diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index f6a6bf767c..50db211a67 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -41,6 +41,7 @@ import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; import { parseAntigravityAvailableModels } from "../../providers/antigravity-models"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets, providerConfigSeed } from "../../providers/derive"; +import { initializeProviderModelSelection } from "../../providers/initial-model-selection"; import { effectiveGoogleMode, providerCodexAccountMode, providerMatchesRegistryTransport } from "../../providers/registry"; import { extractModelEnvelopeRows, @@ -994,6 +995,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { */ export async function fetchAllModels(config: OcxConfig): Promise { const { gatherRoutedModels } = await import("../../codex/catalog"); - return gatherRoutedModels(config); + const baseline = captureInitialSelectionBaseline(config); + if (!baseline) return gatherRoutedModels(config); + const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; + const models = await gatherRoutedModels(config, { providerModelOutcomes: outcomes }); + finalizeInitialModelSelection(config, baseline, uniqueCatalogModelsForPublicList(models), + outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider)); + return models; } export interface GrokCandidateModel { @@ -187,6 +195,11 @@ export interface GrokCandidateModel { native: boolean; } +/** Configuration pickers may retain disabled choices, but never offer provisional models. */ +export async function fetchInitializedModels(config: OcxConfig): Promise { + return (await fetchAllModels(config)).filter(model => !initialModelSelectionPending(config.providers[model.provider])); +} + /** * The model list `syncGrokConfig` would inject, BEFORE the user's exclusions. The Grok * page needs this to show a switch for a model the user has already excluded — such a diff --git a/src/types/provider.ts b/src/types/provider.ts index 691fb01e1c..79651e0012 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -345,6 +345,13 @@ export interface OcxProviderConfig { * full set so the user can pick). See devlog issue_052_provider-model-allowlist. */ selectedModels?: string[]; + /** Registration-owned state. Absent means legacy or OAuth-exempt, not uninitialized. */ + initialModelSelection?: { + version: 1; + registrationId: string; + status: "pending" | "ready" | "all-off"; + modelCount?: number; + }; /** * Per-provider retention allowlist for authoritative live discovery. When non-empty, any * model id in this list is preserved in the routed catalog even if the live `/models` diff --git a/tests/cli/cli-provider.test.ts b/tests/cli/cli-provider.test.ts index b58adfd8cb..402cc973b0 100644 --- a/tests/cli/cli-provider.test.ts +++ b/tests/cli/cli-provider.test.ts @@ -52,6 +52,25 @@ function readConfig(dir: string) { } describe("ocx provider", () => { + test("new provider registration initializes model selection but force overwrite preserves it", () => { + const { dir } = freshConfig(); + try { + const args = ["provider", "add", "model-fixture", "--adapter", "openai-chat", "--base-url", "https://models.example.test/v1", "--json"]; + const added = runCli(args, { OPENCODEX_HOME: dir }); + expect(added.status).toBe(0); + const first = readConfig(dir); + expect(first.providers["model-fixture"].initialModelSelection.status).toBe("pending"); + const registrationId = first.providers["model-fixture"].initialModelSelection.registrationId; + first.providers["model-fixture"].selectedModels = ["chosen"]; + writeFileSync(join(dir, "config.json"), JSON.stringify(first)); + expect(runCli([...args, "--force"], { OPENCODEX_HOME: dir }).status).toBe(0); + const next = readConfig(dir).providers["model-fixture"]; + expect(next.selectedModels).toEqual(["chosen"]); + expect(next.initialModelSelection.registrationId).toBe(registrationId); + expect(next.disabled).not.toBe(true); + } finally { removeTreeWithRetry(dir); } + }); + test("provider --help prints usage", () => { const result = runCli(["provider", "--help"]); expect(result.status).toBe(0); diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index febaf976cb..37d8c69798 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -3032,6 +3032,17 @@ function mergeObservedForTest( } describe("Codex catalog routed normalization", () => { + test("pending re-registration cannot recover ON rows from a degraded old catalog", () => { + const old = { ...nativeTemplate(), slug: "vendor/model-0", owned_by: "vendor", opencodex_catalog_kind: CODEX_PROVIDER_MODEL_CATALOG_KIND }; + const input = { + catalogModels: [old], routedEntries: [], + gatheredProviderNames: new Set(["vendor"]), degradedProviderNames: new Set(["vendor"]), + }; + expect(mergeObservedForTest(input).some(entry => entry.slug === "vendor/model-0")).toBe(true); + expect(mergeObservedForTest({ ...input, pendingProviderNames: new Set(["vendor"]) }) + .some(entry => entry.slug === "vendor/model-0")).toBe(false); + }); + test("does not reuse a routed native alias as the native catalog template", () => { const routedAlias = { ...nativeTemplate(), diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 36602be986..dd350c6aaa 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -655,6 +655,7 @@ "native-profile-startup.test.ts": "codex-integration", "native-profile-store.test.ts": "codex-integration", "new-model-policy.test.ts": "providers", + "initial-model-selection.test.ts": "providers", "nous-oauth-live.test.ts": "providers", "nous-oauth.test.ts": "providers", "novita-provider.test.ts": "providers", diff --git a/tests/providers/initial-model-selection.test.ts b/tests/providers/initial-model-selection.test.ts new file mode 100644 index 0000000000..cdc9ab9dbd --- /dev/null +++ b/tests/providers/initial-model-selection.test.ts @@ -0,0 +1,294 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as configStore from "../../src/config"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { filterCatalogVisibleModels } from "../../src/codex/catalog"; +import { clearModelCache } from "../../src/codex/model-cache"; +import { initializeProviderModelSelection, reconcileInitialModelSelections } from "../../src/providers/initial-model-selection"; +import { captureInitialSelectionBaseline, finalizeInitialModelSelection, resolvePendingInitialModelSelection } from "../../src/providers/initial-model-selection-runtime"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { safeConfigDTO, providerEditorConfigDTO } from "../../src/server/auth-cors"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { upsertOAuthProvider } from "../../src/oauth"; +import { commitKeyLoginProvider } from "../../src/oauth/login-cli"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { ManagementRequest } from "../helpers/management-auth"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; + +let home = ""; +let previousHome: string | undefined; +let codex: IsolatedCodexHome; +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-initial-selection-")); + process.env.OPENCODEX_HOME = home; + codex = installIsolatedCodexHome("ocx-initial-selection-codex-"); +}); +afterEach(async () => { + clearModelCache(); + await flushConfigDirHardeningForTests(); + codex.restore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +function fixture(count = 20): OcxConfig { + const provider: OcxProviderConfig = { + adapter: "openai-chat", baseUrl: "https://models.example.test/v1", authMode: "key", + apiKey: "fixture-key", liveModels: false, + models: Array.from({ length: count }, (_, i) => `model-${i}`), + }; + initializeProviderModelSelection("vendor", provider); + return { port: 0, defaultProvider: "vendor", providers: { vendor: provider }, clientIntegrations: { codex: false } }; +} +function rows(count: number) { + return Array.from({ length: count }, (_, i) => ({ provider: "vendor", id: `model-${i}` })); +} +async function api(config: OcxConfig, path: string, body?: unknown, method = "PUT"): Promise { + const url = new URL(`http://localhost${path}`); + const response = await handleManagementAPI(new ManagementRequest(url, body === undefined ? {} : { + method, headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }), url, config, { createManagementConvergeCodex: catalogConvergenceFactory() }); + if (!response) throw new Error("route missing"); + return response; +} + +describe("initial provider model switches", () => { + test.each([0, 19, 20])("authoritative %i-row boundary keeps the provider active", count => { + const config = fixture(count); + expect(reconcileInitialModelSelections(config, rows(count), ["vendor"])).toBe(true); + expect(config.providers.vendor.initialModelSelection).toEqual({ version: 1, registrationId: expect.any(String), status: count >= 20 ? "all-off" : "ready", modelCount: count }); + expect(config.providers.vendor.disabled).not.toBe(true); + expect(config.disabledModels ?? []).toHaveLength(count >= 20 ? count : 0); + expect(reconcileInitialModelSelections(config, rows(count), ["vendor"])).toBe(false); + }); + + test("counts duplicate selectors once and metadata overrides as real switch rows", () => { + const config = fixture(); + const listed = [...rows(19), { provider: "vendor", id: "model-0", custom: true }]; + reconcileInitialModelSelections(config, listed, ["vendor"]); + expect(config.providers.vendor.initialModelSelection?.modelCount).toBe(19); + expect(config.disabledModels).toBeUndefined(); + const withAlias = fixture(); + reconcileInitialModelSelections(withAlias, [...listed, { provider: "vendor", id: "displayed-alias" }], ["vendor"]); + expect(withAlias.providers.vendor.initialModelSelection?.status).toBe("all-off"); + expect(withAlias.disabledModels).toContain("vendor/displayed-alias"); + }); + + test("OFF preserves unrelated exclusions, uses canonical IDs and never repeats", () => { + const config = fixture(); + config.disabledModels = ["other/keep", "vendor/a/b"]; + const listed = [...rows(19), { provider: "vendor", id: "a/b" }]; + reconcileInitialModelSelections(config, listed, ["vendor"]); + expect(config.disabledModels).toHaveLength(21); + expect(config.disabledModels).toContain("other/keep"); + config.disabledModels = config.disabledModels.filter(id => id !== "vendor/model-0"); + expect(reconcileInitialModelSelections(config, listed, ["vendor"])).toBe(false); + expect(config.disabledModels).not.toContain("vendor/model-0"); + }); + + test("OAuth and ChatGPT forwarding are exempt, mixed-auth key connections are not", () => { + for (const name of ["openai", "cursor", "xai"]) { + const provider = providerConfigSeed(getProviderRegistryEntry(name)!); + initializeProviderModelSelection(name, provider); + expect(provider.initialModelSelection).toBeUndefined(); + } + const key = providerConfigSeed(getProviderRegistryEntry("xai")!); + key.authMode = "key"; + key.apiKey = "fixture-key"; + initializeProviderModelSelection("xai", key); + expect(key.initialModelSelection?.status).toBe("pending"); + const local = { adapter: "openai-chat", baseUrl: "http://127.0.0.1:11434/v1", authMode: "local" } satisfies OcxProviderConfig; + initializeProviderModelSelection("local-test", local); + expect((local as OcxProviderConfig).initialModelSelection?.status).toBe("pending"); + }); + + test("existing selections and marker survive provider replacement and OAuth upsert", () => { + const existing = fixture().providers.vendor; + existing.selectedModels = ["chosen"]; + existing.modelPreset = { mode: "custom" }; + existing.newModelPolicy = "off"; + existing.initialModelSelection = { ...existing.initialModelSelection!, status: "all-off", modelCount: 20 }; + const replacement: OcxProviderConfig = { adapter: "openai-chat", baseUrl: existing.baseUrl }; + initializeProviderModelSelection("vendor", replacement, existing); + expect(replacement.selectedModels).toEqual(["chosen"]); + expect(replacement.modelPreset).toEqual({ mode: "custom" }); + expect(replacement.newModelPolicy).toBe("off"); + expect(replacement.initialModelSelection).toEqual(existing.initialModelSelection); + const xai = providerConfigSeed(getProviderRegistryEntry("xai")!); + xai.selectedModels = ["grok-4.6"]; + const config: OcxConfig = { port: 0, defaultProvider: "xai", providers: { xai } }; + upsertOAuthProvider(config, "xai"); + expect(config.providers.xai.selectedModels).toEqual(["grok-4.6"]); + expect(config.providers.xai.initialModelSelection).toBeUndefined(); + }); + + test("management discovery finalizes and persists with Codex integration OFF", async () => { + const config = fixture(); + configStore.saveConfig(config); + expect(config.clientIntegrations?.codex).toBe(false); + const response = await api(config, "/api/models"); + expect(response.status).toBe(200); + const listed = (await response.json()).filter((row: { provider: string }) => row.provider === "vendor"); + expect(listed).toHaveLength(20); + expect(listed.every((row: { disabled: boolean }) => row.disabled)).toBe(true); + expect(config.providers.vendor.initialModelSelection?.status).toBe("all-off"); + const saved = configStore.loadConfig(); + expect(saved.providers.vendor.initialModelSelection?.modelCount).toBe(20); + expect(saved.disabledModels).toHaveLength(20); + expect(filterCatalogVisibleModels(rows(20), saved)).toEqual([]); + }); + + test("POST creation stamps its own pending state and overwrite preserves selections", async () => { + const config: OcxConfig = { port: 0, defaultProvider: "openai", providers: {}, clientIntegrations: { codex: false } }; + configStore.saveConfig(config); + const provider = { + adapter: "openai-chat", baseUrl: "http://127.0.0.1:11434/v1", allowPrivateNetwork: true, + liveModels: false, models: rows(20).map(row => row.id), + initialModelSelection: { version: 1, registrationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", status: "ready" }, + }; + expect((await api(config, "/api/providers", { name: "vendor", provider }, "POST")).status).toBe(200); + const created = config.providers.vendor; + expect(created.initialModelSelection?.status).toBe("pending"); + const registrationId = created.initialModelSelection?.registrationId; + expect(registrationId).not.toBe(provider.initialModelSelection.registrationId); + created.selectedModels = ["model-2"]; + created.modelPreset = { mode: "custom" }; + configStore.saveConfig(config); + expect((await api(config, "/api/providers", { name: "vendor", provider }, "POST")).status).toBe(200); + const saved = configStore.loadConfig().providers.vendor; + expect(saved.selectedModels).toEqual(["model-2"]); + expect(saved.modelPreset).toEqual({ mode: "custom" }); + expect(saved.initialModelSelection?.registrationId).toBe(registrationId); + expect(saved.disabled).not.toBe(true); + }); + + test("key-login commit initializes new rows and preserves choices during key replacement", async () => { + const config: OcxConfig = { port: 0, defaultProvider: "vendor", providers: {} }; + configStore.saveConfig(config); + const provider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://models.example.test/v1", apiKey: "fixture-first" }; + await commitKeyLoginProvider(config, "vendor", provider); + const first = configStore.loadConfig().providers.vendor; + expect(first.initialModelSelection?.status).toBe("pending"); + config.providers.vendor.selectedModels = ["chosen"]; + configStore.saveConfig(config); + await commitKeyLoginProvider(config, "vendor", { ...provider, apiKey: "fixture-second" }); + const saved = configStore.loadConfig().providers.vendor; + expect(saved.apiKey).toBe("fixture-second"); + expect(saved.selectedModels).toEqual(["chosen"]); + expect(saved.initialModelSelection?.registrationId).toBe(first.initialModelSelection?.registrationId); + }); + + test("degraded discovery does not complete initialization or expose models", () => { + const config = fixture(); + configStore.saveConfig(config); + const before = readFileSync(configStore.getConfigPath(), "utf8"); + finalizeInitialModelSelection(config, captureInitialSelectionBaseline(config), rows(20), []); + expect(config.providers.vendor.initialModelSelection?.status).toBe("pending"); + expect(filterCatalogVisibleModels(rows(20), config)).toEqual([]); + expect(readFileSync(configStore.getConfigPath(), "utf8")).toBe(before); + }); + + test("another initializer and subsequent manual enable are adopted, never overwritten", async () => { + const config = fixture(); + configStore.saveConfig(config); + const baseline = captureInitialSelectionBaseline(config); + const other = configStore.loadConfig(); + await resolvePendingInitialModelSelection(other); + other.disabledModels = other.disabledModels!.filter(id => id !== "vendor/model-0"); + configStore.saveConfig(other); + finalizeInitialModelSelection(config, baseline, rows(20), ["vendor"]); + expect(config.providers.vendor.initialModelSelection?.status).toBe("all-off"); + expect(config.disabledModels).not.toContain("vendor/model-0"); + expect(configStore.loadConfig().disabledModels).not.toContain("vendor/model-0"); + }); + + test("a concurrent provider edit invalidates an initial decision", () => { + const config = fixture(); + configStore.saveConfig(config); + const baseline = captureInitialSelectionBaseline(config); + const edited = configStore.loadConfig(); + edited.providers.vendor.selectedModels = ["model-2"]; + configStore.saveConfig(edited); + finalizeInitialModelSelection(config, baseline, rows(20), ["vendor"]); + expect(configStore.loadConfig().providers.vendor.selectedModels).toEqual(["model-2"]); + expect(configStore.loadConfig().disabledModels).toBeUndefined(); + }); + + test("failed persistence keeps pending, including management rows and candidate APIs", async () => { + // The failed write must leave both policy and visibility pending. + const config = fixture(); + configStore.saveConfig(config); + writeFileSync(configStore.getConfigPath(), "{invalid"); + const response = await api(config, "/api/models"); + const listed = (await response.json()).filter((row: { provider: string }) => row.provider === "vendor"); + expect(listed).toHaveLength(20); + expect(listed.every((row: { disabled: boolean; initialSelectionPending: boolean }) => row.disabled && row.initialSelectionPending)).toBe(true); + for (const path of ["/api/injection-model", "/api/subagent-model-fallback"]) { + const candidates = await (await api(config, path)).json(); + expect(JSON.stringify(candidates.available)).not.toContain("vendor/"); + } + const put = await api(config, "/api/model-visibility", { scope: "provider", provider: "vendor", enabled: true, targets: [{ id: "model-0" }] }); + expect(put.status).toBe(409); + expect(config.providers.vendor.disabled).not.toBe(true); + expect(readFileSync(configStore.getConfigPath(), "utf8")).toBe("{invalid"); + }); + + test("identical delete and re-registration cannot consume an earlier discovery", () => { + const old = fixture(); + configStore.saveConfig(old); + const baseline = captureInitialSelectionBaseline(old); + const replacement = fixture(); + expect(replacement.providers.vendor.initialModelSelection?.registrationId) + .not.toBe(old.providers.vendor.initialModelSelection?.registrationId); + configStore.saveConfig(replacement); + finalizeInitialModelSelection(old, baseline, rows(20), ["vendor"]); + const saved = configStore.loadConfig(); + expect(saved.providers.vendor.initialModelSelection?.status).toBe("pending"); + expect(saved.providers.vendor.initialModelSelection?.registrationId).toBe(replacement.providers.vendor.initialModelSelection?.registrationId); + expect(saved.disabledModels).toBeUndefined(); + }); + + test("custom inventory changes invalidate a gathered count", () => { + const config = fixture(19); + configStore.saveConfig(config); + const baseline = captureInitialSelectionBaseline(config); + const edited = configStore.loadConfig(); + edited.customModels = [{ id: "extra", provider: "vendor", modelId: "extra-model", displayName: "Extra" }]; + configStore.saveConfig(edited); + finalizeInitialModelSelection(config, baseline, rows(19), ["vendor"]); + const saved = configStore.loadConfig(); + expect(saved.providers.vendor.initialModelSelection?.status).toBe("pending"); + expect(saved.customModels?.[0].modelId).toBe("extra-model"); + }); + + test("a thrown transaction never publishes a completed marker", () => { + const config = fixture(); + configStore.saveConfig(config); + const mutation = spyOn(configStore, "mutatePersistedConfig").mockImplementation(() => { throw new Error("fixture failure"); }); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + finalizeInitialModelSelection(config, captureInitialSelectionBaseline(config), rows(20), ["vendor"]); + expect(config.providers.vendor.initialModelSelection?.status).toBe("pending"); + expect(config.disabledModels).toBeUndefined(); + } finally { mutation.mockRestore(); warn.mockRestore(); } + }); + + test("state round-trips as read-only DTO metadata; malformed state does not discard providers", () => { + const config = fixture(); + configStore.saveConfig(config); + const loaded = configStore.loadConfig(); + expect(loaded.providers.vendor.initialModelSelection?.status).toBe("pending"); + expect((safeConfigDTO(loaded) as { providers: Record }).providers.vendor.initialModelSelection?.status).toBe("pending"); + expect(providerEditorConfigDTO(loaded).providers.vendor.initialModelSelection).toBeUndefined(); + writeFileSync(configStore.getConfigPath(), JSON.stringify({ ...config, providers: { vendor: { ...config.providers.vendor, initialModelSelection: { version: 1, status: "invalid" } } } })); + expect(configStore.loadConfig().providers.vendor.initialModelSelection).toBeUndefined(); + expect(configStore.loadConfig().providers.vendor.apiKey).toBe("fixture-key"); + }); +}); From f4c5baf1ae94b293f15df9f7d1f0d4afc0a75f0d Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 13:56:19 +0900 Subject: [PATCH 2/6] fix(models): reset orphaned selections on provider re-registration --- src/cli/provider.ts | 2 +- src/oauth/index.ts | 2 +- src/oauth/login-cli.ts | 2 +- src/providers/initial-model-selection.ts | 23 +++++++++-- src/server/management/provider-routes.ts | 2 +- .../providers/initial-model-selection.test.ts | 39 +++++++++++++++++-- 6 files changed, 59 insertions(+), 11 deletions(-) diff --git a/src/cli/provider.ts b/src/cli/provider.ts index fb4c12a754..c81e6bede2 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -213,7 +213,7 @@ async function handleAdd(args: string[]): Promise { const existingProvider = config.providers[name]; const { initializeProviderModelSelection } = await import("../providers/initial-model-selection"); - initializeProviderModelSelection(name, provConfig, existingProvider); + initializeProviderModelSelection(name, provConfig, existingProvider, config); config.providers[name] = provConfig; // A --force overwrite rotates the key/endpoint but must not drop a // user-configured price overlay (same rule as the /api/providers path and diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 15b094347c..1a8bd07157 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1482,7 +1482,7 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { if (previousModeAllowsKey) next.authMode = "key"; } } - initializeProviderModelSelection(provider, next, existing); + initializeProviderModelSelection(provider, next, existing, config); config.providers[provider] = next; } diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index 1952e81a51..7f6605586c 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -157,7 +157,7 @@ export async function commitKeyLoginProvider( onLiveReload?: (result: LocalProviderReloadResult | null) => void, ): Promise { const mergedProvider = mergeKeyLoginProviderRow(provider, config.providers[name]); - initializeProviderModelSelection(name, mergedProvider, config.providers[name]); + initializeProviderModelSelection(name, mergedProvider, config.providers[name], config); config.providers[name] = mergedProvider; saveConfig(config); // Evaluate the reload BEFORE the optional call: `onLiveReload?.(await ...)` short-circuits diff --git a/src/providers/initial-model-selection.ts b/src/providers/initial-model-selection.ts index 0b1ba3541b..50720f3cff 100644 --- a/src/providers/initial-model-selection.ts +++ b/src/providers/initial-model-selection.ts @@ -2,6 +2,7 @@ import type { OcxConfig, OcxProviderConfig } from "../types"; import { randomUUID } from "node:crypto"; import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "./registry"; import { routedSlug, slugEquivalenceKey } from "./slug-codec"; +import { comboDisabledModelSelectors } from "../combos/types"; export const INITIAL_MODEL_SELECTION_THRESHOLD = 20; type Selection = NonNullable; @@ -36,7 +37,12 @@ function loginConnection(name: string, provider: OcxProviderConfig): boolean { } /** Registration only: absence on an existing row is legacy/exempt, never a migration trigger. */ -export function initializeProviderModelSelection(name: string, next: OcxProviderConfig, existing?: OcxProviderConfig): void { +export function initializeProviderModelSelection( + name: string, + next: OcxProviderConfig, + existing?: OcxProviderConfig, + config?: Pick, +): void { delete next.initialModelSelection; if (existing) { for (const key of ["selectedModels", "modelPreset", "newModelPolicy"] as const) { @@ -45,8 +51,19 @@ export function initializeProviderModelSelection(name: string, next: OcxProvider } } if (existing.initialModelSelection !== undefined) next.initialModelSelection = structuredClone(existing.initialModelSelection); - } else if (!loginConnection(name, next)) { - next.initialModelSelection = { version: 1, registrationId: randomUUID(), status: "pending" }; + } else { + // A deleted provider's discovery history belongs to that old registration too. + if (config?.modelDiscovery?.knownModels) delete config.modelDiscovery.knownModels[name]; + if (config?.modelDiscovery?.recentArrivals) delete config.modelDiscovery.recentArrivals[name]; + if (config?.disabledModels) { + const comboSelectors = new Set(Object.entries(config.combos ?? {}) + .flatMap(([id, combo]) => comboDisabledModelSelectors(id, combo))); + config.disabledModels = config.disabledModels.filter(selector => + !selector.startsWith(`${name}/`) || comboSelectors.has(selector)); + } + if (!loginConnection(name, next)) { + next.initialModelSelection = { version: 1, registrationId: randomUUID(), status: "pending" }; + } } } diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 50db211a67..579dc1d794 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -995,7 +995,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { reconcileInitialModelSelections(config, listed, ["vendor"]); expect(config.providers.vendor.initialModelSelection?.modelCount).toBe(19); expect(config.disabledModels).toBeUndefined(); - const withAlias = fixture(); - reconcileInitialModelSelections(withAlias, [...listed, { provider: "vendor", id: "displayed-alias" }], ["vendor"]); - expect(withAlias.providers.vendor.initialModelSelection?.status).toBe("all-off"); - expect(withAlias.disabledModels).toContain("vendor/displayed-alias"); + const withExtraRow = fixture(); + reconcileInitialModelSelections(withExtraRow, [...listed, { provider: "vendor", id: "additional-catalog-id" }], ["vendor"]); + expect(withExtraRow.providers.vendor.initialModelSelection?.status).toBe("all-off"); + expect(withExtraRow.disabledModels).toContain("vendor/additional-catalog-id"); }); test("OFF preserves unrelated exclusions, uses canonical IDs and never repeats", () => { @@ -169,6 +169,37 @@ describe("initial provider model switches", () => { expect(saved.disabled).not.toBe(true); }); + test("new registration clears orphaned OFF selectors without touching other providers", async () => { + const config: OcxConfig = { + port: 0, defaultProvider: "openai", providers: {}, + disabledModels: ["vendor/model-0", "vendor/a/b", "vendor-old/keep", "other/keep"], + modelDiscovery: { + newModelPolicy: "off", + knownModels: { vendor: { ids: ["old"], removed: [], updatedAt: "old" }, other: { ids: ["keep"], removed: [], updatedAt: "old" } }, + recentArrivals: { vendor: [{ id: "old", at: "old" }] }, + }, + }; + configStore.saveConfig(config); + const provider = { adapter: "openai-chat", baseUrl: "http://127.0.0.1:11434/v1", allowPrivateNetwork: true, liveModels: false, models: ["model-0", "a/b"] }; + expect((await api(config, "/api/providers", { name: "vendor", provider }, "POST")).status).toBe(200); + await api(config, "/api/models"); + expect(configStore.loadConfig().disabledModels).toEqual(["vendor-old/keep", "other/keep"]); + expect(config.providers.vendor.initialModelSelection?.status).toBe("ready"); + expect(config.providers.vendor.disabled).not.toBe(true); + expect(configStore.loadConfig().modelDiscovery?.knownModels?.vendor).toBeUndefined(); + expect(configStore.loadConfig().modelDiscovery?.knownModels?.other?.ids).toEqual(["keep"]); + expect(configStore.loadConfig().modelDiscovery?.recentArrivals?.vendor).toBeUndefined(); + }); + + test("new-registration cleanup preserves a current combo alias sharing the namespace", () => { + const config = fixture(); + config.disabledModels = ["vendor/combo-alias", "vendor/orphan", "other/keep"]; + config.combos = { retained: { alias: "vendor/combo-alias", targets: [{ provider: "other", model: "keep" }] } }; + const provider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://models.example.test/v1" }; + initializeProviderModelSelection("vendor", provider, undefined, config); + expect(config.disabledModels).toEqual(["vendor/combo-alias", "other/keep"]); + }); + test("key-login commit initializes new rows and preserves choices during key replacement", async () => { const config: OcxConfig = { port: 0, defaultProvider: "vendor", providers: {} }; configStore.saveConfig(config); From 4198c22569aa35d1727f9cf05e4aea4b6cb746a2 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 14:03:31 +0900 Subject: [PATCH 3/6] fix(models): cover effective auth and batch provider registration --- .../fr/reference/configuration/providers.md | 13 ++++ .../ja/reference/configuration/providers.md | 13 ++++ .../ko/reference/configuration/providers.md | 13 ++++ .../docs/reference/configuration/providers.md | 13 ++++ .../ru/reference/configuration/providers.md | 13 ++++ .../tr/reference/configuration/providers.md | 13 ++++ .../reference/configuration/providers.md | 13 ++++ .../reference/configuration/providers.md | 13 ++++ .../initial-model-selection-runtime.ts | 6 +- src/providers/initial-model-selection.ts | 3 +- src/providers/key-store.ts | 12 +++- src/router.ts | 7 +-- src/server/management/provider-routes.ts | 11 ++++ structure/03_catalog-and-subagents.md | 15 +++++ .../providers/initial-model-selection.test.ts | 59 +++++++++++++++++++ 15 files changed, 208 insertions(+), 9 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 992f097e3d..b9d0ee986d 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -6,6 +6,19 @@ description: Entrées du fournisseur, authentification, points de terminaison, c Un fournisseur indique à opencodex où se trouve un modèle, quel adaptateur de protocole il utilise et comment les requêtes sont authentifiées. +## Sélection des modèles à l’inscription + +Une nouvelle connexion sans OAuth attend une liste de modèles fiable avant de les exposer. Si l’onglet Models contient au moins 20 lignes distinctes, tous les interrupteurs de modèles sont initialement OFF ; le fournisseur reste ACTIVE. Les connexions utilisant effectivement OAuth ou la connexion ChatGPT conservent leurs valeurs par défaut. + +Cette règle ne s’applique qu’à l’inscription d’un nouveau fournisseur. Les mises à jour, reconnexions et remplacements de clé préservent les choix existants. Après l’initialisation, activez les modèles souhaités dans Models ou avec les commandes ci-dessous. La politique distincte concernant les nouveaux modèles reste inchangée. Remplacez `` par un ID de la liste. + +```sh +ocx models live --provider openrouter +ocx models enable 'openrouter/' +ocx models disable 'openrouter/' +ocx models provider openrouter on +``` + ## Champs de premier niveau liés aux fournisseurs | Champ | Type | Par défaut | Signification | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 922a77f3c3..faa569ac3a 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -5,6 +5,19 @@ description: プロバイダー エントリ、認証、エンドポイント、 プロバイダーは、opencodex に、モデルが存在する場所、モデルが通信するワイヤー アダプター、およびリクエストの認証方法を伝えます。 +## 初回登録時のモデル選択 + +新しい非 OAuth 接続では、信頼できるモデル一覧の取得が完了するまでモデルの公開を保留します。Models タブの重複しないモデル行が20個以上なら、モデルのスイッチをすべて OFF にします。プロバイダー自体は ACTIVE のままです。実際の認証方式が OAuth または ChatGPT ログインなら既定値を維持します。 + +初回のプロバイダー登録にのみ適用され、更新、再ログイン、キー交換で既存の選択をリセットしません。初期設定後は Models または以下の CLI で必要なモデルを有効にできます。後から追加されるモデルのポリシーは変更しません。`` を一覧の ID に置き換えてください。 + +```sh +ocx models live --provider openrouter +ocx models enable 'openrouter/' +ocx models disable 'openrouter/' +ocx models provider openrouter on +``` + ## プロバイダー関連のトップレベルフィールド |フィールド |タイプ |デフォルト |意味 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index da0b770ba2..6d900d303c 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -5,6 +5,19 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할 공급자는 opencodex에 모델의 위치, 사용하는 와이어 어댑터, 요청 인증 방식을 알려줍니다. +## 처음 등록할 때의 모델 선택 + +신규 비-OAuth 연결은 모델 목록 조회가 끝날 때까지 모델 노출을 보류합니다. Models 탭의 중복 없는 모델 행이 20개 이상이면 모델 스위치를 모두 OFF로 설정합니다. 프로바이더는 활성 상태를 유지합니다. 실제 인증 방식이 OAuth나 ChatGPT 로그인인 연결은 기존 기본값을 유지합니다. + +처음 등록할 때만 적용하며 업데이트, 재로그인, 키 교체로 기존 선택을 초기화하지 않습니다. 초기 설정이 끝나면 Models 탭이나 아래 CLI 명령으로 필요한 모델을 켤 수 있습니다. 이후 새 모델이 추가될 때의 정책은 별도입니다. ``는 목록에 나온 ID로 바꾸세요. + +```sh +ocx models live --provider openrouter +ocx models enable 'openrouter/' +ocx models disable 'openrouter/' +ocx models provider openrouter on +``` + ## 공급자 관련 최상위 필드 | 필드 | 타입 | 기본값 | 의미 | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 7d6d4fa1d7..a188accace 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -6,6 +6,19 @@ description: Provider entries, authentication, endpoints, model catalogs, quotas A provider tells opencodex where a model lives, which wire adapter it speaks, and how requests are authenticated. +## Initial model selection + +New non-OAuth connections wait for a reliable model list before exposing models. If that list contains at least 20 distinct Models-tab rows, all model switches start OFF; the provider itself stays ACTIVE. OAuth and ChatGPT-login connections keep their defaults, based on the effective authentication mode. + +This runs only for a new provider registration. Existing selections survive updates, re-login and key replacement. After initialization, enable the models you need in Models or with the CLI below; the separate new-model-arrival policy is unchanged. Replace `` with an ID from the list. + +```sh +ocx models live --provider openrouter +ocx models enable 'openrouter/' +ocx models disable 'openrouter/' +ocx models provider openrouter on +``` + ## Provider-related top-level fields | Field | Type | Default | Meaning | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 26ab107b45..f6b3725705 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -6,6 +6,19 @@ description: Записи провайдеров, аутентификация, Провайдер сообщает opencodex, где живёт модель, на каком wire-adapter'е она работает и как аутентифицируются запросы. +## Выбор моделей при первой регистрации + +Новое подключение без OAuth не публикует модели до получения достоверного списка. Если в Models не менее 20 уникальных строк моделей, все переключатели моделей изначально OFF, но сам провайдер остаётся ACTIVE. Подключения, фактически использующие OAuth или вход ChatGPT, сохраняют настройки по умолчанию. + +Правило действует только при регистрации нового провайдера. Обновления, повторный вход и смена ключа не сбрасывают существующий выбор. После инициализации включите нужные модели в Models или командами ниже. Отдельная политика появления новых моделей не меняется. Замените `` на ID из списка. + +```sh +ocx models live --provider openrouter +ocx models enable 'openrouter/' +ocx models disable 'openrouter/' +ocx models provider openrouter on +``` + ## Верхнеуровневые поля, связанные с провайдерами | Поле | Тип | По умолчанию | Значение | diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 217c8e2466..8a70f6e4ff 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -6,6 +6,19 @@ description: Sağlayıcı girdileri, kimlik doğrulama, uç noktalar, model kata Bir sağlayıcı, opencodex'e bir modelin nerede yaşadığını, hangi hat adaptörünü konuştuğunu ve isteklerin nasıl doğrulandığını söyler. +## İlk kayıtta model seçimi + +Yeni OAuth dışı bağlantılar, modelleri göstermeden önce güvenilir bir model listesini bekler. Models sekmesinde en az 20 benzersiz model satırı varsa tüm model anahtarları başlangıçta OFF olur; sağlayıcının kendisi ACTIVE kalır. Gerçekte OAuth veya ChatGPT girişi kullanan bağlantılar varsayılanlarını korur. + +Bu kural yalnızca yeni sağlayıcı kaydında uygulanır. Güncellemeler, yeniden giriş ve anahtar değişimi mevcut seçimleri sıfırlamaz. İlk ayardan sonra gerekli modelleri Models üzerinden veya aşağıdaki CLI komutlarıyla açın. Sonradan gelen yeni modellerin ayrı politikası değişmez. `` yerine listedeki bir ID yazın. + +```sh +ocx models live --provider openrouter +ocx models enable 'openrouter/' +ocx models disable 'openrouter/' +ocx models provider openrouter on +``` + ## Sağlayıcı ile ilgili üst düzey alanlar | Alan | Tip | Varsayılan | Anlamı | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index fe3b8cafe5..a15f57b591 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -5,6 +5,19 @@ description: 提供者条目、身份验证、端点、模型目录、配额、 提供者用于告诉 opencodex 模型位于哪里、使用哪种线协议适配器,以及请求如何进行身份验证。 +## 首次注册时的模型选择 + +新的非 OAuth 连接会等待可靠的模型列表,再公开模型。如果 Models 标签页中去重后的模型行达到20个,所有模型开关初始为 OFF,但提供者本身保持 ACTIVE。实际认证方式为 OAuth 或 ChatGPT 登录的连接保留默认设置。 + +仅在首次注册提供者时应用;更新、重新登录和更换密钥不会重置已有选择。初始化后,可在 Models 或使用以下 CLI 命令启用所需模型。后续新增模型的独立策略不变。请将 `` 替换为列表中的 ID。 + +```sh +ocx models live --provider openrouter +ocx models enable 'openrouter/' +ocx models disable 'openrouter/' +ocx models provider openrouter on +``` + ## 提供者相关顶级字段 | 字段 | 类型 | 默认值 | 含义 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 32511b66b0..dbcd5ea063 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -5,6 +5,19 @@ description: 供應商項目、認證、端點、模型目錄、配額、context 供應商告訴 opencodex 模型在哪裡、它使用哪種 wire adapter,以及請求如何被認證。 +## 首次註冊時的模型選擇 + +新的非 OAuth 連線會先等待可靠的模型清單,再公開模型。如果 Models 分頁中去重後的模型列達到20個,所有模型開關初始為 OFF,但供應商本身保持 ACTIVE。實際驗證方式為 OAuth 或 ChatGPT 登入的連線保留預設值。 + +只在首次註冊供應商時套用;更新、重新登入與更換金鑰不會重設既有選擇。初始化後,可在 Models 或使用以下 CLI 指令啟用所需模型。後續新增模型的獨立政策不變。請將 `` 換成清單中的 ID。 + +```sh +ocx models live --provider openrouter +ocx models enable 'openrouter/' +ocx models disable 'openrouter/' +ocx models provider openrouter on +``` + ## 供應商相關的頂層欄位 | 欄位 | 型別 | 預設值 | 意義 | diff --git a/src/providers/initial-model-selection-runtime.ts b/src/providers/initial-model-selection-runtime.ts index e0ab1b6e60..0039ee05aa 100644 --- a/src/providers/initial-model-selection-runtime.ts +++ b/src/providers/initial-model-selection-runtime.ts @@ -19,14 +19,16 @@ function inventoryIdentity(config: OcxConfig): unknown { const validated = validateConfigCandidate(config); if (!validated.ok) return null; // Compare all inventory-producing configuration, including custom rows and combos. - // Normalize schema defaults and ignore only completed-selection state and switch values. + // Normalize schema defaults, ignoring completed-selection state and switch values. + // Listener binding intentionally differs between live and disk after a port/host edit; + // it cannot affect provider discovery and must not leave registration pending forever. // The incarnation remains: identical delete/re-add is NOT the same registration. const providers = Object.fromEntries(Object.entries(validated.config.providers).map(([name, provider]) => [name, { ...provider, initialModelSelection: initialModelSelection(provider)?.registrationId, }])); // Ephemeral only: never log this value, which may contain credentials. - return JSON.parse(JSON.stringify({ ...validated.config, providers, disabledModels: undefined })); + return JSON.parse(JSON.stringify({ ...validated.config, providers, disabledModels: undefined, port: undefined, hostname: undefined })); } export function captureInitialSelectionBaseline(config: OcxConfig): InitialSelectionBaseline | null { diff --git a/src/providers/initial-model-selection.ts b/src/providers/initial-model-selection.ts index 50720f3cff..d00ae9c19f 100644 --- a/src/providers/initial-model-selection.ts +++ b/src/providers/initial-model-selection.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "./registry"; import { routedSlug, slugEquivalenceKey } from "./slug-codec"; import { comboDisabledModelSelectors } from "../combos/types"; +import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./key-store"; export const INITIAL_MODEL_SELECTION_THRESHOLD = 20; type Selection = NonNullable; @@ -30,7 +31,7 @@ function loginConnection(name: string, provider: OcxProviderConfig): boolean { if (entry && providerMatchesRegistryTransport(name, provider)) { if (entry.authKind === "forward") return true; if (entry.authKind === "oauth") { - return !(entry.allowKeyAuthOverride === true && provider.authMode === "key"); + return !providerUsesKeyAuthOverride(entry, provider, resolveProviderApiKey(provider.apiKey)); } } return provider.authMode === "oauth" || provider.authMode === "forward"; diff --git a/src/providers/key-store.ts b/src/providers/key-store.ts index bf8ec3198f..12e4ce6cb7 100644 --- a/src/providers/key-store.ts +++ b/src/providers/key-store.ts @@ -1,6 +1,17 @@ import { createRequire } from "node:module"; import { resolveEnvValue, saveConfigPreservingClaudeCode } from "../config"; import type { OcxConfig, OcxProviderConfig } from "../types"; +import type { ProviderRegistryEntry } from "./registry"; + +/** Shared with routing: a key-mode override is effective only while its key resolves. */ +export function providerUsesKeyAuthOverride( + entry: Pick, + provider: Pick, + resolvedKey: string | undefined, +): boolean { + return entry.authKind === "oauth" && entry.allowKeyAuthOverride === true + && provider.authMode === "key" && typeof resolvedKey === "string" && resolvedKey.trim().length > 0; +} /** * Opt-in OS keychain storage for provider API keys (#1221). @@ -194,4 +205,3 @@ export function restoreProviderKeyFromKeychain(config: OcxConfig, name: string): saveConfigPreservingClaudeCode(config); return { ok: true, restored: resolved.size }; } - diff --git a/src/router.ts b/src/router.ts index 1dcd78481e..4af0ea497e 100644 --- a/src/router.ts +++ b/src/router.ts @@ -9,7 +9,7 @@ import { } from "./combos"; import type { NormalizedComboConfig } from "./combos/types"; import { hasOwnProvider } from "./config/provider-name"; -import { resolveProviderApiKey } from "./providers/key-store"; +import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./providers/key-store"; import { assertProviderDestinationAllowed } from "./lib/destination-policy"; import { redactSecretString, redactUrlForLog } from "./lib/redact"; import { @@ -300,10 +300,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider const repairLegacyMimoFreeAuth = providerName === "mimo-free" && staticModelCatalog && (provider.authMode === undefined || provider.authMode === "local"); - const explicitKeyOverride = registryEntry.authKind === "oauth" - && registryEntry.allowKeyAuthOverride === true - && provider.authMode === "key" - && resolvedApiKey !== undefined; + const explicitKeyOverride = providerUsesKeyAuthOverride(registryEntry, provider, resolvedApiKey); const canonicalAuthMode = explicitKeyOverride ? "key" : repairLegacyMimoFreeAuth diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 579dc1d794..e26420e003 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -288,6 +288,10 @@ function adoptProviderEditorCandidate(live: OcxConfig, persisted: OcxConfig): vo else live.customModels = structuredClone(persisted.customModels); if (persisted.providerContextCaps === undefined) delete live.providerContextCaps; else live.providerContextCaps = structuredClone(persisted.providerContextCaps); + if (persisted.disabledModels === undefined) delete live.disabledModels; + else live.disabledModels = [...persisted.disabledModels]; + if (persisted.modelDiscovery === undefined) delete live.modelDiscovery; + else live.modelDiscovery = structuredClone(persisted.modelDiscovery); } /** @@ -832,8 +836,15 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise/` ids. The diff --git a/tests/providers/initial-model-selection.test.ts b/tests/providers/initial-model-selection.test.ts index d67f6f35cd..a241359ac1 100644 --- a/tests/providers/initial-model-selection.test.ts +++ b/tests/providers/initial-model-selection.test.ts @@ -129,6 +129,43 @@ describe("initial provider model switches", () => { expect(config.providers.xai.initialModelSelection).toBeUndefined(); }); + test("an unresolved mixed-auth key follows the router's OAuth exemption", () => { + const env = "OCX_INITIAL_SELECTION_KEY_FIXTURE"; + const previous = process.env[env]; + delete process.env[env]; + try { + const provider = providerConfigSeed(getProviderRegistryEntry("xai")!); + provider.authMode = "key"; + provider.apiKey = `\${${env}}`; + initializeProviderModelSelection("xai", provider); + expect(provider.initialModelSelection).toBeUndefined(); + process.env[env] = "fixture-key"; + initializeProviderModelSelection("xai", provider); + expect(provider.initialModelSelection?.status).toBe("pending"); + } finally { + if (previous === undefined) delete process.env[env]; + else process.env[env] = previous; + } + }); + + test("intentional live/disk listener differences do not fence initial selection forever", async () => { + const config = fixture(); + configStore.saveConfig(config); + const baseline = configStore.loadConfig(); + const edited = configStore.loadConfig(); + edited.port = 23456; + edited.hostname = "127.0.0.2"; + configStore.saveConfig(edited); + configStore.reconcileLiveConfigFromDisk(config, baseline); + expect(config.port).toBe(0); + await resolvePendingInitialModelSelection(config); + expect(config.providers.vendor.initialModelSelection?.status).toBe("all-off"); + expect(config.port).toBe(0); + expect(configStore.loadConfig().port).toBe(23456); + expect(configStore.loadConfig().hostname).toBe("127.0.0.2"); + expect(configStore.loadConfig().disabledModels).toHaveLength(20); + }); + test("management discovery finalizes and persists with Codex integration OFF", async () => { const config = fixture(); configStore.saveConfig(config); @@ -216,6 +253,28 @@ describe("initial provider model switches", () => { expect(saved.initialModelSelection?.registrationId).toBe(first.initialModelSelection?.registrationId); }); + test("batch editor creates pending state server-side without resetting edited existing rows", async () => { + const config = fixture(); + config.providers.vendor.baseUrl = "http://127.0.0.1:11434/v1"; + config.providers.vendor.allowPrivateNetwork = true; + config.disabledModels = ["batch/model-0", "other/keep"]; + configStore.saveConfig(config); + const registrationId = config.providers.vendor.initialModelSelection?.registrationId; + const baseline = providerEditorConfigDTO(config); + const next = structuredClone(baseline); + next.providers.vendor.selectedModels = ["model-1"]; + next.providers.batch = { adapter: "openai-chat", baseUrl: "http://127.0.0.1:11435/v1", allowPrivateNetwork: true, liveModels: false, models: ["model-0"] }; + const response = await api(config, "/api/providers", { baseline, next }); + expect(response.status).toBe(200); + const saved = configStore.loadConfig(); + expect(saved.providers.batch.initialModelSelection?.status).toBe("pending"); + expect(saved.providers.batch.disabled).not.toBe(true); + expect(saved.providers.vendor.initialModelSelection?.registrationId).toBe(registrationId); + expect(saved.providers.vendor.selectedModels).toEqual(["model-1"]); + expect(saved.disabledModels).toEqual(["other/keep"]); + expect(config.disabledModels).toEqual(["other/keep"]); + }); + test("degraded discovery does not complete initialization or expose models", () => { const config = fixture(); configStore.saveConfig(config); From 8699a6ebc4db5a2a2973e9d9ed14e10c26d00cc3 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 14:12:53 +0900 Subject: [PATCH 4/6] test(models): expect registration state in new batch provider rows --- tests/providers/provider-config-batch-management.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/providers/provider-config-batch-management.test.ts b/tests/providers/provider-config-batch-management.test.ts index ae3af067f5..c0646c98f9 100644 --- a/tests/providers/provider-config-batch-management.test.ts +++ b/tests/providers/provider-config-batch-management.test.ts @@ -213,7 +213,14 @@ describe("atomic provider editor batch", () => { headers: { "x-beta-private": "keep-me" }, project: "private-beta-project", }); - expect(persisted.providers.gamma).toEqual(next.providers.gamma); + expect(persisted.providers.gamma).toEqual({ + ...next.providers.gamma, + initialModelSelection: { + version: 1, + registrationId: expect.stringMatching(/^[0-9a-f-]{36}$/), + status: "pending", + }, + }); expect(liveConfig.defaultProvider).toBe("beta"); expect(liveConfig.providers).toEqual(persisted.providers); expect(catalogRefreshes).toBe(1); From 2cc90b4471e1d86d4f6733d2f5f7e032f2bce995 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 14:25:31 +0900 Subject: [PATCH 5/6] test(models): use valid baseline configurations for registration cases --- tests/providers/initial-model-selection.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/providers/initial-model-selection.test.ts b/tests/providers/initial-model-selection.test.ts index a241359ac1..46fc586cf0 100644 --- a/tests/providers/initial-model-selection.test.ts +++ b/tests/providers/initial-model-selection.test.ts @@ -183,7 +183,7 @@ describe("initial provider model switches", () => { }); test("POST creation stamps its own pending state and overwrite preserves selections", async () => { - const config: OcxConfig = { port: 0, defaultProvider: "openai", providers: {}, clientIntegrations: { codex: false } }; + const config: OcxConfig = { ...configStore.getDefaultConfig(), port: 0, clientIntegrations: { codex: false } }; configStore.saveConfig(config); const provider = { adapter: "openai-chat", baseUrl: "http://127.0.0.1:11434/v1", allowPrivateNetwork: true, @@ -208,7 +208,7 @@ describe("initial provider model switches", () => { test("new registration clears orphaned OFF selectors without touching other providers", async () => { const config: OcxConfig = { - port: 0, defaultProvider: "openai", providers: {}, + ...configStore.getDefaultConfig(), port: 0, clientIntegrations: { codex: false }, disabledModels: ["vendor/model-0", "vendor/a/b", "vendor-old/keep", "other/keep"], modelDiscovery: { newModelPolicy: "off", @@ -238,7 +238,7 @@ describe("initial provider model switches", () => { }); test("key-login commit initializes new rows and preserves choices during key replacement", async () => { - const config: OcxConfig = { port: 0, defaultProvider: "vendor", providers: {} }; + const config: OcxConfig = { ...configStore.getDefaultConfig(), port: 0, clientIntegrations: { codex: false } }; configStore.saveConfig(config); const provider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://models.example.test/v1", apiKey: "fixture-first" }; await commitKeyLoginProvider(config, "vendor", provider); From c5ad48c19504b7666781dc95e5ddd0e657021f98 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 15:03:31 +0900 Subject: [PATCH 6/6] fix(models): keep registration metadata out of JSON editor writes --- gui/src/hooks/useJsonConfigEditor.ts | 1 + gui/tests/use-json-config-editor.test.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/gui/src/hooks/useJsonConfigEditor.ts b/gui/src/hooks/useJsonConfigEditor.ts index f27110ab90..39b8cc2a38 100644 --- a/gui/src/hooks/useJsonConfigEditor.ts +++ b/gui/src/hooks/useJsonConfigEditor.ts @@ -10,6 +10,7 @@ const PROVIDER_EDITOR_DERIVED_FIELDS = [ "hasApiKey", "hasHeaders", "xaiResponsesOptInState", + "initialModelSelection", ] as const; type ProviderEditorConfig = { diff --git a/gui/tests/use-json-config-editor.test.tsx b/gui/tests/use-json-config-editor.test.tsx index 787db615e1..0d35ac2cc3 100644 --- a/gui/tests/use-json-config-editor.test.tsx +++ b/gui/tests/use-json-config-editor.test.tsx @@ -23,6 +23,7 @@ const config: Config = { allowPrivateNetwork: true, hasApiKey: true, hasHeaders: true, + initialModelSelection: { version: 1, registrationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", status: "pending" }, note: "derived registry note", }, beta: {