From 13c7e9b21c133afe9a115764fd66a538c45172c0 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 13:50:02 +0900 Subject: [PATCH 01/11] 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 25afb0a7c20..5aabfea7013 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 5e579748ef1..84d363b8959 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 be3f0ac8b6f..9f5551447b7 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 f9ac3b5b214..fb4c12a7545 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 fa7e0b4b003..f810bfb4229 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 0c8b00a2cf4..6288f9bf873 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 b84bbcb9091..df765a78532 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 9847e5dd976..16035236228 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 68764d2f719..575d3cbc48b 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 36233983098..15b094347cc 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 437b61e6d69..1952e81a511 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 00000000000..e0ab1b6e600 --- /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 00000000000..0b1ba3541b4 --- /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 c0c7b77fd1d..ccc23c5ef55 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 75bcaf36ae9..51ebc746cb7 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 601444a958f..a364694b2cf 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 f6a6bf767cf..50db211a676 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 691fb01e1cf..79651e00126 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 b58adfd8cbe..402cc973b04 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 febaf976cba..37d8c697988 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 36602be9866..dd350c6aaa6 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 00000000000..cdc9ab9dbde --- /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 02/11] 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 fb4c12a7545..c81e6bede2f 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 15b094347cc..1a8bd071578 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 1952e81a511..7f6605586c0 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 0b1ba3541b4..50720f3cff8 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 50db211a676..579dc1d7949 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 03/11] 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 992f097e3d2..b9d0ee986d1 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 922a77f3c38..faa569ac3a6 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 da0b770ba22..6d900d303c1 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 7d6d4fa1d72..a188accace0 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 26ab107b45d..f6b37257055 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 217c8e24667..8a70f6e4ff1 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 fe3b8cafe51..a15f57b5911 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 32511b66b04..dbcd5ea063b 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 e0ab1b6e600..0039ee05aae 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 50720f3cff8..d00ae9c19f0 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 bf8ec3198f6..12e4ce6cb7a 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 1dcd78481e2..4af0ea497ed 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 579dc1d7949..e26420e003f 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 d67f6f35cd5..a241359ac13 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 04/11] 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 ae3af067f5c..c0646c98f9e 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 05/11] 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 a241359ac13..46fc586cf00 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 06/11] 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 f27110ab904..39b8cc2a38f 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 787db615e10..0d35ac2cc31 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: { From 87adf52ef48f811c7883ec9dbc3aa34e9b98fe67 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 15:25:38 +0900 Subject: [PATCH 07/11] feat(onboarding): guide registrations to model selection --- .../020_registration_guidance.md | 37 ++++- .../021_registration_notice.png | Bin 0 -> 47461 bytes .../022_models_all_off.png | Bin 0 -> 78595 bytes .../023_onboarding_verification.md | 20 +++ .../fr/reference/configuration/providers.md | 6 +- .../ja/reference/configuration/providers.md | 6 +- .../ko/reference/configuration/providers.md | 6 +- .../docs/reference/configuration/providers.md | 6 +- .../ru/reference/configuration/providers.md | 6 +- .../tr/reference/configuration/providers.md | 6 +- .../reference/configuration/providers.md | 6 +- .../reference/configuration/providers.md | 6 +- gui/src/components/CodexAccountPool.tsx | 10 ++ gui/src/components/ProviderModelsNotice.tsx | 63 ++++++++ .../ProviderWorkspaceShell.tsx | 9 +- gui/src/hooks/useJsonConfigEditor.ts | 6 +- gui/src/i18n/de.ts | 9 ++ gui/src/i18n/en.ts | 9 ++ gui/src/i18n/fr.ts | 9 ++ gui/src/i18n/ja.ts | 9 ++ gui/src/i18n/ko.ts | 9 ++ gui/src/i18n/ru.ts | 9 ++ gui/src/i18n/tr.ts | 9 ++ gui/src/i18n/zh-TW.ts | 9 ++ gui/src/i18n/zh.ts | 9 ++ gui/src/pages/Models.tsx | 12 +- gui/src/pages/Providers.tsx | 42 ++++- gui/src/pages/models-shared.ts | 1 + gui/src/pages/providers-page-modals.tsx | 4 + gui/src/pages/providers-shared.ts | 1 + gui/src/pages/use-provider-models-notice.ts | 34 ++++ gui/src/pages/use-providers-fetch.ts | 13 +- gui/tests/models-empty-provider.test.tsx | 9 +- gui/tests/provider-models-notice.test.tsx | 149 ++++++++++++++++++ .../providers-codex-completion-toast.test.tsx | 30 ++++ gui/tests/use-json-config-editor.test.tsx | 15 +- scripts/test-layout/layout.json | 1 + src/cli/account-auth.ts | 9 +- src/cli/init.ts | 2 + src/cli/model-selection-guidance.ts | 27 ++++ src/cli/models-runtime.ts | 3 +- src/cli/provider.ts | 3 + src/oauth/login-cli.ts | 3 + structure/05_gui-and-management-api.md | 2 +- tests/cli/cli-account.test.ts | 16 ++ tests/cli/cli-provider.test.ts | 1 + tests/cli/model-selection-guidance.test.ts | 27 ++++ tests/fixtures/test-layout-expected.json | 1 + 48 files changed, 637 insertions(+), 42 deletions(-) create mode 100644 devlog/_plan/260905_provider_registration_selection/021_registration_notice.png create mode 100644 devlog/_plan/260905_provider_registration_selection/022_models_all_off.png create mode 100644 devlog/_plan/260905_provider_registration_selection/023_onboarding_verification.md create mode 100644 gui/src/components/ProviderModelsNotice.tsx create mode 100644 gui/src/pages/use-provider-models-notice.ts create mode 100644 gui/tests/provider-models-notice.test.tsx create mode 100644 src/cli/model-selection-guidance.ts create mode 100644 tests/cli/model-selection-guidance.test.ts diff --git a/devlog/_plan/260905_provider_registration_selection/020_registration_guidance.md b/devlog/_plan/260905_provider_registration_selection/020_registration_guidance.md index 7a9b05a1e7c..5a7159e1b21 100644 --- a/devlog/_plan/260905_provider_registration_selection/020_registration_guidance.md +++ b/devlog/_plan/260905_provider_registration_selection/020_registration_guidance.md @@ -41,7 +41,21 @@ toast for new registration). Capture whether provider existed before add/login. Wire modal-local OAuth and catalog OAuth completion through the same notice owner; existing account management/relogin continues Accounts navigation and does not reset selections. For initial Codex provider creation, onCodexAdded also shows -Models guidance; avoid showing it merely for every added pool account. +Models guidance. Explicit account/login completion also receives generic guidance +(including the pre-seeded OpenAI provider); it never resets model choices. Existing +Accounts navigation remains underneath the notice. Historical all-OFF copy is +shown only for a newly created provider, not as a claim that re-login reset switches. + +Implementation owners after source recheck: NEW +gui/src/pages/use-provider-models-notice.ts owns the operation token and render-local +notice lifecycle. ProviderWorkspaceShell's existing /api/selected-models completion +invokes a stable onModelsSettled callback; only an active notice triggers a later +config refresh. Reuse its existing refresh token for Retry, with no duplicate model +fetch and no new poll timer. useProvidersFetch adds a latest-request guard so an +earlier pending config response cannot overwrite the post-discovery snapshot. +The refresh result is explicit (applied/failed/superseded), with one bounded retry +for supersession. Failed config reads never become success guidance. API-base +changes clear both the active operation and render state, including A→B→A. ### MODIFY gui/src/pages/use-providers-oauth.ts as needed @@ -49,6 +63,18 @@ Forward an existing new-provider boolean/name at completion through its callback without touching credential polling, reauth identity rules or secrets. Code submission is not success; popup waits for existing login-settled signal. +Embedded/standalone Codex account add/reauth is a separate completion owner. +Reuse the same ProviderModelsNotice renderer directly in CodexAccountPool for +generic forward-auth guidance, preserving its pool state and catalog-refresh +warning. It does not need a model-count fetch or four callback-prop forwarding +layers. Cover this path as well as Providers' top-level modal completion. + +The JSON editor reports newly added provider names to Providers after successful +save; show one generic notice for a batch and the normal per-provider notice for +a single new row. It also strips initialModelSelection from editor payloads; that +two-line compatibility fix is carried in core c5ad48c19, already an ancestor of +this branch. Core final CI must validate that updated head before merge. + ### MODIFY gui/src/pages/providers-shared.ts Add the sanitized initialModelSelection read-only field to ProvidersConfig. Keep @@ -76,8 +102,8 @@ ocx models disable ocx models provider on ``` -Use a real ID from a trustworthy result where available; otherwise an explicitly -labeled placeholder. Include `ocx start` prerequisite when the proxy is absent, +Use the exact ID printed by `live` (native or namespaced), represented in examples +by an explicitly labeled, quoted placeholder. Include `ocx start` prerequisite when the proxy is absent, and `ocx sync` retry guidance when discovery remains pending. No credentials in commands or messages. No shell execution from the builder. @@ -120,6 +146,11 @@ commands or messages. No shell execution from the builder. - NEW tests/cli/model-selection-guidance.test.ts with both layout manifests. - Preserve existing auth URL/credential tests; no network/API-key requirements. +P recheck: core state shape is version1 + registrationId + status + modelCount; +safeConfigDTO exposes it read-only. Public threshold docs already landed in the core +layer, so this phase adds only registration-guidance text, not a second policy rewrite. +Core exact-head CI33947171242 passed at2cc90b447 before this cycle began. + Local checks: TypeScript, GUI lint/i18n, GUI build, docs build, whitespace. All test suites run remotely in GitHub CI, not locally. Runtime UI proof is a manual isolated fake-provider scenario, not a repository test suite: new 20-model key diff --git a/devlog/_plan/260905_provider_registration_selection/021_registration_notice.png b/devlog/_plan/260905_provider_registration_selection/021_registration_notice.png new file mode 100644 index 0000000000000000000000000000000000000000..c1e5a8e9647f70c5a692b28f362bff30c232c3f6 GIT binary patch literal 47461 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�&+xds6=XY+5VHxOP@6 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 literal 0 HcmV?d00001 diff --git a/devlog/_plan/260905_provider_registration_selection/022_models_all_off.png b/devlog/_plan/260905_provider_registration_selection/022_models_all_off.png new file mode 100644 index 0000000000000000000000000000000000000000..64728199c8e8ec83fe5d030f5622e35b38a0ec7f GIT binary patch literal 78595 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' -ocx models disable 'openrouter/' +ocx models enable '' +ocx models disable '' ocx models provider openrouter on ``` +Après une inscription ou une connexion OAuth dans l’interface, une boîte de dialogue permet d’ouvrir Models. La CLI affiche les commandes de gestion des modèles, aussi présentes dans les étapes suivantes du JSON. `--no-wait` indique une connexion en attente, pas terminée. Lancez le proxy avec `ocx start` avant les commandes de modèles en direct. + ## 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 faa569ac3a6..bd33d34a3fc 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -13,11 +13,13 @@ description: プロバイダー エントリ、認証、エンドポイント、 ```sh ocx models live --provider openrouter -ocx models enable 'openrouter/' -ocx models disable 'openrouter/' +ocx models enable '' +ocx models disable '' ocx models provider openrouter on ``` +GUI で登録または OAuth ログインが完了すると、Models ページへ移動できる案内が表示されます。CLI はモデル管理コマンドを出力し、JSON にも次の操作を含めます。`--no-wait` は完了ではなくログイン待機を示します。ライブモデルのコマンドを使う前に `ocx start` でプロキシを起動してください。 + ## プロバイダー関連のトップレベルフィールド |フィールド |タイプ |デフォルト |意味 | 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 6d900d303c1..b263aa11ff1 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -13,11 +13,13 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할 ```sh ocx models live --provider openrouter -ocx models enable 'openrouter/' -ocx models disable 'openrouter/' +ocx models enable '' +ocx models disable '' ocx models provider openrouter on ``` +GUI에서 등록이나 OAuth 로그인을 마치면 Models 페이지로 이동하는 안내 팝업이 뜹니다. CLI는 모델 관리 명령을 출력하며 JSON 응답에도 다음 단계가 포함됩니다. `--no-wait`는 로그인 완료가 아닌 대기 상태를 표시합니다. 실시간 모델 명령을 쓰기 전에 `ocx start`로 프록시를 시작하세요. + ## 공급자 관련 최상위 필드 | 필드 | 타입 | 기본값 | 의미 | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index a188accace0..3c19a5066ff 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -14,11 +14,13 @@ This runs only for a new provider registration. Existing selections survive upda ```sh ocx models live --provider openrouter -ocx models enable 'openrouter/' -ocx models disable 'openrouter/' +ocx models enable '' +ocx models disable '' ocx models provider openrouter on ``` +After GUI registration or OAuth login, the confirmation dialog opens the Models page. CLI registration and login print model-management commands; JSON includes structured next steps. `--no-wait` reports pending login, not completion. Start the proxy with `ocx start` before using live model commands. + ## 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 f6b37257055..a058fdb8426 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -14,11 +14,13 @@ description: Записи провайдеров, аутентификация, ```sh ocx models live --provider openrouter -ocx models enable 'openrouter/' -ocx models disable 'openrouter/' +ocx models enable '' +ocx models disable '' ocx models provider openrouter on ``` +После регистрации или входа OAuth в интерфейсе диалог предлагает открыть Models. CLI выводит команды управления моделями; JSON содержит следующие шаги. `--no-wait` означает ожидание входа, а не завершение. Перед командами для актуального списка моделей запустите прокси через `ocx start`. + ## Верхнеуровневые поля, связанные с провайдерами | Поле | Тип | По умолчанию | Значение | 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 8a70f6e4ff1..4213ab60019 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -14,11 +14,13 @@ Bu kural yalnızca yeni sağlayıcı kaydında uygulanır. Güncellemeler, yenid ```sh ocx models live --provider openrouter -ocx models enable 'openrouter/' -ocx models disable 'openrouter/' +ocx models enable '' +ocx models disable '' ocx models provider openrouter on ``` +Arayüzde kayıt veya OAuth girişi tamamlanınca Models sayfasını açan bir bilgilendirme penceresi gösterilir. CLI model yönetimi komutlarını yazdırır; JSON sonraki adımları içerir. `--no-wait` tamamlanmış değil, bekleyen girişi bildirir. Canlı model komutlarından önce proxy’yi `ocx start` ile başlatın. + ## 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 a15f57b5911..f2d245b5ec0 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 @@ -13,11 +13,13 @@ description: 提供者条目、身份验证、端点、模型目录、配额、 ```sh ocx models live --provider openrouter -ocx models enable 'openrouter/' -ocx models disable 'openrouter/' +ocx models enable '' +ocx models disable '' ocx models provider openrouter on ``` +在界面中完成注册或 OAuth 登录后,提示框可打开 Models 页面。CLI 会输出模型管理命令,JSON 也包含后续步骤。`--no-wait` 表示登录仍在等待中,并非已完成。使用实时模型命令前,请先运行 `ocx start` 启动代理。 + ## 提供者相关顶级字段 | 字段 | 类型 | 默认值 | 含义 | 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 dbcd5ea063b..7a27de4f61f 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 @@ -13,11 +13,13 @@ description: 供應商項目、認證、端點、模型目錄、配額、context ```sh ocx models live --provider openrouter -ocx models enable 'openrouter/' -ocx models disable 'openrouter/' +ocx models enable '' +ocx models disable '' ocx models provider openrouter on ``` +在介面中完成註冊或 OAuth 登入後,提示視窗可開啟 Models 頁面。CLI 會輸出模型管理指令,JSON 也包含後續步驟。`--no-wait` 表示登入仍在等待中,並非已完成。使用即時模型指令前,請先執行 `ocx start` 啟動代理。 + ## 供應商相關的頂層欄位 | 欄位 | 型別 | 預設值 | 意義 | diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 13f4f7a26f7..ea1f1fe3000 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -25,6 +25,8 @@ import { quotaAutoRefreshAvailability } from "../codex-quota-utils"; // Single definition lives with the controller that owns this data (WP3). export type { CodexAccountEntry } from "../hooks/useCodexAccountPool"; +import ProviderModelsNotice from "./ProviderModelsNotice"; +import { navigateHash } from "../hash-routing"; const DOCTOR_CMD = "ocx doctor"; type QuotaAutoRefreshSettings = Record; @@ -68,6 +70,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const { accounts, activeId, loadState, switchingId, pauseUpdatingId, priorityUpdatingId, pausingExhausted, activePinnedId, load } = controller; const [confirm, setConfirm] = useState(null); const [showAdd, setShowAdd] = useState(false); + const [modelsNotice, setModelsNotice] = useState<{ catalogRefreshPending: boolean } | null>(null); const [advancedOpen, setAdvancedOpen] = useState(false); const [reauthId, setReauthId] = useState(null); const [actionFeedback, setActionFeedback] = useState(null); @@ -162,6 +165,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban completion.catalogRefreshPending ? "warn" : "ok", ); closeAddModal(); + setModelsNotice({ catalogRefreshPending: completion.catalogRefreshPending }); }, [closeAddModal, controller, showActionFeedback, t]); const setActive = async (id: string | null) => { @@ -562,6 +566,12 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban onAdded={handleAccountAdded} /> )} + {modelsNotice && setModelsNotice(null)} + onOpenModels={() => { setModelsNotice(null); navigateHash("models"); }} + />} ); } diff --git a/gui/src/components/ProviderModelsNotice.tsx b/gui/src/components/ProviderModelsNotice.tsx new file mode 100644 index 00000000000..c19844e84cb --- /dev/null +++ b/gui/src/components/ProviderModelsNotice.tsx @@ -0,0 +1,63 @@ +import { useEffect, useId, useRef } from "react"; +import { useT } from "../i18n/shared"; + +export interface ProviderModelsNoticeProps { + provider: string; + loading: boolean; + failed: boolean; + providerKnown: boolean; + initialRegistration: boolean; + selection?: { status: "pending" | "ready" | "all-off"; modelCount?: number }; + catalogRefreshPending?: boolean; + onClose: () => void; + onOpenModels: () => void; + onRetry?: () => void; +} + +export default function ProviderModelsNotice(props: ProviderModelsNoticeProps) { + const t = useT(); + const titleId = useId(); + const dialog = useRef(null); + const primary = useRef(null); + useEffect(() => { + const previous = document.activeElement as HTMLElement | null; + primary.current?.focus(); + return () => { if (previous?.isConnected && typeof previous.focus === "function") previous.focus(); }; + }, []); + const pending = props.selection?.status === "pending"; + const unavailable = props.failed || !props.providerKnown; + const message = props.loading ? t("prov.modelsNoticeChecking") + : unavailable ? t("prov.modelsNoticeFailed") + : pending ? t("prov.modelsNoticePending") + : props.initialRegistration && props.selection?.status === "all-off" ? t("prov.modelsNoticeOff") + : t("prov.modelsNoticeReady"); + + return ( +

{ + if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); props.onClose(); } + if (event.key !== "Tab") return; + const buttons = dialog.current?.querySelectorAll("button:not([disabled])"); + const first = buttons?.[0], last = buttons?.[buttons.length - 1]; + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } + }}> +
+

{t("prov.modelsNoticeTitle")}

+

{props.provider}

+

{message}

+ {props.initialRegistration && props.selection?.modelCount !== undefined && ( +

{t("prov.modelsNoticeCount", { count: props.selection.modelCount })}

+ )} + {props.catalogRefreshPending &&

{t("codexAuth.catalogRefreshPending")}

} + {!props.loading && (pending || unavailable) && props.onRetry && ( + + )} +
+ + +
+
+
+ ); +} diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index 5b0004c9205..2564bc9c380 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -78,6 +78,7 @@ export default function ProviderWorkspaceShell({ jsonEditor, jsonSaving = false, modelsRefreshToken = 0, + onModelsSettled, activeAccountNeedsReauth, /** Stable key of active OAuth account ids — refetch overview quotas after account switch. */ quotaRefreshEpoch = 0, @@ -99,6 +100,8 @@ export default function ProviderWorkspaceShell({ jsonSaving?: boolean; /** Bump after login/config changes so /api/selected-models is refetched. */ modelsRefreshToken?: number; + /** Registration feedback re-reads config only after this discovery actually settles. */ + onModelsSettled?: (ok: boolean) => void; activeAccountNeedsReauth?: Record; /** * Monotonic quota revision. It moves only when something actually invalidates the quota @@ -176,6 +179,7 @@ export default function ProviderWorkspaceShell({ const timeout = window.setTimeout(() => { setModelsLoading(true); void (async () => { + let succeeded = false; try { const res = await fetch(`${apiBase}/api/selected-models`); const data = await readJsonOrThrow(res); @@ -185,11 +189,12 @@ export default function ProviderWorkspaceShell({ setLiveModelCounts(parseLiveModelCounts(data)); setSelectedModels(parseSelectedModels(data)); setModelsLoadFailed(false); + succeeded = true; } catch { if (cancelled) return; setModelsLoadFailed(true); } finally { - if (!cancelled) setModelsLoading(false); + if (!cancelled) { setModelsLoading(false); onModelsSettled?.(succeeded); } } })(); }, 0); @@ -197,7 +202,7 @@ export default function ProviderWorkspaceShell({ cancelled = true; window.clearTimeout(timeout); }; - }, [apiBase, modelsRefreshToken, modelsLoadEpoch]); + }, [apiBase, modelsRefreshToken, modelsLoadEpoch, onModelsSettled]); useEffect(() => { let cancelled = false; diff --git a/gui/src/hooks/useJsonConfigEditor.ts b/gui/src/hooks/useJsonConfigEditor.ts index 39b8cc2a38f..a72236fa56d 100644 --- a/gui/src/hooks/useJsonConfigEditor.ts +++ b/gui/src/hooks/useJsonConfigEditor.ts @@ -39,7 +39,7 @@ export function useJsonConfigEditor(deps: { notify: (msg: string, ok?: boolean) => void; fetchConfig: () => Promise; fetchProviderQuotas: (refresh?: boolean) => Promise; - onSaved: () => void; + onSaved: (addedProviders: string[]) => void; t: (key: string, values?: Record) => string; }) { const { apiBase, config, notify, fetchConfig, fetchProviderQuotas, onSaved, t } = deps; @@ -85,7 +85,9 @@ export function useJsonConfigEditor(deps: { setJsonBaseline(JSON.stringify(parsed, null, 2)); fetchConfig(); fetchProviderQuotas(true); - onSaved(); + const addedProviders = Object.keys((parsed as ProviderEditorConfig).providers) + .filter(name => !Object.hasOwn((baseline as ProviderEditorConfig).providers, name)); + onSaved(addedProviders); return true; } catch { notify(t("prov.saveFailed"), false); diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 2f34948385b..71125c9c225 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -425,6 +425,15 @@ export const de: Record = { "prov.updateFail": "Dieser Anbieter konnte nicht aktualisiert werden.", "prov.networkError": "Netzwerkfehler. Prüfe, ob der Proxy läuft, und versuche es erneut.", "prov.added": "\"{name}\" hinzugefügt. Sofort aktiv — führe {cmd} aus (oder starte neu), um seine Modelle in Codex’ Auswahl zu listen.", + "prov.modelsNoticeTitle": "Modelle auswählen", + "prov.modelsNoticeChecking": "Die Modellliste wird geprüft. Modellschalter deaktivieren den Anbieter nicht.", + "prov.modelsNoticePending": "Die erste Modellliste ist noch nicht bestätigt. Modelle bleiben bis zum Abschluss der Erkennung ausgeblendet.", + "prov.modelsNoticeOff": "Bei der Registrierung wurden alle Modellschalter auf OFF gesetzt. Aktiviere die gewünschten Modelle auf der Seite Models.", + "prov.modelsNoticeReady": "Wähle auf der Seite Models aus, welche Modelle angezeigt werden. Die Schalter deaktivieren nicht den Anbieter selbst.", + "prov.modelsNoticeFailed": "Der Anbieter wurde gespeichert, die Modellliste konnte aber nicht aktualisiert werden. Versuche es erneut.", + "prov.modelsNoticeCount": "{count} Modelle", + "prov.modelsNoticeOpen": "Models öffnen", + "models.initialSelectionPending": "Erste Modellerkennung ausstehend", "prov.removeConfirm": "Anbieter \"{name}\" entfernen? Seine Modelle verschwinden aus Codex’ Auswahl.", "prov.hasApiKey": "API-Schlüssel konfiguriert", "prov.hasHeaders": "benutzerdefinierte Header konfiguriert", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 7a3a21b11a9..d6bc323128b 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -448,6 +448,15 @@ export const en = { "prov.updateFail": "Couldn't update this provider.", "prov.networkError": "Network error. Check that the proxy is running and try again.", "prov.added": "Added \"{name}\". Live now — run {cmd} (or restart) to list its models in Codex's picker.", + "prov.modelsNoticeTitle": "Choose models", + "prov.modelsNoticeChecking": "Checking the model list. Model switches do not disable the provider.", + "prov.modelsNoticePending": "The initial model list is not confirmed yet. Models stay hidden until discovery finishes.", + "prov.modelsNoticeOff": "All model switches were turned OFF at registration. Enable the models you want on the Models page.", + "prov.modelsNoticeReady": "Choose which models appear on the Models page. Model switches do not disable the provider.", + "prov.modelsNoticeFailed": "The provider was saved, but the model list could not be refreshed. Try again.", + "prov.modelsNoticeCount": "{count} models", + "prov.modelsNoticeOpen": "Open Models", + "models.initialSelectionPending": "Initial discovery pending", "prov.removeConfirm": "Remove provider \"{name}\"? Its models disappear from Codex's picker.", "prov.hasApiKey": "api key configured", "prov.hasHeaders": "custom headers configured", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index e953217ede3..1622a373cff 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -435,6 +435,15 @@ export const fr: Record = { "prov.updateFail": "Impossible de mettre à jour ce fournisseur.", "prov.networkError": "Erreur réseau. Vérifiez que le proxy est en cours d’exécution et réessayez.", "prov.added": "« {name} » ajouté. Déjà actif — exécutez {cmd} (ou redémarrez) pour afficher ses modèles dans le sélecteur de Codex.", + "prov.modelsNoticeTitle": "Choisir les modèles", + "prov.modelsNoticeChecking": "Vérification de la liste des modèles. Les interrupteurs de modèles ne désactivent pas le fournisseur.", + "prov.modelsNoticePending": "La liste initiale n’est pas encore confirmée. Les modèles restent masqués jusqu’à la fin de la découverte.", + "prov.modelsNoticeOff": "Tous les interrupteurs de modèles ont été mis sur OFF à l’inscription. Activez les modèles souhaités sur la page Models.", + "prov.modelsNoticeReady": "Choisissez les modèles affichés sur la page Models. Ces interrupteurs ne désactivent pas le fournisseur.", + "prov.modelsNoticeFailed": "Le fournisseur a été enregistré, mais la liste des modèles n’a pas pu être actualisée. Réessayez.", + "prov.modelsNoticeCount": "{count} modèles", + "prov.modelsNoticeOpen": "Ouvrir Models", + "models.initialSelectionPending": "Découverte initiale en attente", "prov.removeConfirm": "Supprimer le fournisseur « {name} » ? Ses modèles disparaîtront du sélecteur de Codex.", "prov.hasApiKey": "clé API configurée", "prov.hasHeaders": "en-têtes personnalisés configurés", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e0f8e317c33..fccb5fb92eb 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -431,6 +431,15 @@ export const ja: Record = { "prov.updateFail": "このプロバイダーを更新できませんでした。", "prov.networkError": "ネットワークエラーです。プロキシが実行中であることを確認して、もう一度試してください。", "prov.added": "\"{name}\" を追加しました。即時反映 — {cmd} を実行(または再起動)して Codex のピッカーにモデルを一覧表示します。", + "prov.modelsNoticeTitle": "モデル設定の案内", + "prov.modelsNoticeChecking": "モデル一覧を確認しています。モデルのスイッチを切ってもプロバイダーは無効になりません。", + "prov.modelsNoticePending": "初回のモデル一覧をまだ確認できていません。取得が完了するまでモデルの公開を保留します。", + "prov.modelsNoticeOff": "初回登録時にモデルのスイッチをすべて OFF にしました。Models ページで必要なモデルを有効にしてください。", + "prov.modelsNoticeReady": "Models ページで表示するモデルを選択できます。プロバイダー自体を無効にする操作ではありません。", + "prov.modelsNoticeFailed": "プロバイダーは保存しましたが、モデル一覧を更新できませんでした。再試行してください。", + "prov.modelsNoticeCount": "モデル {count} 個", + "prov.modelsNoticeOpen": "Models を開く", + "models.initialSelectionPending": "初回のモデル取得待ち", "prov.removeConfirm": "プロバイダー \"{name}\" を削除しますか? そのモデルは Codex のピッカーから消えます。", "prov.hasApiKey": "API キー設定済み", "prov.hasHeaders": "カスタムヘッダー設定済み", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index ab644d34d4c..de6d8f90cd4 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -434,6 +434,15 @@ export const ko: Record = { "prov.updateFail": "이 프로바이더를 업데이트하지 못했습니다.", "prov.networkError": "네트워크 오류입니다. 프록시가 실행 중인지 확인한 후 다시 시도하세요.", "prov.added": "\"{name}\" 을(를) 추가했습니다. 지금 활성화됨 — Codex 모델 선택기에 표시하려면 {cmd} 를 실행하세요(또는 재시작).", + "prov.modelsNoticeTitle": "모델 설정 안내", + "prov.modelsNoticeChecking": "모델 목록을 확인하고 있습니다. 모델 스위치를 꺼도 프로바이더는 비활성화되지 않습니다.", + "prov.modelsNoticePending": "초기 모델 목록을 아직 확인하지 못했습니다. 조회가 끝날 때까지 모델 노출을 보류합니다.", + "prov.modelsNoticeOff": "처음 등록할 때 모델 스위치를 모두 꺼 두었습니다. 모델 페이지에서 필요한 모델을 켜세요.", + "prov.modelsNoticeReady": "모델 페이지에서 사용할 모델을 켜거나 끌 수 있습니다. 프로바이더 자체를 끄는 것은 아닙니다.", + "prov.modelsNoticeFailed": "프로바이더는 저장했지만 모델 목록을 갱신하지 못했습니다. 다시 시도하세요.", + "prov.modelsNoticeCount": "모델 {count}개", + "prov.modelsNoticeOpen": "모델 페이지로 이동", + "models.initialSelectionPending": "초기 모델 조회 대기", "prov.removeConfirm": "프로바이더 \"{name}\" 을(를) 삭제할까요? 해당 모델이 Codex 선택기에서 사라집니다.", "prov.hasApiKey": "API 키 설정됨", "prov.hasHeaders": "커스텀 헤더 설정됨", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index d1e721ebe64..f6d9f4518d4 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -436,6 +436,15 @@ export const ru: Record = { "prov.updateFail": "Не удалось обновить этого провайдера.", "prov.networkError": "Ошибка сети. Проверьте, что прокси запущен, и повторите попытку.", "prov.added": "Провайдер \"{name}\" добавлен. Уже активен — выполните {cmd} (или перезапустите), чтобы его модели появились в селекторе моделей Codex.", + "prov.modelsNoticeTitle": "Настройка моделей", + "prov.modelsNoticeChecking": "Проверяем список моделей. Переключатели моделей не отключают провайдера.", + "prov.modelsNoticePending": "Начальный список моделей ещё не подтверждён. Модели скрыты до завершения обнаружения.", + "prov.modelsNoticeOff": "При регистрации все переключатели моделей были установлены в OFF. Включите нужные модели на странице Models.", + "prov.modelsNoticeReady": "На странице Models можно выбрать отображаемые модели. Эти переключатели не отключают самого провайдера.", + "prov.modelsNoticeFailed": "Провайдер сохранён, но обновить список моделей не удалось. Повторите попытку.", + "prov.modelsNoticeCount": "Моделей: {count}", + "prov.modelsNoticeOpen": "Открыть Models", + "models.initialSelectionPending": "Ожидание обнаружения моделей", "prov.removeConfirm": "Удалить провайдера \"{name}\"? Его модели исчезнут из селектора моделей Codex.", "prov.hasApiKey": "API-ключ настроен", "prov.hasHeaders": "настроены пользовательские заголовки", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 5a39f0c3c38..4c7afb97b65 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -418,6 +418,15 @@ export const tr: Record = { "prov.loginSameAccount": "Hâlâ aynı {provider} hesabı — tarayıcıda hesap değiştirin, ardından tekrar Hesap Ekle'yi deneyin.", "prov.loginOk": "{provider} hesabına giriş yapıldı. Modellerini listelemek için {cmd} çalıştırın (veya canlı olarak uygulanır).", "prov.added": "\"{name}\" eklendi. Modellerini listelemek için {cmd} çalıştırın (veya canlı olarak uygulanır).", + "prov.modelsNoticeTitle": "Model ayarları", + "prov.modelsNoticeChecking": "Model listesi kontrol ediliyor. Model anahtarları sağlayıcıyı devre dışı bırakmaz.", + "prov.modelsNoticePending": "İlk model listesi henüz doğrulanmadı. Keşif tamamlanana kadar modeller gizli kalır.", + "prov.modelsNoticeOff": "İlk kayıtta tüm model anahtarları OFF olarak ayarlandı. Models sayfasında ihtiyacınız olan modelleri açın.", + "prov.modelsNoticeReady": "Models sayfasında hangi modellerin görüneceğini seçin. Bu anahtarlar sağlayıcının kendisini kapatmaz.", + "prov.modelsNoticeFailed": "Sağlayıcı kaydedildi ancak model listesi yenilenemedi. Tekrar deneyin.", + "prov.modelsNoticeCount": "{count} model", + "prov.modelsNoticeOpen": "Models sayfasını aç", + "models.initialSelectionPending": "İlk model keşfi bekleniyor", "oauthTos.highTitle": "{provider}: abonelik OAuth riski", "oauthTos.elevatedTitle": "{provider}: gayri resmi OAuth köprüsü", "oauthTos.anthropicBody": "Claude abonelik OAuth jetonlarının OpenCodex gibi üçüncü taraf bir proxy üzerinden doğrudan yeniden kullanılması desteklenen bir Anthropic entegrasyonu değildir ve erişim kısıtlamalarına yol açabilir. Claude aboneliklerini kullanan desteklenen Agent SDK entegrasyonları ayrıdır.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 94101c189e6..a96f09b1aff 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -323,6 +323,15 @@ export const zhTW: Record = { "prov.removed": "已移除 \"{name}\"。", "prov.removeFail": "移除 \"{name}\" 失敗。", "prov.added": "已新增 \"{name}\"。現已生效 — 執行 {cmd}(或重新啟動)以在 Codex 選擇器中列出其模型。", + "prov.modelsNoticeTitle": "模型設定提示", + "prov.modelsNoticeChecking": "正在檢查模型清單。關閉模型開關不會停用供應商。", + "prov.modelsNoticePending": "尚未確認初始模型清單。在探索完成之前,暫不公開模型。", + "prov.modelsNoticeOff": "首次註冊時已關閉所有模型開關。請在模型頁面啟用需要的模型。", + "prov.modelsNoticeReady": "可在模型頁面選擇要顯示的模型。模型開關不會停用供應商本身。", + "prov.modelsNoticeFailed": "供應商已儲存,但無法更新模型清單。請重試。", + "prov.modelsNoticeCount": "{count} 個模型", + "prov.modelsNoticeOpen": "開啟模型頁面", + "models.initialSelectionPending": "等待初始模型探索", "prov.removeConfirm": "移除供應商 \"{name}\"?其模型將從 Codex 選擇器中消失。", "prov.hasApiKey": "已配置 API 金鑰", "prov.hasHeaders": "已配置自訂請求標頭", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 5626b46abd4..b7964a3da08 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -431,6 +431,15 @@ export const zh: Record = { "prov.updateFail": "无法更新此提供方。", "prov.networkError": "网络错误。请确认代理正在运行后重试。", "prov.added": "已添加 \"{name}\"。现已生效 — 运行 {cmd}(或重启)以在 Codex 选择器中列出其模型。", + "prov.modelsNoticeTitle": "模型设置提示", + "prov.modelsNoticeChecking": "正在检查模型列表。关闭模型开关不会停用提供者。", + "prov.modelsNoticePending": "尚未确认初始模型列表。在发现完成之前,模型暂不公开。", + "prov.modelsNoticeOff": "首次注册时已关闭所有模型开关。请在模型页面启用需要的模型。", + "prov.modelsNoticeReady": "可在模型页面选择显示哪些模型。模型开关不会停用提供者本身。", + "prov.modelsNoticeFailed": "提供者已保存,但无法刷新模型列表。请重试。", + "prov.modelsNoticeCount": "{count} 个模型", + "prov.modelsNoticeOpen": "打开模型页面", + "models.initialSelectionPending": "等待初始模型发现", "prov.removeConfirm": "移除提供方 \"{name}\"?其模型将从 Codex 选择器中消失。", "prov.hasApiKey": "已配置 API 密钥", "prov.hasHeaders": "已配置自定义请求头", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index d849780e49d..91971cf54d1 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -1207,10 +1207,11 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; // An empty provider has nothing to send: keep both bulk buttons inert so we never PUT an // empty target list (the management API rejects it with 400). const hasRows = rows.length > 0; + const selectionPending = rows.some(model => model.initialSelectionPending); const allOn = !hasRows || rows.every(isVisible); const allOff = !hasRows || rows.every(m => !isVisible(m)); const bulkToggle = (enable: boolean) => { - if (!hasRows) return; + if (!hasRows || selectionPending) return; void applyVisibility( "provider", provider, @@ -1298,7 +1299,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; background: preset.mode === mode ? undefined : "transparent", color: preset.mode === mode ? undefined : "var(--muted)", }} - disabled={busy || busyHere} + disabled={busy || busyHere || selectionPending} onClick={(e) => { e.stopPropagation(); // Switching from a custom selection destroys it, so confirm first. @@ -1339,8 +1340,8 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; ); })()} - - + +
{/* The label names the FUNCTION. It used to be `models.capValue` - "기본 128k" - which is a value masquerading as a name: even a @@ -1459,7 +1460,8 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; }} >
- void applyVisibility("models", provider, [{ id: m.id, native: m.native === true }], off)} disabled={busy} label={m.native ? m.id : m.namespaced} /> + void applyVisibility("models", provider, [{ id: m.id, native: m.native === true }], off)} disabled={busy || m.initialSelectionPending} label={m.native ? m.id : m.namespaced} /> + {m.initialSelectionPending && {t("models.initialSelectionPending")}} {aliases.models[provider]?.[m.id] && {aliases.models[provider][m.id].alias}} {m.native ? modelLabel(m.id) : formatNamespacedModelId(m.namespaced, t)} {aliases.models[provider]?.[m.id]?.source === "builtin" && {t("models.aliasAuto")}} diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 47668f010e6..6dfff667230 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -20,6 +20,8 @@ import { useProvidersFetch } from "./use-providers-fetch"; import { ProvidersPageModals } from "./providers-page-modals"; import { buildAccountLoginStatus, buildAddModalAccountRows } from "./providers-page-utils"; import type { CodexAccountMutationCompletion } from "../codex-account-mutation"; +import { useProviderModelsNotice } from "./use-provider-models-notice"; +import { navigateHash } from "../hash-routing"; export default function Providers({ apiBase }: { apiBase: string }) { const t = useT(); @@ -155,11 +157,18 @@ export default function Providers({ apiBase }: { apiBase: string }) { quotaRefreshWaiters.current = []; for (const resolve of waiters) resolve(ok); }, []); - const { fetchConfig, fetchOauth, fetchProviderQuotas } = useProvidersFetch({ + const { fetchConfig: refreshConfigResult, fetchOauth, fetchProviderQuotas } = useProvidersFetch({ apiBase, t, setConfig, setOauthProviders, setOauthStatus, notify, invalidateProviderQuotas, configCacheKey, }); + const fetchConfig = useCallback(async () => { await refreshConfigResult(); }, [refreshConfigResult]); + const modelsNotice = useProviderModelsNotice(apiBase, refreshConfigResult); + const openModelsNotice = modelsNotice.open; + const onProviderLoginSettled = useCallback((provider: string) => { + revealProviderAccounts(provider); + openModelsNotice(provider, false); + }, [revealProviderAccounts, openModelsNotice]); // WP3: one Codex account controller for the whole Providers page, shared by the // Overview tab and the Accounts tab so a mutation on either is instantly visible on @@ -204,7 +213,10 @@ export default function Providers({ apiBase }: { apiBase: string }) { const jsonEditor = useJsonConfigEditor({ apiBase, config, notify, - fetchConfig, fetchProviderQuotas, onSaved: () => setModelsRefreshToken(n => n + 1), + fetchConfig, fetchProviderQuotas, onSaved: added => { + if (added.length) modelsNotice.open(added, true); + setModelsRefreshToken(n => n + 1); + }, t: t as unknown as Parameters[0]["t"], }); const { @@ -266,7 +278,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { apiBase, t, aliveRef, accountSets, setAccountSets, setBusy, setStatus, setLoginInfo, setOauthStatus, notify, fetchConfig, fetchOauth, fetchAccountSets, fetchProviderQuotas, bumpModelsRefresh, - onLoginSettled: revealProviderAccounts, + onLoginSettled: onProviderLoginSettled, }); const { removeProvider, confirmRemoveProvider, setProviderDisabled, setDefaultProvider, updateProvider } = useProvidersCrud({ @@ -390,6 +402,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { }} jsonSaving={jsonSaving} modelsRefreshToken={modelsRefreshToken} + onModelsSettled={modelsNotice.modelsSettled} activeAccountNeedsReauth={activeAccountNeedsReauth} quotaRefreshEpoch={quotaRefresh.epoch} quotaForceRefresh={quotaRefresh.force} @@ -451,6 +464,25 @@ export default function Providers({ apiBase }: { apiBase: string }) { apiBase={apiBase} config={config} adding={adding} + modelsNotice={modelsNotice.notice ? { + provider: modelsNotice.notice.context.provider, + initialRegistration: modelsNotice.notice.context.initialRegistration, + catalogRefreshPending: modelsNotice.notice.context.catalogRefreshPending, + loading: modelsNotice.notice.loading, + failed: modelsNotice.notice.failed, + providerKnown: modelsNotice.notice.context.providers.every(name => !!config.providers[name]), + selection: modelsNotice.notice.context.providers.length === 1 + ? config.providers[modelsNotice.notice.context.provider]?.initialModelSelection + : modelsNotice.notice.context.providers.some(name => config.providers[name]?.initialModelSelection?.status === "pending") + ? { status: "pending" } : undefined, + onClose: modelsNotice.close, + onOpenModels: () => { modelsNotice.close(); navigateHash("models"); }, + onRetry: () => { + const current = modelsNotice.notice!.context; + modelsNotice.open(current.providers, current.initialRegistration, current.catalogRefreshPending); + bumpModelsRefresh(); + }, + } : null} addIntent={addIntent} busy={busy} addModalAccountRows={addModalAccountRows} @@ -472,7 +504,8 @@ export default function Providers({ apiBase }: { apiBase: string }) { onAdded={(name) => { setAdding(false); setAddIntent(null); - notify(t("prov.added", { name, cmd: "ocx sync" }), true); + clearStatus(); + modelsNotice.open(name, !config.providers[name]); fetchConfig(); fetchOauth(); fetchProviderQuotas(true); @@ -487,6 +520,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { onCodexAdded={(completion) => { setCodexLoginOpen(false); notifyCodexCompletion(completion); + modelsNotice.open("openai", !config.providers.openai, completion.catalogRefreshPending); void fetchConfig(); void fetchOauth(); void fetchProviderQuotas(true); diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts index 6e1f463db70..1575a52ac92 100644 --- a/gui/src/pages/models-shared.ts +++ b/gui/src/pages/models-shared.ts @@ -30,6 +30,7 @@ export interface ModelRow { id: string; namespaced: string; disabled: boolean; + initialSelectionPending?: boolean; native?: boolean; custom?: boolean; customId?: string; diff --git a/gui/src/pages/providers-page-modals.tsx b/gui/src/pages/providers-page-modals.tsx index 051c3d2ee6f..ba5964675e3 100644 --- a/gui/src/pages/providers-page-modals.tsx +++ b/gui/src/pages/providers-page-modals.tsx @@ -1,4 +1,5 @@ import AddProviderModal from "../components/AddProviderModal"; +import ProviderModelsNotice, { type ProviderModelsNoticeProps } from "../components/ProviderModelsNotice"; import AddCodexAccountModal from "../components/AddCodexAccountModal"; import OAuthTosWarningModal from "../components/OAuthTosWarningModal"; import { RemoveConfirmDialog, UnsavedLeaveDialog } from "../components/provider-workspace/ProviderDialogs"; @@ -12,6 +13,7 @@ export function ProvidersPageModals({ apiBase, config, adding, + modelsNotice, addIntent, busy, addModalAccountRows, @@ -43,6 +45,7 @@ export function ProvidersPageModals({ apiBase: string; config: ProvidersConfig; adding: boolean; + modelsNotice?: ProviderModelsNoticeProps | null; addIntent: AddProviderIntent | null; busy: string | null; addModalAccountRows: AccountLoginRow[]; @@ -73,6 +76,7 @@ export function ProvidersPageModals({ }) { return ( <> + {modelsNotice && } {adding && ( Promise) { + const [state, setState] = useState<{ apiBase: string; notice: Notice | null }>({ apiBase, notice: null }); + if (state.apiBase !== apiBase) setState({ apiBase, notice: null }); + const active = useRef(null); + useEffect(() => () => { active.current = null; }, [apiBase]); + const open = useCallback((provider: string | readonly string[], initialRegistration: boolean, catalogRefreshPending = false) => { + const providers = typeof provider === "string" ? [provider] : provider; + const context = { provider: providers.join(", "), providers, apiBase, initialRegistration: initialRegistration && providers.length === 1, catalogRefreshPending }; + active.current = context; + setState({ apiBase, notice: { context, loading: true, failed: false } }); + }, [apiBase]); + const close = useCallback(() => { active.current = null; setState(current => ({ ...current, notice: null })); }, []); + const modelsSettled = useCallback((ok: boolean) => { + const context = active.current; + if (!context || context.apiBase !== apiBase) return; + void refreshConfig().then(async result => { + // One newer config request may supersede this one; retry once, never poll. + if (result === "superseded" && active.current === context) result = await refreshConfig(); + if (active.current === context) setState(current => current.apiBase === context.apiBase + ? { ...current, notice: { context, loading: false, failed: !ok || result !== "applied" } } : current); + }).catch(() => { + if (active.current === context) setState(current => current.apiBase === context.apiBase + ? { ...current, notice: { context, loading: false, failed: true } } : current); + }); + }, [apiBase, refreshConfig]); + return { notice: state.apiBase === apiBase ? state.notice : null, open, close, modelsSettled }; +} diff --git a/gui/src/pages/use-providers-fetch.ts b/gui/src/pages/use-providers-fetch.ts index b310731d2f8..5b7d8632eba 100644 --- a/gui/src/pages/use-providers-fetch.ts +++ b/gui/src/pages/use-providers-fetch.ts @@ -1,8 +1,9 @@ -import { useCallback } from "react"; +import { useCallback, useEffect, useRef } from "react"; import type { TFn } from "../i18n/shared"; import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; import { writeSessionListCache } from "../session-list-cache"; import type { OAuthStatus, ProvidersConfig } from "./providers-shared"; +export type ProvidersConfigRefreshResult = "applied" | "failed" | "superseded"; export function useProvidersFetch({ apiBase, @@ -25,14 +26,22 @@ export function useProvidersFetch({ /** Session seed key for instant Providers shell paint (no secrets — hasApiKey flags only). */ configCacheKey?: string; }) { - const fetchConfig = useCallback(async () => { + const configRequest = useRef(0); + useEffect(() => () => { configRequest.current += 1; }, [apiBase]); + const fetchConfig = useCallback(async (): Promise => { + const request = ++configRequest.current; try { const res = await fetch(`${apiBase}/api/config`); const data = await readJsonOrThrow(res); + if (request !== configRequest.current) return "superseded"; + if (!data) throw new Error("config response missing"); setConfig(data ?? null); if (configCacheKey && data) writeSessionListCache(configCacheKey, data); + return "applied"; } catch { + if (request !== configRequest.current) return "superseded"; notify(t("prov.loadConfigFail"), false); + return "failed"; } }, [apiBase, configCacheKey, notify, setConfig, t]); diff --git a/gui/tests/models-empty-provider.test.tsx b/gui/tests/models-empty-provider.test.tsx index 296c85d2b1a..85843f64b49 100644 --- a/gui/tests/models-empty-provider.test.tsx +++ b/gui/tests/models-empty-provider.test.tsx @@ -130,10 +130,11 @@ test("Models page combines final visibility, atomic actions, discovery status, a }; let failNext = false; let failCatalog = false; + let initialSelectionPending = false; let modelFetches = 0; let resolveModels!: (response: Response) => void; const firstModels = new Promise(resolve => { resolveModels = resolve; }); - const rows = () => ids.map(id => ({ provider, id, namespaced: `${provider}/${id}`, disabled: disabled.has(id) })); + const rows = () => ids.map(id => ({ provider, id, namespaced: `${provider}/${id}`, disabled: initialSelectionPending || disabled.has(id), ...(initialSelectionPending ? { initialSelectionPending: true } : {}) })); testWindow.sessionStorage.setItem("ocx.models.catalog.v1:http://localhost", JSON.stringify({ models: rows(), providers: [{ name: provider, liveModels: true, models: ids }], @@ -491,6 +492,12 @@ test("Models page combines final visibility, atomic actions, discovery status, a await act(async () => { poll(); await new Promise(resolve => testWindow.setTimeout(resolve, 0)); }); expect(container.textContent).toContain("fallback-provider"); expect(container.textContent).toContain("Failed to load models"); + failCatalog = false; + initialSelectionPending = true; + await act(async () => { poll(); await new Promise(resolve => testWindow.setTimeout(resolve, 0)); }); + expect(container.textContent).toContain("Initial discovery pending"); + expect(switchFor("gemini-pro").disabled).toBe(true); + expect(buttonText("All on").disabled).toBe(true); } finally { if (root) { await act(async () => root?.unmount()); diff --git a/gui/tests/provider-models-notice.test.tsx b/gui/tests/provider-models-notice.test.tsx new file mode 100644 index 00000000000..b69007ab096 --- /dev/null +++ b/gui/tests/provider-models-notice.test.tsx @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, useState, type ReactNode } from "react"; +import type { Root } from "react-dom/client"; +import ProviderModelsNotice, { type ProviderModelsNoticeProps } from "../src/components/ProviderModelsNotice"; +import { LanguageProvider } from "../src/i18n/provider"; +import { useProviderModelsNotice } from "../src/pages/use-provider-models-notice"; +import { useProvidersFetch } from "../src/pages/use-providers-fetch"; +import type { ProvidersConfig } from "../src/pages/providers-shared"; + +const keys = ["window", "document", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let saved: Record; +let win: Window; +let host: HTMLElement; +let root: Root | null; + +beforeEach(() => { + saved = Object.fromEntries(keys.map(key => [key, Reflect.get(globalThis, key)])); + win = new Window({ url: "http://localhost/#providers" }); + win.localStorage.setItem("ocx-lang", "en"); + for (const key of ["window", "document", "navigator", "localStorage", "sessionStorage"] as const) { + Object.defineProperty(globalThis, key, { configurable: true, value: key === "window" ? win : win[key] }); + } + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true }); + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); + root = null; +}); +afterEach(async () => { + if (root) await act(async () => { root?.unmount(); }); + await win.happyDOM.close(); + for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, value: saved[key] }); +}); +async function render(node: ReactNode) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { root ??= createRoot(host); root.render(node); }); +} +function button(label: string): HTMLButtonElement { + const found = [...host.querySelectorAll("button")].find(node => node.textContent === label); + if (!found) throw new Error(`missing button ${label}`); + return found; +} + +test("all-OFF notice has keyboard navigation, explicit actions and focus restoration", async () => { + const trigger = win.document.createElement("button"); + win.document.body.appendChild(trigger); + trigger.focus(); + let closed = 0, opened = 0; + const props: ProviderModelsNoticeProps = { + provider: "openrouter", loading: false, failed: false, providerKnown: true, initialRegistration: true, + selection: { status: "all-off", modelCount: 20 }, onClose: () => { closed++; }, onOpenModels: () => { opened++; }, + }; + await render(); + expect(host.querySelector('[role="dialog"]')?.getAttribute("aria-modal")).toBe("true"); + expect(host.textContent).toContain("turned OFF at registration"); + expect(host.textContent).toContain("20 models"); + expect(win.document.activeElement as unknown).toBe(button("Open Models")); + button("Open Models").dispatchEvent(new win.KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }) as never); + expect(win.document.activeElement as unknown).toBe(button("Close")); + button("Close").dispatchEvent(new win.KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }) as never); + expect(win.document.activeElement as unknown).toBe(button("Open Models")); + button("Open Models").click(); + expect(opened).toBe(1); + button("Open Models").dispatchEvent(new win.KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }) as never); + expect(closed).toBe(1); + await act(async () => { root!.unmount(); root = null; }); + expect(win.document.activeElement).toBe(trigger); +}); + +test("pending/error recovery and generic OAuth/re-login copy stay truthful", async () => { + let retried = 0; + const props: ProviderModelsNoticeProps = { + provider: "xai", loading: false, failed: false, providerKnown: true, initialRegistration: false, + selection: { status: "pending" }, onClose: () => {}, onOpenModels: () => {}, onRetry: () => { retried++; }, + }; + await render(); + expect(host.textContent).toContain("not confirmed yet"); + button("Retry").click(); + expect(retried).toBe(1); + await render(); + expect(host.textContent).toContain("was saved"); + await render(); + expect(host.textContent).not.toContain("turned OFF at registration"); + expect(host.textContent).not.toContain("20 models"); + expect(host.textContent).toContain("Choose which models appear"); + expect(host.textContent).toContain("ocx sync"); +}); + +test("notice waits for post-discovery config refresh and ignores closed/superseded operations", async () => { + let controller: ReturnType; + const gates: Array<() => void> = []; + const refresh = () => new Promise<"applied">(resolve => gates.push(() => resolve("applied"))); + function Harness() { controller = useProviderModelsNotice("/notice", refresh); return null; } + await render(); + await act(async () => { controller!.open("one", true); }); + await act(async () => { controller!.modelsSettled(true); }); + expect(controller!.notice?.loading).toBe(true); + await act(async () => { gates.shift()!(); await Promise.resolve(); }); + expect(controller!.notice?.loading).toBe(false); + await act(async () => { controller!.modelsSettled(false); controller!.close(); }); + await act(async () => { gates.shift()!(); await Promise.resolve(); }); + expect(controller!.notice).toBeNull(); + await act(async () => { controller!.open("old", true); controller!.modelsSettled(true); controller!.open("new", true); }); + await act(async () => { gates.shift()!(); await Promise.resolve(); }); + expect(controller!.notice?.context.provider).toBe("new"); + expect(controller!.notice?.loading).toBe(true); +}); + +test("returning to an API target does not reopen its old notice", async () => { + let controller: ReturnType; + const refresh = async () => "applied" as const; + function Harness({ base }: { base: string }) { controller = useProviderModelsNotice(base, refresh); return null; } + await render(); + await act(async () => { controller!.open("old", true); }); + await render(); + expect(controller!.notice).toBeNull(); + await render(); + expect(controller!.notice).toBeNull(); +}); + +test("failed config refresh is not announced as successful model setup", async () => { + let controller: ReturnType; + function Harness() { controller = useProviderModelsNotice("/failed", async () => "failed"); return null; } + await render(); + await act(async () => { controller!.open("vendor", true); }); + await act(async () => { controller!.modelsSettled(true); await Promise.resolve(); }); + expect(controller!.notice?.loading).toBe(false); + expect(controller!.notice?.failed).toBe(true); +}); + +test("an older pending config response cannot overwrite the newer completed snapshot", async () => { + let loader: ReturnType; + const observed: { config: ProvidersConfig | null } = { config: null }; + const responses: Array<(response: Response) => void> = []; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: () => new Promise(resolve => responses.push(resolve)) }); + function Harness() { + const [config, setConfig] = useState(null); + observed.config = config; + loader = useProvidersFetch({ apiBase: "/fresh", t: key => key, setConfig, setOauthProviders: () => {}, setOauthStatus: () => {}, notify: () => {}, invalidateProviderQuotas: () => {} }); + return null; + } + await render(); + const first = loader!.fetchConfig(); + const second = loader!.fetchConfig(); + const snapshot = (status: string) => ({ port: 0, defaultProvider: "vendor", providers: { vendor: { adapter: "openai-chat", baseUrl: "https://example.test", initialModelSelection: { status } } } }); + await act(async () => { responses[1]!(Response.json(snapshot("all-off"))); await second; }); + await act(async () => { responses[0]!(Response.json(snapshot("pending"))); await first; }); + expect(observed.config?.providers.vendor.initialModelSelection?.status).toBe("all-off"); +}); diff --git a/gui/tests/providers-codex-completion-toast.test.tsx b/gui/tests/providers-codex-completion-toast.test.tsx index 92d8eaf9d29..d5b69c0eb16 100644 --- a/gui/tests/providers-codex-completion-toast.test.tsx +++ b/gui/tests/providers-codex-completion-toast.test.tsx @@ -5,6 +5,7 @@ import type { Root } from "react-dom/client"; import { clearClientResourceStoresForTests } from "../src/client-resource"; import { LanguageProvider } from "../src/i18n/provider"; import Providers from "../src/pages/Providers"; +import CodexAccountPool from "../src/components/CodexAccountPool"; const globals = [ "document", @@ -203,6 +204,7 @@ test("pending Codex completion stays amber, private, dismissible, and refreshes const warning = testWindow.document.querySelector(".toast-notice.notice-warn"); expect(warning).toBeTruthy(); expect(warning!.textContent).toContain("The change was saved"); + expect(host.querySelector('[role="dialog"]')?.textContent).toContain("Choose models"); expect(warning!.textContent).toContain("ocx sync"); expect(testWindow.document.body.textContent).not.toContain("private-account-detail"); expect(pathCount("/api/config")).toBeGreaterThan(before.config); @@ -232,3 +234,31 @@ test("completed Codex catalog convergence reports clean success without sync adv expect(success!.textContent).not.toContain("ocx sync"); expect(testWindow.document.querySelector(".toast-notice.notice-warn")).toBeNull(); }); + +for (const embedded of [false, true]) { + test(`Codex pool completion opens Models guidance (embedded=${embedded})`, async () => { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render(); + }); + await flush(); + await flush(); + await act(async () => { buttonWithText(host, "Add account").click(); }); + await flush(); + const login = testWindow.document.querySelector('dialog[aria-label="Add Codex Account"] button.list-row') as HTMLButtonElement; + expect(login).toBeTruthy(); + await act(async () => { login.click(); }); + await flush(); + await act(async () => { jest.advanceTimersByTime(2_000); await Promise.resolve(); }); + await flush(); + await flush(); + const notice = host.querySelector('[role="dialog"]'); + expect(notice?.textContent).toContain("Choose models"); + expect(notice?.textContent).toContain("ocx sync"); + expect(notice?.textContent).not.toContain("All model switches were turned OFF"); + await act(async () => { buttonWithText(notice!, "Open Models").click(); }); + expect(testWindow.location.hash).toBe("#models"); + expect(host.querySelector('[role="dialog"]')).toBeNull(); + }); +} diff --git a/gui/tests/use-json-config-editor.test.tsx b/gui/tests/use-json-config-editor.test.tsx index 0d35ac2cc31..3619aed8f94 100644 --- a/gui/tests/use-json-config-editor.test.tsx +++ b/gui/tests/use-json-config-editor.test.tsx @@ -46,6 +46,7 @@ let responseFactory: () => Promise; let configRefreshes: number; let quotaRefreshes: number; let savedCallbacks: number; +let addedProviderNames: string[]; let notifications: Array<{ message: string; ok?: boolean }>; function Harness() { @@ -55,7 +56,7 @@ function Harness() { notify: (message, ok) => { notifications.push({ message, ok }); }, fetchConfig: async () => { configRefreshes += 1; }, fetchProviderQuotas: async () => { quotaRefreshes += 1; }, - onSaved: () => { savedCallbacks += 1; }, + onSaved: added => { savedCallbacks += 1; addedProviderNames = added; }, t: key => key, }); return null; @@ -85,6 +86,7 @@ beforeEach(() => { configRefreshes = 0; quotaRefreshes = 0; savedCallbacks = 0; + addedProviderNames = []; notifications = []; responseFactory = async () => Response.json({ success: true }); globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { @@ -154,6 +156,17 @@ test("Save sends one atomic provider PUT with baseline and next, then refreshes" expect(savedCallbacks).toBe(1); }); +test("successful batch registration reports new names for model-selection guidance", async () => { + await mountHook(); + await act(async () => { editor!.openJsonEditor(); }); + const next = JSON.parse(editor!.draft); + next.providers.gamma = { adapter: "openai-chat", baseUrl: "https://gamma.example.test/v1" }; + await act(async () => { editor!.setDraft(JSON.stringify(next)); }); + await act(async () => { expect(await editor!.saveConfig()).toBe(true); }); + expect(addedProviderNames).toEqual(["gamma"]); + expect(savedCallbacks).toBe(1); +}); + test("parse failures stay distinct from server failures and failed saves do not refresh", async () => { await mountHook(); await act(async () => { editor!.openJsonEditor(); }); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 84d363b8959..f9cfdc802a4 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -819,6 +819,7 @@ "native-profile-store.test.ts": "codex-integration", "new-model-policy.test.ts": "providers", "initial-model-selection.test.ts": "providers", + "model-selection-guidance.test.ts": "cli", "nous-oauth-live.test.ts": "providers", "nous-oauth.test.ts": "providers", "novita-provider.test.ts": "providers", diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index 7ca6b04f418..f807659cb82 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -1,4 +1,5 @@ import { writeSync } from "node:fs"; +import { modelSelectionGuidance, modelSelectionNextSteps } from "./model-selection-guidance"; import { warnIfCodexCatalogRefreshPending } from "./account-catalog-refresh"; import { isCodexResetCreditOperationId } from "../codex/reset-credit-recovery"; import { @@ -137,7 +138,7 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { }, deps); } if (noWait) { - if (wantsJson) printData(start, true); + printData({ ...start, modelSelection: modelSelectionNextSteps("openai", true) }, wantsJson, modelSelectionGuidance("openai", true)); return; } if (!start.flowId) throw new CliUsageError("login did not return a flow id"); @@ -153,7 +154,7 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { {}, deps, ); if (state.status === "done") { - printData(state, wantsJson, [`Logged in${state.email ? ` as ${String(state.email)}` : ""}.`]); + printData({ ...state, modelSelection: modelSelectionNextSteps("openai") }, wantsJson, [`Logged in${state.email ? ` as ${String(state.email)}` : ""}.`, ...modelSelectionGuidance("openai")]); if (!wantsJson) warnIfCodexCatalogRefreshPending(state); return; } @@ -184,7 +185,7 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { }, deps); } if (noWait) { - if (wantsJson) printData(start, true); + printData({ ...start, modelSelection: modelSelectionNextSteps(provider, true) }, wantsJson, modelSelectionGuidance(provider, true)); return; } for (let attempt = 0; attempt < 100; attempt++) { @@ -192,7 +193,7 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { const state = await runtimeRequest>(`/api/oauth/status?provider=${encodeURIComponent(provider)}`, {}, deps); if (state.error) throw new CliUsageError(String(state.error)); if (state.loggedIn === true) { - printData(state, wantsJson, [`Logged in to ${provider}.`]); + printData({ ...state, modelSelection: modelSelectionNextSteps(provider) }, wantsJson, [`Logged in to ${provider}.`, ...modelSelectionGuidance(provider)]); return; } } diff --git a/src/cli/init.ts b/src/cli/init.ts index 9f5551447b7..72ad3c1b707 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -1,4 +1,5 @@ import * as readline from "node:readline"; +import { modelSelectionGuidance } from "./model-selection-guidance"; import { initializeProviderModelSelection } from "../providers/initial-model-selection"; import { existsSync, readFileSync, unlinkSync } from "node:fs"; import { injectCodexConfig } from "../codex/inject"; @@ -200,6 +201,7 @@ export async function runInit(): Promise { } console.log(`\n🚀 Setup complete! Run 'ocx start' to start the proxy.`); + for (const line of modelSelectionGuidance(providerName)) console.log(line); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (/stdin (closed|reached EOF)/i.test(message)) { diff --git a/src/cli/model-selection-guidance.ts b/src/cli/model-selection-guidance.ts new file mode 100644 index 00000000000..e8e087f46b4 --- /dev/null +++ b/src/cli/model-selection-guidance.ts @@ -0,0 +1,27 @@ +/** Existing model-management commands; use the exact ID from `live`, including native IDs. */ +export function modelSelectionNextSteps(provider: string, afterLogin = false) { + const name = provider === "codex" || provider === "chatgpt" ? "openai" : provider; + return { + provider: name, + afterLogin, + requiresRunningProxy: true, + commands: { + list: `ocx models live --provider ${name}`, + enable: 'ocx models enable ""', + disable: 'ocx models disable ""', + enableAll: `ocx models provider ${name} on`, + disableAll: `ocx models provider ${name} off`, + }, + }; +} + +export function modelSelectionGuidance(provider: string, afterLogin = false): string[] { + const next = modelSelectionNextSteps(provider, afterLogin); + return [ + afterLogin ? "After login completes, manage model switches with:" : "Manage model switches (the provider stays active):", + " Start the proxy first if needed: ocx start", + " Replace with an exact ID printed by the list command.", + ...Object.values(next.commands).map(command => ` ${command}`), + " If initial discovery is still pending, check the provider connection and retry: ocx sync", + ]; +} diff --git a/src/cli/models-runtime.ts b/src/cli/models-runtime.ts index a2fc07ed031..e21fa25d9ee 100644 --- a/src/cli/models-runtime.ts +++ b/src/cli/models-runtime.ts @@ -36,6 +36,7 @@ type ModelRow = { namespaced?: string; native?: boolean; disabled?: boolean; + initialSelectionPending?: boolean; custom?: boolean; customId?: string; displayName?: string; @@ -49,7 +50,7 @@ async function live(argv: string[], deps: RuntimeApiDeps): Promise { const rows = await runtimeRequest("/api/models", {}, deps); const filtered = provider ? rows.filter(row => row.provider === provider) : rows; printData(filtered, wantsJson, filtered.map(row => { - const flags = [row.native ? "native" : "routed", row.custom ? "custom" : "", row.disabled ? "disabled" : "enabled"].filter(Boolean); + const flags = [row.native ? "native" : "routed", row.custom ? "custom" : "", row.initialSelectionPending ? "initial discovery pending" : row.disabled ? "disabled" : "enabled"].filter(Boolean); return `${row.namespaced ?? `${row.provider}/${row.id}`} [${flags.join(", ")}]`; })); } diff --git a/src/cli/provider.ts b/src/cli/provider.ts index c81e6bede2f..6795b3db52b 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -18,6 +18,7 @@ import type { OcxProviderConfig } from "../types"; import { findLiveProxy } from "../server/proxy-liveness"; import { syncModelsToCodex } from "../codex/sync"; import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; +import { modelSelectionGuidance, modelSelectionNextSteps } from "./model-selection-guidance"; // --------------------------------------------------------------------------- // Arg helpers @@ -229,6 +230,7 @@ async function handleAdd(args: string[]): Promise { if (wantsJson) { console.log(JSON.stringify({ action: "added", + modelSelection: modelSelectionNextSteps(name), provider: name, adapter: provConfig.adapter, baseUrl: provConfig.baseUrl, @@ -257,6 +259,7 @@ async function handleAdd(args: string[]): Promise { const registryLabel = registryEntry ? ` (${registryEntry.label})` : ""; console.log(`✅ Provider "${name}"${registryLabel} added.`); + for (const line of modelSelectionGuidance(name)) console.log(line); if (setDefault) console.log(` Set as default provider.`); if (registryEntry?.authKind === "oauth") { console.log(` Authenticate with: ocx login ${name}`); diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index 7f6605586c0..79a3aa6ecac 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -1,4 +1,5 @@ import * as readline from "node:readline"; +import { modelSelectionGuidance } from "../cli/model-selection-guidance"; import { initializeProviderModelSelection } from "../providers/initial-model-selection"; import { openUrl } from "../lib/open-url"; import { loadConfig, saveConfig } from "../config"; @@ -94,6 +95,7 @@ async function handleOAuthLogin(name: string): Promise { } const reload = await notifyRunningProxyAfterOAuthLogin(name); console.log(`\n✅ Logged in to ${name}. Try: ocx sync`); + for (const line of modelSelectionGuidance(name)) console.log(line); warnIfLiveReloadSkipped(reload); } @@ -213,6 +215,7 @@ async function handleKeyLogin(name: string): Promise { let reload: LocalProviderReloadResult | null = null; await commitKeyLoginProvider(config, name, provider, result => { reload = result; }); console.log(`✅ ${def.label} added. Try: ocx sync`); + for (const line of modelSelectionGuidance(name)) console.log(line); warnIfLiveReloadSkipped(reload); } diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index c71335657c8..678ee076456 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -121,7 +121,7 @@ this document owns is which module holds which area and what invariant that area | 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/badge` backs the sidebar badge: it reports that an update exists and links to the update surface rather than gating other actions. | | Providers | Create/update/delete ordinary provider configs and enrich registry metadata. 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. | +| 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. | | 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`, `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, and remove one. 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. 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. 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 [`08_openai-provider-tiers.md`](08_openai-provider-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/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 6f1dc0a09e6..453ec6c3640 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -478,6 +478,12 @@ describe("account login --device", () => { expect(JSON.parse(result.stdout)).toMatchObject({ deviceCode: "ABCD-EFGH", url: "https://auth.openai.com/codex/device", + modelSelection: { + provider: "openai", + afterLogin: true, + requiresRunningProxy: true, + commands: { list: "ocx models live --provider openai" }, + }, }); }); @@ -2065,6 +2071,16 @@ describe("ocx account CLI (issue #180 matrix)", () => { expect(JSON.parse(result.stdout)).toEqual({ status: "done", catalogRefreshPending: true, + modelSelection: { + provider: "openai", afterLogin: false, requiresRunningProxy: true, + commands: { + list: "ocx models live --provider openai", + enable: 'ocx models enable ""', + disable: 'ocx models disable ""', + enableAll: "ocx models provider openai on", + disableAll: "ocx models provider openai off", + }, + }, }); expect(result.stderr).toBe(""); } finally { diff --git a/tests/cli/cli-provider.test.ts b/tests/cli/cli-provider.test.ts index 402cc973b04..b83bc8d514a 100644 --- a/tests/cli/cli-provider.test.ts +++ b/tests/cli/cli-provider.test.ts @@ -58,6 +58,7 @@ describe("ocx provider", () => { 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); + expect(JSON.parse(added.stdout).modelSelection.commands.list).toBe("ocx models live --provider model-fixture"); const first = readConfig(dir); expect(first.providers["model-fixture"].initialModelSelection.status).toBe("pending"); const registrationId = first.providers["model-fixture"].initialModelSelection.registrationId; diff --git a/tests/cli/model-selection-guidance.test.ts b/tests/cli/model-selection-guidance.test.ts new file mode 100644 index 00000000000..c7df74d632f --- /dev/null +++ b/tests/cli/model-selection-guidance.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test"; +import { modelSelectionGuidance, modelSelectionNextSteps } from "../../src/cli/model-selection-guidance"; + +test("registration guidance uses real CLI model commands and preserves exact listed IDs", () => { + const next = modelSelectionNextSteps("openrouter"); + expect(next.commands).toEqual({ + list: "ocx models live --provider openrouter", + enable: 'ocx models enable ""', + disable: 'ocx models disable ""', + enableAll: "ocx models provider openrouter on", + disableAll: "ocx models provider openrouter off", + }); + expect(next.requiresRunningProxy).toBe(true); + const text = modelSelectionGuidance("openrouter").join("\n"); + expect(text).toContain("ocx start"); + expect(text).toContain("the provider stays active"); + expect(text).not.toContain("http"); +}); + +test("Codex login aliases target the native provider and no-wait advice is explicitly future work", () => { + for (const alias of ["codex", "chatgpt", "openai"]) { + expect(modelSelectionNextSteps(alias).commands.list).toBe("ocx models live --provider openai"); + } + expect(modelSelectionNextSteps("xai", true).afterLogin).toBe(true); + expect(modelSelectionGuidance("xai", true)[0]).toContain("After login completes"); + expect(modelSelectionNextSteps("xai", true).commands.enable).not.toContain("xai/<"); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index dd350c6aaa6..858c8a82c65 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -656,6 +656,7 @@ "native-profile-store.test.ts": "codex-integration", "new-model-policy.test.ts": "providers", "initial-model-selection.test.ts": "providers", + "model-selection-guidance.test.ts": "cli", "nous-oauth-live.test.ts": "providers", "nous-oauth.test.ts": "providers", "novita-provider.test.ts": "providers", From 936ec029f8d88c51d95751caef54ad359bc7dcbb Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 15:26:31 +0900 Subject: [PATCH 08/11] test(onboarding): match the account pool add action --- gui/tests/providers-codex-completion-toast.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/tests/providers-codex-completion-toast.test.tsx b/gui/tests/providers-codex-completion-toast.test.tsx index d5b69c0eb16..1852f8884f8 100644 --- a/gui/tests/providers-codex-completion-toast.test.tsx +++ b/gui/tests/providers-codex-completion-toast.test.tsx @@ -244,7 +244,7 @@ for (const embedded of [false, true]) { }); await flush(); await flush(); - await act(async () => { buttonWithText(host, "Add account").click(); }); + await act(async () => { buttonWithText(host, "Add").click(); }); await flush(); const login = testWindow.document.querySelector('dialog[aria-label="Add Codex Account"] button.list-row') as HTMLButtonElement; expect(login).toBeTruthy(); From fa0ca6bd587582af5dc5ba7e69b0712215f7a98a Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 15:31:40 +0900 Subject: [PATCH 09/11] fix(onboarding): include explicit native model commands --- .../020_registration_guidance.md | 3 ++ src/cli/model-selection-guidance.ts | 3 ++ tests/cli/cli-account.test.ts | 2 ++ tests/cli/model-selection-guidance.test.ts | 29 ++++++++++++++++++- 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/devlog/_plan/260905_provider_registration_selection/020_registration_guidance.md b/devlog/_plan/260905_provider_registration_selection/020_registration_guidance.md index 5a7159e1b21..2b87b1e086b 100644 --- a/devlog/_plan/260905_provider_registration_selection/020_registration_guidance.md +++ b/devlog/_plan/260905_provider_registration_selection/020_registration_guidance.md @@ -106,6 +106,9 @@ Use the exact ID printed by `live` (native or namespaced), represented in exampl by an explicitly labeled, quoted placeholder. Include `ocx start` prerequisite when the proxy is absent, and `ocx sync` retry guidance when discovery remains pending. No credentials in commands or messages. No shell execution from the builder. +Rows marked native also receive explicit enable/disable --native command variants +in both human and JSON output, so account-qualified native IDs containing a slash +are not misparsed as routed provider/model selectors. ### MODIFY CLI completion owners diff --git a/src/cli/model-selection-guidance.ts b/src/cli/model-selection-guidance.ts index e8e087f46b4..6802c1758f0 100644 --- a/src/cli/model-selection-guidance.ts +++ b/src/cli/model-selection-guidance.ts @@ -9,6 +9,8 @@ export function modelSelectionNextSteps(provider: string, afterLogin = false) { list: `ocx models live --provider ${name}`, enable: 'ocx models enable ""', disable: 'ocx models disable ""', + enableNative: 'ocx models enable "" --native', + disableNative: 'ocx models disable "" --native', enableAll: `ocx models provider ${name} on`, disableAll: `ocx models provider ${name} off`, }, @@ -21,6 +23,7 @@ export function modelSelectionGuidance(provider: string, afterLogin = false): st afterLogin ? "After login completes, manage model switches with:" : "Manage model switches (the provider stays active):", " Start the proxy first if needed: ocx start", " Replace with an exact ID printed by the list command.", + " For rows marked native, use the --native variants (including IDs containing /).", ...Object.values(next.commands).map(command => ` ${command}`), " If initial discovery is still pending, check the provider connection and retry: ocx sync", ]; diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 453ec6c3640..58146c63833 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -2077,6 +2077,8 @@ describe("ocx account CLI (issue #180 matrix)", () => { list: "ocx models live --provider openai", enable: 'ocx models enable ""', disable: 'ocx models disable ""', + enableNative: 'ocx models enable "" --native', + disableNative: 'ocx models disable "" --native', enableAll: "ocx models provider openai on", disableAll: "ocx models provider openai off", }, diff --git a/tests/cli/model-selection-guidance.test.ts b/tests/cli/model-selection-guidance.test.ts index c7df74d632f..e3719fb1d27 100644 --- a/tests/cli/model-selection-guidance.test.ts +++ b/tests/cli/model-selection-guidance.test.ts @@ -1,5 +1,6 @@ -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import { modelSelectionGuidance, modelSelectionNextSteps } from "../../src/cli/model-selection-guidance"; +import { handleModelsRuntimeCommand } from "../../src/cli/models-runtime"; test("registration guidance uses real CLI model commands and preserves exact listed IDs", () => { const next = modelSelectionNextSteps("openrouter"); @@ -7,6 +8,8 @@ test("registration guidance uses real CLI model commands and preserves exact lis list: "ocx models live --provider openrouter", enable: 'ocx models enable ""', disable: 'ocx models disable ""', + enableNative: 'ocx models enable "" --native', + disableNative: 'ocx models disable "" --native', enableAll: "ocx models provider openrouter on", disableAll: "ocx models provider openrouter off", }); @@ -14,9 +17,33 @@ test("registration guidance uses real CLI model commands and preserves exact lis const text = modelSelectionGuidance("openrouter").join("\n"); expect(text).toContain("ocx start"); expect(text).toContain("the provider stays active"); + expect(text).toContain("For rows marked native"); expect(text).not.toContain("http"); }); +test("generated native commands preserve qualified IDs through the actual CLI parser", async () => { + const log = spyOn(console, "log").mockImplementation(() => {}); + const writes: unknown[] = []; + try { + const commands = modelSelectionNextSteps("openai").commands; + for (const command of [commands.enableNative, commands.disableNative]) { + const [, , action, placeholder, ...flags] = command.split(" "); + const selector = placeholder.replace('""', "team/gpt-future-unlisted"); + expect(await handleModelsRuntimeCommand(action, [selector, ...flags], { + baseUrl: "http://model-guidance.test", + fetchImpl: (async (_input, init) => { + writes.push(JSON.parse(String(init?.body))); + return Response.json({ ok: true }); + }) as typeof fetch, + })).toBe(0); + } + expect(writes).toEqual([true, false].map(enabled => ({ + scope: "models", provider: "openai", enabled, + targets: [{ id: "team/gpt-future-unlisted", native: true }], + }))); + } finally { log.mockRestore(); } +}); + test("Codex login aliases target the native provider and no-wait advice is explicitly future work", () => { for (const alias of ["codex", "chatgpt", "openai"]) { expect(modelSelectionNextSteps(alias).commands.list).toBe("ocx models live --provider openai"); From 8a31d16a63f0203f8519fd056dce44f70d0cccd6 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 17:38:23 +0900 Subject: [PATCH 10/11] fix(server): carry final port-probe peer disposal Carry only src/server/ports.ts and tests/server/ports.test.ts from PR #3640 final head d2b4a81c61294c3c9ae7a2d58a01397167b120d0. No upstream work logs, unrelated source changes, or stack rewrites are included. Source commits: - 0ea491ea7a202f27bf2041f916bd902056ecc225 (regressions) - 0d9b6c2b20bf90b6da506fc6f6935845e31d6990 (probe disposal) - f47a8e39885a6c79ffdb7b50fb4594aae199a2da (explicit listener registration) Local preparation and static review only. This combined head requires new runtime validation; prior CI outcomes are not reused as proof. Co-authored-by: t --- src/server/ports.ts | 14 +++++- tests/server/ports.test.ts | 100 +++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/server/ports.ts b/src/server/ports.ts index 4c5a857803e..11fe76838ac 100644 --- a/src/server/ports.ts +++ b/src/server/ports.ts @@ -1,5 +1,15 @@ import { createServer } from "node:net"; +/** Temporary bind probes must not let accepted peers hold server.close() open. */ +function createProbeServer(): ReturnType { + const server = createServer(); + server.on("connection", socket => { + socket.on("error", () => socket.destroy()); + socket.destroy(); + }); + return server; +} + /** * True when an error means "this port/address is already bound" — the only bind failure * that is safe to answer with a retry on another port. Bun/Node surface it as @@ -15,7 +25,7 @@ export function isAddrInUse(err: unknown): boolean { export async function isPortAvailable(port: number, hostname = "127.0.0.1"): Promise { return await new Promise(resolve => { - const server = createServer(); + const server = createProbeServer(); // Fail closed: EACCES / EADDRNOTAVAIL / EPERM / unknown listen errors mean the // requested bind is not available. Only the listening event reports free. server.once("error", () => resolve(false)); @@ -133,7 +143,7 @@ export function setEphemeralPortAllocatorForTests( async function allocateEphemeralPort(hostname: string): Promise { if (ephemeralAllocator) return ephemeralAllocator(hostname); return await new Promise((resolve, reject) => { - const server = createServer(); + const server = createProbeServer(); server.once("error", reject); server.once("listening", () => { const address = server.address(); diff --git a/tests/server/ports.test.ts b/tests/server/ports.test.ts index 6d573ad7525..6471244c564 100644 --- a/tests/server/ports.test.ts +++ b/tests/server/ports.test.ts @@ -1,6 +1,86 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createServer, type Server } from "node:net"; +import { pathToFileURL } from "node:url"; import { findAvailablePort, isAddrInUse, isPortAvailable, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../../src/server/ports"; +import { repoPath, repoRoot } from "../helpers/repo-root"; + +// Prototype overrides exist only inside the disposable child process. +const PORT_PROBE_PEER_DISPOSAL_CHILD = ` + import assert from "node:assert/strict"; + import { EventEmitter } from "node:events"; + import { Server } from "node:net"; + + const [operation, portsUrl] = process.argv.slice(-2); + const peers = Array.from({ length: 2 }, () => { + const peer = new EventEmitter(); + peer.destroyed = false; + peer.destroyCalls = 0; + peer.destroy = () => { + peer.destroyCalls++; + peer.destroyed = true; + return peer; + }; + return peer; + }); + let bindOptions; + let completeClose; + let closeCompleted = false; + let probeCalls = 0; + // Native createServer stays real, including its connection-listener registration. + Server.prototype.address = function () { + return { address: "127.0.0.1", family: "IPv4", port: 43219 }; + }; + Server.prototype.close = function (callback) { + completeClose = () => { + if (peers.some(peer => !peer.destroyed)) return false; + closeCompleted = true; + callback(); + return true; + }; + return this; + }; + Server.prototype.listen = function (options) { + probeCalls++; + bindOptions = options; + for (const peer of peers) this.emit("connection", peer); + this.emit("listening"); + return this; + }; + + const ports = await import(portsUrl); + let settled = false; + let rejection; + const pending = (operation === "isPortAvailable" + ? ports.isPortAvailable(43117, "127.0.0.1") + : ports.findAvailablePort(0, "127.0.0.1")).then(value => { + settled = true; + return value; + }, error => { + settled = true; + rejection = error; + }); + // One event-loop turn drains promise reactions without time-based polling. + await new Promise(resolve => setImmediate(resolve)); + assert.equal(probeCalls, 1, "must intercept the real temporary Server instance"); + assert.deepEqual(bindOptions, { + port: operation === "isPortAvailable" ? 43117 : 0, host: "127.0.0.1", + }); + assert.equal(rejection, undefined, "probe must not reject before disposal assertions"); + assert.equal(typeof completeClose, "function", "server.close callback must be registered"); + assert.deepEqual(peers.map(peer => peer.destroyed), [true, true], + "probe must destroy both accepted peers"); + for (const peer of peers) { + const beforeError = peer.destroyCalls; + assert.doesNotThrow(() => peer.emit("error", new Error("peer reset"))); + assert.ok(peer.destroyCalls > beforeError, "socket errors must dispose the peer"); + } + await new Promise(resolve => setImmediate(resolve)); + assert.equal(settled, false, "destroying peers must not resolve before close callback"); + assert.equal(closeCompleted, false); + assert.equal(completeClose(), true); + const value = await pending; + console.log(JSON.stringify({ value, closeCompleted })); +`; const servers: Server[] = []; @@ -30,6 +110,26 @@ afterEach(async () => { }); describe("port selection", () => { + test.each(["isPortAvailable", "findAvailablePort"] as const)( + "%s disposes accepted peers and waits for probe close completion", + (operation) => { + // Keep Server.prototype overrides out of this process and its real-socket tests. + const portsUrl = pathToFileURL(repoPath("src", "server", "ports.ts")).href; + const child = Bun.spawnSync([process.execPath, "--eval", PORT_PROBE_PEER_DISPOSAL_CHILD, "--", operation, portsUrl], { + cwd: repoRoot(), + stdout: "pipe", + stderr: "pipe", + timeout: 5000, + }); + expect(child.exitCode, child.stderr.toString()).toBe(0); + expect(JSON.parse(child.stdout.toString())).toEqual({ + value: operation === "isPortAvailable" ? true : 43219, + closeCompleted: true, + }); + }, + 10000, + ); + test("resolves port 0 to a concrete ephemeral port", async () => { const selected = await findAvailablePort(0); From f6aeb7a3c60a9c4c2f6d924198b539ccddb9a939 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 17:44:39 +0900 Subject: [PATCH 11/11] docs(providers): clarify reliable initial discovery Carry the one-line clarification prepared in 1717640aa64aabaffa883d16e7a8d1f67add58b7 into the onboarding PR for final delivery. --- .../src/content/docs/ko/reference/configuration/providers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b263aa11ff1..3d65dbfb4d6 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -7,7 +7,7 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할 ## 처음 등록할 때의 모델 선택 -신규 비-OAuth 연결은 모델 목록 조회가 끝날 때까지 모델 노출을 보류합니다. Models 탭의 중복 없는 모델 행이 20개 이상이면 모델 스위치를 모두 OFF로 설정합니다. 프로바이더는 활성 상태를 유지합니다. 실제 인증 방식이 OAuth나 ChatGPT 로그인인 연결은 기존 기본값을 유지합니다. +신규 비-OAuth 연결은 신뢰할 수 있는 모델 목록을 확보할 때까지 모델 노출을 보류합니다. Models 탭의 중복 없는 모델 행이 20개 이상이면 모델 스위치를 모두 OFF로 설정합니다. 프로바이더는 활성 상태를 유지합니다. 실제 인증 방식이 OAuth나 ChatGPT 로그인인 연결은 기존 기본값을 유지합니다. 처음 등록할 때만 적용하며 업데이트, 재로그인, 키 교체로 기존 선택을 초기화하지 않습니다. 초기 설정이 끝나면 Models 탭이나 아래 CLI 명령으로 필요한 모델을 켤 수 있습니다. 이후 새 모델이 추가될 때의 정책은 별도입니다. ``는 목록에 나온 ID로 바꾸세요.