From d74711f440a3224ff9d014a748d68e6d9b2916d2 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 6 Nov 2026 12:02:25 +0900 Subject: [PATCH 01/15] fix(models): list router-known IDs with local metadata Project effective provider policy without refreshing reasoning metadata for local listing. Co-authored-by: ismell0992-afk <255305877+ismell0992-afk@users.noreply.github.com> --- .../docs/reference/cli/providers-accounts.md | 8 +- .../references/01_surface_providers-models.md | 6 +- src/cli/capabilities-provider-models.ts | 6 +- src/cli/models.ts | 62 +++-- src/reasoning-effort.ts | 9 +- structure/cli-management.md | 2 +- tests/cli/cli-models.test.ts | 232 +++++++++++++++++- .../reasoning-metadata.test.ts | 26 +- 8 files changed, 315 insertions(+), 36 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 092133e51de..fcefca53955 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -1200,8 +1200,10 @@ moved because its encrypted AAD and operating-system keyring identity are intent ### `ocx models [subcommand]` · `ocx model ` -`ocx model` is an alias of `ocx models`. With no subcommand, list the models statically seeded in -configured providers. `--provider` filters one configured provider and `--json` returns model +`ocx model` is an alias of `ocx models`. With no subcommand, list saved and router-known model IDs +for configured providers, including registry seeds, custom models and this process's discovery +cache. A fresh CLI process may have an empty cache; this is not the running proxy catalog. Legacy +key presets may retain registry seeds when their configured destination changes. `--provider` filters one configured provider and `--json` returns model metadata. `live` reads the running catalog; `add`, `edit`, `remove`, and `list-custom` manage manual catalog entries; `enable`, `disable`, and `provider` control visibility; `selected` controls a provider allowlist; `context` controls provider context caps; and `shadow` manages background @@ -1231,7 +1233,7 @@ from custom-model `edit`. | Subcommand | Supported flags | Action | | --- | --- | --- | -| `list` (default) | `--provider `, `--json` | List models seeded in configured providers, with estimated input/output prices. | +| `list` (default) | `--provider `, `--json` | List local saved and router-known models, with estimated input/output prices. | | `live` | `--provider `, `--json` | Read the running catalog, including models discovered at runtime. Rows are flagged `native`/`routed`, `custom`, and `enabled`/`disabled`. | | `price ` | `--json` | Read the saved manual override and effective price, including automatic catalog defaults. | | `set-price ` | `--input `, `--output `, `--cache-read `, `--cache-write `, `--auto`, `--json` | Set display prices in USD per 1M tokens. Input/output are required when setting; omitted cache rates become zero. `--auto` removes only this model's override. | diff --git a/skills/ocx/references/01_surface_providers-models.md b/skills/ocx/references/01_surface_providers-models.md index 5f9804ef3fe..75571fe5f74 100644 --- a/skills/ocx/references/01_surface_providers-models.md +++ b/skills/ocx/references/01_surface_providers-models.md @@ -385,7 +385,7 @@ Drives no management route. Usage: `ocx models list [--provider ] [--json]` -List static configured models locally. +List local configured and router-known models. State-changing: no. JSON mode: `envelope`. @@ -394,9 +394,9 @@ Drives no management route. | Flag | Value | Meaning | |---|---|---| | `--provider` | string | Limit to a configured provider. | -| `--json` | boolean | Emit models and the static-catalog note. | +| `--json` | boolean | Emit models and the local-catalog note. | -- A bare models invocation is equivalent. Reads local config, never the live catalog; use models live for runtime discovery. +- A bare models invocation is equivalent. Includes saved IDs, registry seeds, custom models and this process's discovery cache; use models live for the running catalog. ### `ocx models add` diff --git a/src/cli/capabilities-provider-models.ts b/src/cli/capabilities-provider-models.ts index 871712a790e..56bd2fb2bd3 100644 --- a/src/cli/capabilities-provider-models.ts +++ b/src/cli/capabilities-provider-models.ts @@ -87,10 +87,10 @@ export const PROVIDER_MODEL_CAPABILITIES: readonly Capability[] = [ details: ["Projects values only from an explicit allowlist of non-secret metadata fields (provider IDs, app types, base URLs, wire dialects, and default models); source and embedded-config bytes are read transiently while secret fields, free-text names, and TOML comments are skipped at the byte level and never decoded into strings, written, or logged, and transient byte buffers are zeroed in finally.", "Only import from a trusted local CC-Switch source: apply prints every imported ID, model, and destination URL before writing, refuses SQLite hard links (nlink > 1) and non-empty -wal/-journal sidecars until CC-Switch is closed, and opens sources read-only with O_NOFOLLOW|O_NONBLOCK descriptor checks; restore inspects auth.json read-only at the byte level and retains any provider that was edited, selected as defaultProvider, or referenced by routing, combos, fallback policy, OAuth accounts, or API keys."], }, { - command: ["models", "list"], summary: "List static configured models locally.", + command: ["models", "list"], summary: "List local configured and router-known models.", usage: "ocx models list [--provider ] [--json]", routes: [], mutates: false, json: "envelope", - flags: [{ name: "--provider", value: "string", summary: "Limit to a configured provider." }, { name: "--json", value: "boolean", summary: "Emit models and the static-catalog note." }], - details: ["A bare models invocation is equivalent. Reads local config, never the live catalog; use models live for runtime discovery."], + flags: [{ name: "--provider", value: "string", summary: "Limit to a configured provider." }, { name: "--json", value: "boolean", summary: "Emit models and the local-catalog note." }], + details: ["A bare models invocation is equivalent. Includes saved IDs, registry seeds, custom models and this process's discovery cache; use models live for the running catalog."], }, { command: ["models","add"], diff --git a/src/cli/models.ts b/src/cli/models.ts index 28d83536b49..3668d874018 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -6,7 +6,7 @@ import { resolveMatchedPrice, type MatchedPrice } from "../usage/cost"; import { randomUUID } from "node:crypto"; import { createInterface } from "node:readline/promises"; import { syncModelsToCodex } from "../codex/sync"; -import { configuredContextWindow } from "../codex/catalog/provider-fetch"; +import { applyRegistryCapabilitySeedFill, configuredContextWindow } from "../codex/catalog/provider-fetch"; import { hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config"; import { readCliConfigForMutation } from "./config-mutation"; import { @@ -16,6 +16,11 @@ import { import { encodedModelIdCollides, resolveSlugSelection, routedSlug } from "../providers/slug-codec"; import { isModelsRuntimeSubcommand } from "./models-runtime-subcommands"; import { knownModelIdsForProvider } from "../router"; +import { enrichProviderFromRegistry, registryInputModalitiesForDestination } from "../providers/derive"; +import { getProviderRegistryEntry } from "../providers/registry"; +import { providerMatchesRegistryTransportWithStaticGuards } from "../providers/static-model-discovery"; +import { resolveModelPolicy } from "../providers/resolved-model-policy"; +import { customRowInputModalities } from "../vision/eligibility"; import { findLiveProxy } from "../server/proxy-liveness"; import { modelInList, type OcxConfig, type OcxCustomModel } from "../types"; import type { RuntimeApiDeps } from "./runtime-api"; @@ -56,35 +61,45 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] for (const [provName, prov] of Object.entries(providers)) { if (!prov) continue; const seen = new Set(); - const inputModalities = prov.modelInputModalities ?? {}; + // Model listing is read-only: derive effective registry policy on a private copy. + const effective = structuredClone(prov); + const registryMatches = providerMatchesRegistryTransportWithStaticGuards(provName, prov); + if (registryMatches) applyRegistryCapabilitySeedFill(provName, effective); + enrichProviderFromRegistry(provName, effective); + if (registryMatches) { + const policy = resolveModelPolicy({ + providerName: provName, modelId: effective.defaultModel ?? "__catalog_capture__", provider: effective, + registryEntry: getProviderRegistryEntry(provName), transportMatchedRegistry: true, + ...(effective.authMode ? { effectiveAuth: { authMode: effective.authMode } } : {}), + }).provider; + Object.assign(effective, policy); + } + effective.inputModalities ??= registryInputModalitiesForDestination(provName, prov); /** Append one model with resolved metadata, ignoring duplicates within this provider. */ const addModel = (model: string, isDefault: boolean) => { if (seen.has(model)) return; seen.add(model); - // Resolve exactly as the runtime does, or this command reports capabilities the - // proxy will not honour: `isModelTextOnly` matches noVisionModels with modelInList - // and reads modelInputModalities with modelRecordValue, so a `gpt-oss` entry covers - // `gpt-oss:120b`. A bare lookup reported that model as unclassified on every field. - // noVisionModels is checked first because `isModelTextOnly` returns true on that - // match before it ever reads modelInputModalities: a `gpt-oss` noVision entry beats - // an exact `gpt-oss:120b` entry that lists "image", and the proxy rejects the image. - const noVision = modelInList(prov.noVisionModels, model); - const modalities = noVision ? ["text"] : (modelRecordValue(inputModalities, model) ?? null); + const custom = config.customModels?.find(row => row.provider === provName && row.modelId === model); + const capability = Object.hasOwn(effective.modelCapabilities ?? {}, model) + ? effective.modelCapabilities?.[model]?.inputModalities : undefined; + const customModalities = customRowInputModalities(config, provName, model); + const modalities = capability ?? customModalities ?? (modelInList(effective.noVisionModels, model) + ? ["text"] : modelRecordValue(effective.modelInputModalities, model) ?? effective.inputModalities ?? null); // Same reason, for the ladder: `configuredReasoningEfforts` is what the catalog // (`provider-fetch`) and the effort cap (`effort-policy`) resolve through, and it // does three things this expression did not — it returns [] for a noReasoningModels // match, drops levels Codex does not declare, and re-adds tiers the wire map proves // the model emits. Restating two of its five lines here reported a ladder the proxy // strips, and unsanitized junk as a supported level. - const efforts = configuredReasoningEfforts(prov, model) ?? null; + const efforts = custom?.reasoningEfforts ?? configuredReasoningEfforts(effective, model, { refreshMetadata: false }) ?? null; entries.push({ provider: provName, model, isDefault, - contextWindow: configuredContextWindow(prov, model) ?? null, + contextWindow: custom?.contextWindow ?? configuredContextWindow(effective, model) ?? null, inputModalities: modalities, reasoningEfforts: efforts, price: resolveMatchedPrice(provName, model), @@ -94,9 +109,17 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] // defaultModel first if (prov.defaultModel) addModel(prov.defaultModel, true); - // models array - if (prov.models) { - for (const m of prov.models) addModel(m, m === prov.defaultModel); + // Keep the CLI list current when this config predates a provider's registry seed. + // knownModelIdsForProvider is the router's own derivation: config models first, then + // registry seeds, hint-map keys, and live-discovered cache ids (registry-derived + // sources only enter when the router's registry transport guard matches), then customModels. + // Some legacy key presets retain registry ownership even after a destination change. Because hint-map keys + // are included, wire-only ids a provider's collapsed picker hides still appear here; + // that is router parity. A model the user pruned from `models` reappears if the + // registry still seeds it -- the list is a union, not the user's curation. The + // user's saved models remain first and are never discarded. + for (const m of [...(prov.models ?? []), ...knownModelIdsForProvider(provName, prov, config)]) { + addModel(m, m === prov.defaultModel); } } @@ -388,18 +411,19 @@ function handleConfiguredModels(args: string[]): void { } const models = collectModels(config, providerFilter ?? undefined); + const note = "Local saved and router-known IDs include registry seeds, custom models and this process's discovery cache. Use ocx models live for the running catalog."; if (wantsJson) { console.log(JSON.stringify({ models, - note: "Static config models only. Providers with liveModels=true may have additional models at runtime.", + note, }, null, 2)); return; } if (models.length === 0) { console.log("No models found in configured providers."); - if (!providerFilter) console.log("Providers may discover models dynamically at runtime (liveModels)."); + console.log(`Note: ${note}`); return; } @@ -425,7 +449,7 @@ function handleConfiguredModels(args: string[]): void { } console.log("* = default model for provider"); - console.log("Note: providers with liveModels may have additional models at runtime."); + console.log(`Note: ${note}`); } export async function handleModels(args: string[], deps: ModelsCommandDeps = {}): Promise { diff --git a/src/reasoning-effort.ts b/src/reasoning-effort.ts index 02669030abe..5fa5356a23b 100644 --- a/src/reasoning-effort.ts +++ b/src/reasoning-effort.ts @@ -153,8 +153,13 @@ export function sanitizeCodexReasoningEfforts(efforts: readonly string[] | undef /** * Provider/model configured reasoning levels for the Codex catalog. `undefined` means “no override”, * while an empty array means “intentionally expose no effort control for this model”. + * Read-only callers can suppress stale-snapshot refresh without changing the returned ladder. */ -export function configuredReasoningEfforts(provider: OcxProviderConfig, modelId: string): string[] | undefined { +export function configuredReasoningEfforts( + provider: OcxProviderConfig, + modelId: string, + options?: { refreshMetadata?: boolean }, +): string[] | undefined { if (modelInList(provider.noReasoningModels, modelId)) return []; const modelEfforts = modelRecordValue(provider.modelReasoningEfforts, modelId); // Rungs this account actually had refused are removed for every ladder source (registry @@ -180,7 +185,7 @@ export function configuredReasoningEfforts(provider: OcxProviderConfig, modelId: // not request work. const fromMetadata = reasoningEffortsFromMetadata(provider, modelId); if (fromMetadata !== undefined) { - ensureReasoningMetadataSnapshot(); + if (options?.refreshMetadata !== false) ensureReasoningMetadataSnapshot(); return dropLearnedUnsupportedReasoningEfforts(provider, modelId, healMappedTiers(provider, modelId, fromMetadata)); } return undefined; diff --git a/structure/cli-management.md b/structure/cli-management.md index 05bac0abe70..26059ce27fd 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -91,7 +91,7 @@ live and latest durable rows are captured under the same mutation lock as deleti ## Model and routing task commands -`src/cli/models.ts` keeps local custom-model authoring as default and selects explicit live operations before local state. `src/cli/models-custom-runtime.ts` resolves deletion against the complete target roster through the shared slug contract and addresses an exact stored id, refusing ambiguous or malformed identities. `src/cli/models-custom-input.ts` owns the shared pure reasoning grammar, re-exported from the original module. Local JSON writes report saved state and opportunistic sync: no proxy is pending success; attempted failure is nonzero. `src/cli/local-sync-result.ts` is the pure safe projection shared with provider writes; caller-specific required/opportunistic policies remain separate. +`src/cli/models.ts` lists saved and router-known IDs locally, including guarded registry seeds, custom rows and its own process cache; `models live` reads the running catalog. Local listing derives metadata from a detached effective policy and does not refresh reasoning metadata. It keeps local custom-model authoring as default and selects explicit live operations before local state. `src/cli/models-custom-runtime.ts` resolves deletion against the complete target roster through the shared slug contract and addresses an exact stored id, refusing ambiguous or malformed identities. `src/cli/models-custom-input.ts` owns the shared pure reasoning grammar, re-exported from the original module. Local JSON writes report saved state and opportunistic sync: no proxy is pending success; attempted failure is nonzero. `src/cli/local-sync-result.ts` is the pure safe projection shared with provider writes; caller-specific required/opportunistic policies remain separate. `src/cli/models-order.ts` owns raw upstream display-name addressing and routed picker operations. Status preserves saved native identities, while manual/preset replacement requires explicit reset of native-inclusive order. Manual input is a full unique routed permutation with the observed featured prefix; settings/identity rereads reject observable drift without claiming CAS. `src/cli/model-picker-ordering.ts` mirrors the GUI's pure identity/sort projection without importing GUI code into the CLI. Independent ordering fixtures and test-only GUI conformance exercise the deliberate shared contract. Most-used consumes complete all/all requested-model usage, not representative resolved-model attribution. The registry declares the existing display-name PUT; its handler remains authoritative, including saved-but-failed HTTP503. diff --git a/tests/cli/cli-models.test.ts b/tests/cli/cli-models.test.ts index 9dbfb6c7195..c65edfc22e2 100644 --- a/tests/cli/cli-models.test.ts +++ b/tests/cli/cli-models.test.ts @@ -1,6 +1,6 @@ import { beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -16,17 +16,21 @@ const cliPath = join(repoRoot, "src", "cli", "index.ts"); setDefaultTimeout(SPAWN_BUDGET_MS); -function runCli(args: string[], env: Record = {}) { +function runCli(args: string[], env: Record = {}, recordAttempts = false) { const fixtureHome = env.OPENCODEX_HOME; const preload = fixtureHome ? join(fixtureHome, "no-live-sync.ts") : undefined; if (preload) { mkdirSync(join(fixtureHome!, "codex"), { recursive: true }); writeFileSync(preload, ` import { mock } from "bun:test"; + import { appendFileSync } from "node:fs"; + const marker = ${JSON.stringify(join(fixtureHome!, "network-attempts.log"))}; + const record = kind => { if (${recordAttempts}) appendFileSync(marker, kind + "\\n"); }; const path = ${JSON.stringify(join(repoRoot, "src/server/proxy-liveness.ts"))}; const original = await import(path); - mock.module(path, () => ({ ...original, findLiveProxy: async () => null })); - globalThis.fetch = () => { throw new Error("fixture network forbidden"); }; + mock.module(path, () => ({ ...original, findLiveProxy: async () => { record("liveness"); return null; } })); + globalThis.fetch = () => { record("fetch"); throw new Error("fixture network forbidden"); }; + if (process.env.OCX_TEST_PROBE_NETWORK === "1") { try { await globalThis.fetch("https://fixture.test"); } catch {} } `); } const result = spawnSync(process.execPath, [...(preload ? ["--preload", preload] : []), cliPath, ...args], { @@ -72,6 +76,170 @@ describe("ocx models", () => { await warmModuleGraph({ graph: "cli-index/models", entry: cliPath }); }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); + test("lists a custom-only router-known model with its saved metadata", () => { + const { dir } = freshConfig({ + providers: { test: { adapter: "openai-chat", baseUrl: "https://example.test/v1", models: [] } }, + customModels: [{ id: "11111111-1111-4111-8111-111111111111", provider: "test", + modelId: "custom-only", contextWindow: 90000, inputModalities: ["text", "image"], reasoningEfforts: [] }], + }); + try { + const result = runCli(["models", "--provider", "test", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).models).toContainEqual(expect.objectContaining({ + provider: "test", model: "custom-only", contextWindow: 90000, + inputModalities: ["text", "image"], reasoningEfforts: [], + })); + } finally { removeTreeWithRetry(dir); } + }); + + test("model capabilities and custom rows precede provider noVision hints", () => { + const { dir } = freshConfig({ + providers: { test: { adapter: "openai-chat", baseUrl: "https://example.test/v1", + models: ["base", "custom", "cap"], contextWindow: 120000, + noVisionModels: ["base", "custom", "cap"], modelInputModalities: { base: ["text", "image"] }, + modelCapabilities: { cap: { inputModalities: ["text"] } } } }, + customModels: [ + { id: "11111111-1111-4111-8111-111111111111", provider: "test", modelId: "custom", + contextWindow: 90000, inputModalities: ["text", "image"], reasoningEfforts: [] }, + { id: "22222222-2222-4222-8222-222222222222", provider: "test", modelId: "cap", + inputModalities: ["text", "image"] }, + ], + }); + try { + const configBefore = readFileSync(join(dir, "config.json"), "utf8"); + const result = runCli(["models", "--provider", "test", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const rows = JSON.parse(result.stdout).models; + expect(rows.find((row: { model: string }) => row.model === "base").inputModalities).toEqual(["text"]); + expect(rows.find((row: { model: string }) => row.model === "custom")).toMatchObject({ + contextWindow: 90000, inputModalities: ["text", "image"], reasoningEfforts: [], + }); + expect(rows.find((row: { model: string }) => row.model === "cap").inputModalities).toEqual(["text"]); + expect(readFileSync(join(dir, "config.json"), "utf8")).toBe(configBefore); + } finally { removeTreeWithRetry(dir); } + }); + + test("registry noVision union survives an existing saved list", () => { + for (const saved of [[], ["unrelated"]]) { + const { dir } = freshConfig({ providers: { deepseek: { adapter: "openai-chat", + baseUrl: "https://api.deepseek.com/v1", models: ["deepseek-chat"], noVisionModels: saved, + modelInputModalities: { "deepseek-chat": ["text", "image"] } } } }); + try { + const result = runCli(["models", "--provider", "deepseek", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).models.find((row: { model: string }) => row.model === "deepseek-chat").inputModalities) + .toEqual(["text"]); + } finally { removeTreeWithRetry(dir); } + } + }); + + test("custom and capability modalities override registry noVision in order", () => { + for (const [capability, expected] of [[undefined, ["text", "image"]], [["text"], ["text"]]] as const) { + const { dir } = freshConfig({ providers: { deepseek: { adapter: "openai-chat", + baseUrl: "https://api.deepseek.com/v1", models: ["deepseek-chat"], noVisionModels: ["unrelated"], + ...(capability ? { modelCapabilities: { "deepseek-chat": { inputModalities: capability } } } : {}) } }, + customModels: [{ id: "11111111-1111-4111-8111-111111111111", provider: "deepseek", + modelId: "deepseek-chat", inputModalities: ["text", "image"] }] }); + try { + const result = runCli(["models", "--provider", "deepseek", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).models.find((row: { model: string }) => row.model === "deepseek-chat").inputModalities) + .toEqual(expected); + } finally { removeTreeWithRetry(dir); } + } + }); + + test("OpenAI API context windows honor registry caps and fill partial saved maps", () => { + for (const [saved, expected] of [[2000000, 1050000], [600000, 600000]] as const) { + const { dir } = freshConfig({ providers: { "openai-apikey": { adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", authMode: "key", models: ["gpt-6-astra"], + modelContextWindows: { "gpt-6-astra": saved } } } }); + try { + const result = runCli(["models", "--provider", "openai-apikey", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const rows = JSON.parse(result.stdout).models; + expect(rows.find((row: { model: string }) => row.model === "gpt-6-astra").contextWindow).toBe(expected); + expect(rows.find((row: { model: string }) => row.model === "gpt-6-luna").contextWindow).toBe(1050000); + } finally { removeTreeWithRetry(dir); } + } + }); + + test("listing reads a stale reasoning snapshot without attempting network or liveness", () => { + const { dir } = freshConfig({ providers: { test: { adapter: "openai-chat", + baseUrl: "https://opencode.ai/zen/go/v1", models: ["fixture-model"] } } }); + const file = join(dir, "reasoning-metadata-cache.json"); + const snapshot = JSON.stringify({ version: 1, fetchedAt: Date.now() - 25 * 60 * 60 * 1000, + source: "test", providers: { "opencode-go": { "fixture-model": { + reasoning: true, options: [{ type: "effort", values: ["low", "high"] }], + } } } }); + writeFileSync(file, snapshot); + try { + const result = runCli(["models", "--provider", "test", "--json"], { OPENCODEX_HOME: dir }, true); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).models[0].reasoningEfforts).toEqual(["low", "high"]); + expect(existsSync(join(dir, "network-attempts.log"))).toBe(false); + expect(readFileSync(file, "utf8")).toBe(snapshot); + const control = runCli(["models", "--provider", "test", "--json"], + { OPENCODEX_HOME: dir, OCX_TEST_PROBE_NETWORK: "1" }, true); + expect(control.status).toBe(0); + expect(readFileSync(join(dir, "network-attempts.log"), "utf8")).toContain("fetch"); + } finally { removeTreeWithRetry(dir); } + }); + + test("keeps default first, deduplicates per provider and uses provider-wide modalities", () => { + const { dir } = freshConfig({ providers: { + one: { adapter: "openai-chat", baseUrl: "https://one.example/v1", defaultModel: "default-only", + models: ["duplicate", "duplicate"], inputModalities: ["text"] }, + two: { adapter: "openai-chat", baseUrl: "https://two.example/v1", models: ["duplicate"] }, + }, customModels: [{ id: "11111111-1111-4111-8111-111111111111", provider: "one", modelId: "duplicate" }] }); + try { + const json = runCli(["models", "--json"], { OPENCODEX_HOME: dir }); + expect(json.status).toBe(0); + const parsed = JSON.parse(json.stdout); + expect(parsed.models.filter((row: { provider: string }) => row.provider === "one").map((row: { model: string }) => row.model)) + .toEqual(["default-only", "duplicate"]); + expect(parsed.models.filter((row: { model: string }) => row.model === "duplicate")).toHaveLength(2); + expect(parsed.models.find((row: { provider: string }) => row.provider === "one").inputModalities).toEqual(["text"]); + expect(parsed.note).toContain("this process's discovery cache"); + const human = runCli(["models"], { OPENCODEX_HOME: dir }); + expect(human.stdout).toContain("this process's discovery cache"); + } finally { removeTreeWithRetry(dir); } + }); + + test("legacy key preset router seeds and mimo-free's special guard match routing", () => { + const { dir } = freshConfig({ providers: { + deepseek: { adapter: "openai-chat", baseUrl: "https://other.example/v1", models: ["my-model"] }, + "mimo-free": { adapter: "openai-chat", baseUrl: "https://other.example/v1", models: ["my-model"] }, + } }); + try { + const result = runCli(["models", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const rows = JSON.parse(result.stdout).models as Array<{ provider: string; model: string }>; + expect(rows.filter(row => row.provider === "deepseek").map(row => row.model)).toContain("deepseek-chat"); + expect(rows.filter(row => row.provider === "mimo-free").map(row => row.model)).toEqual(["my-model"]); + } finally { removeTreeWithRetry(dir); } + const matching = freshConfig({ providers: { "mimo-free": { adapter: "mimo-free", + baseUrl: "https://api.xiaomimimo.com/api/free-ai/openai/chat", authMode: "key", models: ["my-model"] } } }); + try { + const result = runCli(["models", "--provider", "mimo-free", "--json"], { OPENCODEX_HOME: matching.dir }); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).models.map((row: { model: string }) => row.model)).toContain("mimo-auto"); + } finally { removeTreeWithRetry(matching.dir); } + }); + + test("partial saved Kimi effort maps keep registry wire ladder values", () => { + const { dir } = freshConfig({ providers: { "kimi-code": { adapter: "openai-chat", + baseUrl: "https://api.kimi.com/coding/v1", authMode: "key", models: ["kimi-for-coding"], + modelReasoningEfforts: { "kimi-for-coding": ["max"] }, + modelReasoningEffortMap: { unrelated: { high: "high" } } } } }); + try { + const result = runCli(["models", "--provider", "kimi-code", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const row = JSON.parse(result.stdout).models.find((item: { model: string }) => item.model === "kimi-for-coding"); + expect(row.reasoningEfforts).toEqual(["low", "high", "max"]); + } finally { removeTreeWithRetry(dir); } + }); + test("configured models expose automatic prices, overrides and unknowns without persisting defaults", () => { const { dir } = freshConfig({ providers: { relay: { adapter: "openai-chat", baseUrl: "https://example.com/v1", @@ -148,6 +316,62 @@ describe("ocx models", () => { } }); + // The registry-sync merge must go through the router's guarded derivation, not a raw + // registry read: a provider whose transport diverges from its namesake registry entry + // gets nothing, a matching one gets the seed, and stale saved configs gain new seed ids + // while saved models stay first. cline is a registry entry with preserveCustomDestination + // (fixed key destination), so the transport guard actually discriminates for it. + test("registry seed syncs into a stale saved config when the transport matches", () => { + const { dir } = freshConfig({ providers: { cline: { + adapter: "openai-chat", + baseUrl: "https://api.cline.bot/api/v1", + authMode: "key", + apiKey: "sk-test", + defaultModel: "anthropic/claude-sonnet-4-6", + // Deliberately predates the current seed: saved subset, plus one custom-only id. + models: ["anthropic/claude-sonnet-4-6", "custom-only-model"], + } } }); + try { + const result = runCli(["models", "--provider", "cline", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const rows = JSON.parse(result.stdout).models.map((m: { model: string; isDefault: boolean }) => m); + expect(rows[0]).toMatchObject({ model: "anthropic/claude-sonnet-4-6", isDefault: true }); + expect(rows[1]).toMatchObject({ model: "custom-only-model", isDefault: false }); + // Seed ids absent from the stale saved config still appear. + const ids = rows.map((m: { model: string }) => m.model); + expect(ids).toContain("openai/gpt-4o"); + expect(ids).toContain("google/gemini-2.5-pro"); + // No duplicate rows: saved ids also come back from the router union, and the + // per-provider seen-dedupe must collapse them. + expect(new Set(ids).size).toBe(ids.length); + expect(rows.filter((m: { isDefault: boolean }) => m.isDefault)).toHaveLength(1); + } finally { + removeTreeWithRetry(dir); + } + }); + + test("registry seed does NOT leak into a provider whose transport diverges from its namesake", () => { + const { dir } = freshConfig({ providers: { cline: { + adapter: "openai-chat", + baseUrl: "https://elsewhere.example.com/v1", + authMode: "key", + apiKey: "sk-test", + defaultModel: "my-model", + models: ["my-model"], + } } }); + try { + // The cline registry entry carries a non-empty seed (the matching-transport test + // above receives it), so listing only the saved id here means the transport guard + // is what excluded the seed, not an empty registry. + const result = runCli(["models", "--provider", "cline", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const rows = JSON.parse(result.stdout).models; + expect(rows.map((m: { model: string }) => m.model)).toEqual(["my-model"]); + } finally { + removeTreeWithRetry(dir); + } + }); + test("models --provider X --json combines flags", () => { const { dir } = freshConfig(); try { diff --git a/tests/codex-integration/reasoning-metadata.test.ts b/tests/codex-integration/reasoning-metadata.test.ts index 4bd1f5cb9dc..f8d660404db 100644 --- a/tests/codex-integration/reasoning-metadata.test.ts +++ b/tests/codex-integration/reasoning-metadata.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { OcxProviderConfig } from "../../src/types"; @@ -78,6 +78,30 @@ afterEach(() => { }); describe("models.dev reasoning metadata", () => { + test("an offline read uses a stale ladder without requesting a refresh", async () => { + const stale = snapshotFile({ "opencode-go": { + [MUSE_SPARK]: { reasoning: true, options: [{ type: "effort", values: ["low", "high"] }] }, + } }); + stale.fetchedAt = Date.now() - 25 * 60 * 60 * 1000; + const { effort, metadata } = await load({ "reasoning-metadata-cache.json": JSON.stringify(stale) }); + const file = join(process.env.OPENCODEX_HOME!, "reasoning-metadata-cache.json"); + const before = readFileSync(file, "utf8"); + const previousFetch = globalThis.fetch; + let requests = 0; + try { + globalThis.fetch = (() => { requests++; return Promise.reject(new Error("offline fixture")); }) as typeof fetch; + expect(effort.configuredReasoningEfforts(ZEN_GO, MUSE_SPARK, { refreshMetadata: false })).toEqual(["low", "high"]); + expect(requests).toBe(0); + expect(readFileSync(file, "utf8")).toBe(before); + // Positive control: omission keeps the existing refresh behavior. + expect(effort.configuredReasoningEfforts(ZEN_GO, MUSE_SPARK)).toEqual(["low", "high"]); + expect(requests).toBe(1); + } finally { + globalThis.fetch = previousFetch; + metadata.resetReasoningMetadataCachesForTests(); + } + }); + test("an expired snapshot read refreshes in the background with Codex integration off; a missing snapshot does not", async () => { const stale = snapshotFile({ "opencode-go": { From 8d9a3c26837ccc5aa681039e01a3287ec49f344d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 6 Nov 2026 12:12:39 +0900 Subject: [PATCH 02/15] fix: align model listing with catalog limits --- src/cli/models.ts | 29 ++++++++++++++++++++++-- tests/cli/cli-models.test.ts | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/cli/models.ts b/src/cli/models.ts index 3668d874018..4120a41ee46 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -21,6 +21,10 @@ import { getProviderRegistryEntry } from "../providers/registry"; import { providerMatchesRegistryTransportWithStaticGuards } from "../providers/static-model-discovery"; import { resolveModelPolicy } from "../providers/resolved-model-policy"; import { customRowInputModalities } from "../vision/eligibility"; +import { applyProviderContextCap, providerContextCap } from "../providers/context-cap"; +import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; +import { reasoningCapabilitiesFromConfig } from "../providers/reasoning-capabilities"; +import { hasNativeOpenAiCapabilityMetadata, nativeContextLimits, nativeDefaultReasoningEffort, nativeOpenAiContextWindow, nativeReasoningEfforts } from "../codex/catalog/metadata"; import { findLiveProxy } from "../server/proxy-liveness"; import { modelInList, type OcxConfig, type OcxCustomModel } from "../types"; import type { RuntimeApiDeps } from "./runtime-api"; @@ -93,13 +97,34 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] // match, drops levels Codex does not declare, and re-adds tiers the wire map proves // the model emits. Restating two of its five lines here reported a ladder the proxy // strips, and unsanitized junk as a supported level. - const efforts = custom?.reasoningEfforts ?? configuredReasoningEfforts(effective, model, { refreshMetadata: false }) ?? null; + let efforts = custom?.reasoningEfforts ?? (reasoningCapabilitiesFromConfig(effective, model).supportsReasoningEffort === false + ? [] : configuredReasoningEfforts(effective, model, { refreshMetadata: false }) ?? null); + const nativeMetadata = custom && hasNativeOpenAiCapabilityMetadata(model); + if (nativeMetadata && efforts && efforts.length > 0) { + const allowed = nativeReasoningEfforts(model); + if (allowed.length > 0) { + const declared = new Set(efforts); + const surviving = [...new Set(allowed)].filter(effort => declared.has(effort)); + const defaultEffort = nativeDefaultReasoningEffort(model); + efforts = surviving.length > 0 ? surviving : [defaultEffort && allowed.includes(defaultEffort) ? defaultEffort : allowed[0]!]; + } + } + const canonicalProvider = provName === OPENAI_CODEX_PROVIDER_ID && prov.authMode === undefined + ? { ...prov, authMode: "forward" as const } : prov; + const nativeCustomContext = nativeMetadata && provName === OPENAI_CODEX_PROVIDER_ID + && isCanonicalOpenAiForwardProvider(canonicalProvider) + ? nativeOpenAiContextWindow(model, { + ...nativeContextLimits(config), + ...(custom.contextWindow ? { modelWindows: { ...nativeContextLimits(config).modelWindows, [model]: custom.contextWindow } } : {}), + }) : undefined; + const contextWindow = nativeCustomContext ?? custom?.contextWindow + ?? applyProviderContextCap(configuredContextWindow(effective, model), providerContextCap(config, provName)); entries.push({ provider: provName, model, isDefault, - contextWindow: custom?.contextWindow ?? configuredContextWindow(effective, model) ?? null, + contextWindow: contextWindow ?? null, inputModalities: modalities, reasoningEfforts: efforts, price: resolveMatchedPrice(provName, model), diff --git a/tests/cli/cli-models.test.ts b/tests/cli/cli-models.test.ts index c65edfc22e2..91ee6f19825 100644 --- a/tests/cli/cli-models.test.ts +++ b/tests/cli/cli-models.test.ts @@ -164,6 +164,49 @@ describe("ocx models", () => { } }); + test("listing honors provider caps and canonical native custom context limits", () => { + const { dir } = freshConfig({ + providers: { + "openai-apikey": { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "key", models: ["gpt-6-astra"] }, + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + }, + providerContextCaps: { "openai-apikey": 100000, openai: 100000 }, + customModels: [{ id: "11111111-1111-4111-8111-111111111111", provider: "openai", modelId: "gpt-6-luna", contextWindow: 2000000 }], + }); + try { + const result = runCli(["models", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const rows = JSON.parse(result.stdout).models; + expect(rows.find((row: { provider: string; model: string }) => row.provider === "openai-apikey" && row.model === "gpt-6-astra").contextWindow).toBe(100000); + expect(rows.find((row: { provider: string; model: string }) => row.provider === "openai" && row.model === "gpt-6-luna").contextWindow).toBe(100000); + } finally { removeTreeWithRetry(dir); } + }); + + test("listing respects explicit reasoning capability denial for seeded models", () => { + const { dir } = freshConfig({ providers: { xai: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", + models: ["grok-4.7"], modelCapabilities: { "grok-4.7": { supportsReasoningEffort: false } } } } }); + try { + const result = runCli(["models", "--provider", "xai", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).models.find((row: { model: string }) => row.model === "grok-4.7").reasoningEfforts).toEqual([]); + } finally { removeTreeWithRetry(dir); } + }); + + test("listing bounds custom native reasoning to pinned levels and preserves empty ladders", () => { + const { dir } = freshConfig({ providers: { test: { adapter: "openai-chat", baseUrl: "https://example.test/v1", models: [] } }, + customModels: [ + { id: "11111111-1111-4111-8111-111111111111", provider: "test", modelId: "gpt-6-luna", reasoningEfforts: ["ultra"] }, + { id: "22222222-2222-4222-8222-222222222222", provider: "test", modelId: "gpt-6-astra", reasoningEfforts: [] }, + ] }); + try { + const result = runCli(["models", "--provider", "test", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const rows = JSON.parse(result.stdout).models; + expect(rows.find((row: { model: string }) => row.model === "gpt-6-luna").reasoningEfforts).toEqual(["medium"]); + expect(rows.find((row: { model: string }) => row.model === "gpt-6-astra").reasoningEfforts).toEqual([]); + } finally { removeTreeWithRetry(dir); } + }); + test("listing reads a stale reasoning snapshot without attempting network or liveness", () => { const { dir } = freshConfig({ providers: { test: { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", models: ["fixture-model"] } } }); From b38bee8f774ec720fa856b51cba64b62cd3f8db0 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 6 Nov 2026 12:17:30 +0900 Subject: [PATCH 03/15] fix: preserve native metadata for custom models --- src/cli/models.ts | 17 +++++++++-------- tests/cli/cli-models.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/cli/models.ts b/src/cli/models.ts index 4120a41ee46..54df62b3c99 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -24,7 +24,7 @@ import { customRowInputModalities } from "../vision/eligibility"; import { applyProviderContextCap, providerContextCap } from "../providers/context-cap"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; import { reasoningCapabilitiesFromConfig } from "../providers/reasoning-capabilities"; -import { hasNativeOpenAiCapabilityMetadata, nativeContextLimits, nativeDefaultReasoningEffort, nativeOpenAiContextWindow, nativeReasoningEfforts } from "../codex/catalog/metadata"; +import { hasNativeOpenAiCapabilityMetadata, nativeContextLimits, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiContextWindow, nativeReasoningEfforts } from "../codex/catalog/metadata"; import { findLiveProxy } from "../server/proxy-liveness"; import { modelInList, type OcxConfig, type OcxCustomModel } from "../types"; import type { RuntimeApiDeps } from "./runtime-api"; @@ -86,10 +86,15 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] seen.add(model); const custom = config.customModels?.find(row => row.provider === provName && row.modelId === model); + const nativeMetadata = custom && hasNativeOpenAiCapabilityMetadata(model); + const canonicalProvider = provName === OPENAI_CODEX_PROVIDER_ID && prov.authMode === undefined + ? { ...prov, authMode: "forward" as const } : prov; + const canonicalNativeCustom = nativeMetadata && provName === OPENAI_CODEX_PROVIDER_ID + && isCanonicalOpenAiForwardProvider(canonicalProvider); const capability = Object.hasOwn(effective.modelCapabilities ?? {}, model) ? effective.modelCapabilities?.[model]?.inputModalities : undefined; const customModalities = customRowInputModalities(config, provName, model); - const modalities = capability ?? customModalities ?? (modelInList(effective.noVisionModels, model) + const modalities = capability ?? customModalities ?? (canonicalNativeCustom ? nativeInputModalities(model) : undefined) ?? (modelInList(effective.noVisionModels, model) ? ["text"] : modelRecordValue(effective.modelInputModalities, model) ?? effective.inputModalities ?? null); // Same reason, for the ladder: `configuredReasoningEfforts` is what the catalog // (`provider-fetch`) and the effort cap (`effort-policy`) resolve through, and it @@ -97,9 +102,8 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] // match, drops levels Codex does not declare, and re-adds tiers the wire map proves // the model emits. Restating two of its five lines here reported a ladder the proxy // strips, and unsanitized junk as a supported level. - let efforts = custom?.reasoningEfforts ?? (reasoningCapabilitiesFromConfig(effective, model).supportsReasoningEffort === false + let efforts = custom?.reasoningEfforts ?? (canonicalNativeCustom ? nativeReasoningEfforts(model) : undefined) ?? (reasoningCapabilitiesFromConfig(effective, model).supportsReasoningEffort === false ? [] : configuredReasoningEfforts(effective, model, { refreshMetadata: false }) ?? null); - const nativeMetadata = custom && hasNativeOpenAiCapabilityMetadata(model); if (nativeMetadata && efforts && efforts.length > 0) { const allowed = nativeReasoningEfforts(model); if (allowed.length > 0) { @@ -109,10 +113,7 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] efforts = surviving.length > 0 ? surviving : [defaultEffort && allowed.includes(defaultEffort) ? defaultEffort : allowed[0]!]; } } - const canonicalProvider = provName === OPENAI_CODEX_PROVIDER_ID && prov.authMode === undefined - ? { ...prov, authMode: "forward" as const } : prov; - const nativeCustomContext = nativeMetadata && provName === OPENAI_CODEX_PROVIDER_ID - && isCanonicalOpenAiForwardProvider(canonicalProvider) + const nativeCustomContext = canonicalNativeCustom ? nativeOpenAiContextWindow(model, { ...nativeContextLimits(config), ...(custom.contextWindow ? { modelWindows: { ...nativeContextLimits(config).modelWindows, [model]: custom.contextWindow } } : {}), diff --git a/tests/cli/cli-models.test.ts b/tests/cli/cli-models.test.ts index 91ee6f19825..f34ea0ec4d2 100644 --- a/tests/cli/cli-models.test.ts +++ b/tests/cli/cli-models.test.ts @@ -207,6 +207,31 @@ describe("ocx models", () => { } finally { removeTreeWithRetry(dir); } }); + test("sparse canonical native custom rows inherit native metadata without leaking to gateways", () => { + const { dir } = freshConfig({ + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + relay: { adapter: "openai-chat", baseUrl: "https://example.test/v1", models: [] }, + }, + customModels: [ + { id: "11111111-1111-4111-8111-111111111111", provider: "openai", modelId: "gpt-6-luna" }, + { id: "22222222-2222-4222-8222-222222222222", provider: "relay", modelId: "gpt-6-luna" }, + { id: "33333333-3333-4333-8333-333333333333", provider: "openai", modelId: "gpt-6-astra", inputModalities: ["text"], reasoningEfforts: [] }, + ], + }); + try { + const result = runCli(["models", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const rows = JSON.parse(result.stdout).models; + expect(rows.find((row: { provider: string; model: string }) => row.provider === "openai" && row.model === "gpt-6-luna")) + .toMatchObject({ inputModalities: ["text", "image"], reasoningEfforts: ["low", "medium", "high", "xhigh", "max"] }); + expect(rows.find((row: { provider: string; model: string }) => row.provider === "relay" && row.model === "gpt-6-luna")) + .toMatchObject({ inputModalities: null, reasoningEfforts: null }); + expect(rows.find((row: { provider: string; model: string }) => row.provider === "openai" && row.model === "gpt-6-astra")) + .toMatchObject({ inputModalities: ["text"], reasoningEfforts: [] }); + } finally { removeTreeWithRetry(dir); } + }); + test("listing reads a stale reasoning snapshot without attempting network or liveness", () => { const { dir } = freshConfig({ providers: { test: { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", models: ["fixture-model"] } } }); From f1475b7143f8cf4d723fb4c9cfc3ecc7fd1f6077 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 6 Nov 2026 12:21:29 +0900 Subject: [PATCH 04/15] fix: honor provider restrictions in model listings --- src/cli/models.ts | 10 ++++++---- tests/cli/cli-models.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/cli/models.ts b/src/cli/models.ts index 54df62b3c99..1d105d0f61c 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -94,16 +94,18 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] const capability = Object.hasOwn(effective.modelCapabilities ?? {}, model) ? effective.modelCapabilities?.[model]?.inputModalities : undefined; const customModalities = customRowInputModalities(config, provName, model); - const modalities = capability ?? customModalities ?? (canonicalNativeCustom ? nativeInputModalities(model) : undefined) ?? (modelInList(effective.noVisionModels, model) - ? ["text"] : modelRecordValue(effective.modelInputModalities, model) ?? effective.inputModalities ?? null); + const modalities = capability ?? customModalities ?? (modelInList(effective.noVisionModels, model) + ? ["text"] : modelRecordValue(effective.modelInputModalities, model) ?? effective.inputModalities + ?? (canonicalNativeCustom ? nativeInputModalities(model) : null)); // Same reason, for the ladder: `configuredReasoningEfforts` is what the catalog // (`provider-fetch`) and the effort cap (`effort-policy`) resolve through, and it // does three things this expression did not — it returns [] for a noReasoningModels // match, drops levels Codex does not declare, and re-adds tiers the wire map proves // the model emits. Restating two of its five lines here reported a ladder the proxy // strips, and unsanitized junk as a supported level. - let efforts = custom?.reasoningEfforts ?? (canonicalNativeCustom ? nativeReasoningEfforts(model) : undefined) ?? (reasoningCapabilitiesFromConfig(effective, model).supportsReasoningEffort === false - ? [] : configuredReasoningEfforts(effective, model, { refreshMetadata: false }) ?? null); + let efforts = reasoningCapabilitiesFromConfig(effective, model).supportsReasoningEffort === false + ? [] : custom?.reasoningEfforts ?? configuredReasoningEfforts(effective, model, { refreshMetadata: false }) + ?? (canonicalNativeCustom ? nativeReasoningEfforts(model) : null); if (nativeMetadata && efforts && efforts.length > 0) { const allowed = nativeReasoningEfforts(model); if (allowed.length > 0) { diff --git a/tests/cli/cli-models.test.ts b/tests/cli/cli-models.test.ts index f34ea0ec4d2..04376b56c0a 100644 --- a/tests/cli/cli-models.test.ts +++ b/tests/cli/cli-models.test.ts @@ -232,6 +232,38 @@ describe("ocx models", () => { } finally { removeTreeWithRetry(dir); } }); + test("canonical native custom metadata honors provider vision restrictions", () => { + for (const restriction of [ + { noVisionModels: ["gpt-6-luna"] }, + { modelInputModalities: { "gpt-6-luna": ["text"] } }, + ]) { + const { dir } = freshConfig({ providers: { openai: { adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", ...restriction } }, + customModels: [{ id: "11111111-1111-4111-8111-111111111111", provider: "openai", modelId: "gpt-6-luna" }] }); + try { + const result = runCli(["models", "--provider", "openai", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).models.find((row: { model: string }) => row.model === "gpt-6-luna").inputModalities).toEqual(["text"]); + } finally { removeTreeWithRetry(dir); } + } + }); + + test("canonical native custom metadata honors provider reasoning restrictions", () => { + for (const restriction of [ + { noReasoningModels: ["gpt-6-luna"] }, + { modelCapabilities: { "gpt-6-luna": { supportsReasoningEffort: false } } }, + ]) { + const { dir } = freshConfig({ providers: { openai: { adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", ...restriction } }, + customModels: [{ id: "11111111-1111-4111-8111-111111111111", provider: "openai", modelId: "gpt-6-luna" }] }); + try { + const result = runCli(["models", "--provider", "openai", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).models.find((row: { model: string }) => row.model === "gpt-6-luna").reasoningEfforts).toEqual([]); + } finally { removeTreeWithRetry(dir); } + } + }); + test("listing reads a stale reasoning snapshot without attempting network or liveness", () => { const { dir } = freshConfig({ providers: { test: { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", models: ["fixture-model"] } } }); From 6e75adc3812c055765029eddc717732bf3c114ec Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 6 Nov 2026 13:24:47 +0900 Subject: [PATCH 05/15] test: verify expected network probe failure --- tests/cli/cli-models.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/cli/cli-models.test.ts b/tests/cli/cli-models.test.ts index 04376b56c0a..e7d7c555325 100644 --- a/tests/cli/cli-models.test.ts +++ b/tests/cli/cli-models.test.ts @@ -30,7 +30,10 @@ function runCli(args: string[], env: Record = {}, recordAttempts const original = await import(path); mock.module(path, () => ({ ...original, findLiveProxy: async () => { record("liveness"); return null; } })); globalThis.fetch = () => { record("fetch"); throw new Error("fixture network forbidden"); }; - if (process.env.OCX_TEST_PROBE_NETWORK === "1") { try { await globalThis.fetch("https://fixture.test"); } catch {} } + if (process.env.OCX_TEST_PROBE_NETWORK === "1") { + try { await globalThis.fetch("https://fixture.test"); } + catch (error) { if (!(error instanceof Error) || error.message !== "fixture network forbidden") throw error; } + } `); } const result = spawnSync(process.execPath, [...(preload ? ["--preload", preload] : []), cliPath, ...args], { From 9bd162bd018fabebddfeabb366ffecc97d323e25 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 6 Nov 2026 13:57:02 +0900 Subject: [PATCH 06/15] fix(models): align context inference and terminal rendering --- src/cli/models.ts | 10 +++++++--- tests/cli/cli-models.test.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/cli/models.ts b/src/cli/models.ts index 1d105d0f61c..fcbdfccdff0 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -25,6 +25,8 @@ import { applyProviderContextCap, providerContextCap } from "../providers/contex import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; import { reasoningCapabilitiesFromConfig } from "../providers/reasoning-capabilities"; import { hasNativeOpenAiCapabilityMetadata, nativeContextLimits, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiContextWindow, nativeReasoningEfforts } from "../codex/catalog/metadata"; +import { canInferVendorContext, vendorContextInference } from "../codex/catalog/vendor-context"; +import { terminalSafeText } from "./runtime-api"; import { findLiveProxy } from "../server/proxy-liveness"; import { modelInList, type OcxConfig, type OcxCustomModel } from "../types"; import type { RuntimeApiDeps } from "./runtime-api"; @@ -120,8 +122,10 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] ...nativeContextLimits(config), ...(custom.contextWindow ? { modelWindows: { ...nativeContextLimits(config).modelWindows, [model]: custom.contextWindow } } : {}), }) : undefined; + const inferredContext = canInferVendorContext(provName, effective) + ? vendorContextInference(model)?.contextWindow : undefined; const contextWindow = nativeCustomContext ?? custom?.contextWindow - ?? applyProviderContextCap(configuredContextWindow(effective, model), providerContextCap(config, provName)); + ?? applyProviderContextCap(configuredContextWindow(effective, model) ?? inferredContext, providerContextCap(config, provName)); entries.push({ provider: provName, @@ -465,13 +469,13 @@ function handleConfiguredModels(args: string[]): void { for (const [provName, provModels] of byProvider) { const isDefaultProv = provName === config.defaultProvider ? " (default provider)" : ""; - console.log(`${provName}${isDefaultProv}:`); + console.log(`${terminalSafeText(provName)}${isDefaultProv}:`); for (const m of provModels) { const marker = m.isDefault ? " *" : ""; const ctx = m.contextWindow ? ` (${Math.round(m.contextWindow / 1000)}k)` : ""; const rates = m.price?.cost4; const pricing = rates ? ` ~$${rates.input}/$${rates.output} input/output per 1M tokens` : " price unknown"; - console.log(` ${m.model}${marker}${ctx}${pricing}`); + console.log(` ${terminalSafeText(m.model)}${marker}${ctx}${pricing}`); } console.log(); } diff --git a/tests/cli/cli-models.test.ts b/tests/cli/cli-models.test.ts index e7d7c555325..43ebe328c93 100644 --- a/tests/cli/cli-models.test.ts +++ b/tests/cli/cli-models.test.ts @@ -267,6 +267,40 @@ describe("ocx models", () => { } }); + test("listing applies opt-in vendor context inference and provider caps", () => { + const { dir } = freshConfig({ + providers: { + relay: { adapter: "openai-chat", baseUrl: "https://example.test/v1", inferContextWindow: true, models: ["claude-sonnet-5-5"] }, + plain: { adapter: "openai-chat", baseUrl: "https://example.test/v1", models: ["claude-sonnet-5-5"] }, + }, + providerContextCaps: { relay: 350000 }, + customModels: [{ id: "11111111-1111-4111-8111-111111111111", provider: "relay", modelId: "claude-opus-5-5" }], + }); + try { + const result = runCli(["models", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const rows = JSON.parse(result.stdout).models; + expect(rows.find((row: { provider: string; model: string }) => row.provider === "relay" && row.model === "claude-opus-5-5").contextWindow).toBe(350000); + expect(rows.find((row: { provider: string; model: string }) => row.provider === "relay" && row.model === "claude-sonnet-5-5").contextWindow).toBe(350000); + expect(rows.find((row: { provider: string; model: string }) => row.provider === "plain" && row.model === "claude-sonnet-5-5").contextWindow).toBeNull(); + } finally { removeTreeWithRetry(dir); } + }); + + test("human listing renders identifiers safely while JSON preserves their values", () => { + const modelId = `custom${String.fromCharCode(27)}a${String.fromCharCode(0x9d)}b${String.fromCharCode(0x2028)}c`; + const { dir } = freshConfig({ providers: { relay: { adapter: "openai-chat", baseUrl: "https://example.test/v1", models: [] } }, + customModels: [{ id: "11111111-1111-4111-8111-111111111111", provider: "relay", modelId }] }); + try { + const human = runCli(["models", "--provider", "relay"], { OPENCODEX_HOME: dir }); + expect(human.status).toBe(0); + for (const code of [27, 0x9d, 0x2028]) expect(human.stdout).not.toContain(String.fromCharCode(code)); + expect(human.stdout).toContain("custom\\x1ba\\u009db\\u2028c"); + const json = runCli(["models", "--provider", "relay", "--json"], { OPENCODEX_HOME: dir }); + expect(json.status).toBe(0); + expect(JSON.parse(json.stdout).models[0].model).toBe(modelId); + } finally { removeTreeWithRetry(dir); } + }); + test("listing reads a stale reasoning snapshot without attempting network or liveness", () => { const { dir } = freshConfig({ providers: { test: { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", models: ["fixture-model"] } } }); From 00ed64c8dad81a18c195eb1a929ab8b61bfb6f90 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 6 Nov 2026 14:04:28 +0900 Subject: [PATCH 07/15] fix(models): respect local context metadata and safe output --- src/cli/models.ts | 56 +++++++++++++++++++++++------------- tests/cli/cli-models.test.ts | 31 ++++++++++++++++++++ 2 files changed, 67 insertions(+), 20 deletions(-) diff --git a/src/cli/models.ts b/src/cli/models.ts index fcbdfccdff0..f7969dcaa6e 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -16,12 +16,12 @@ import { import { encodedModelIdCollides, resolveSlugSelection, routedSlug } from "../providers/slug-codec"; import { isModelsRuntimeSubcommand } from "./models-runtime-subcommands"; import { knownModelIdsForProvider } from "../router"; -import { enrichProviderFromRegistry, registryInputModalitiesForDestination } from "../providers/derive"; +import { azureVendorModelMetadata, enrichProviderFromRegistry, registryInputModalitiesForDestination } from "../providers/derive"; import { getProviderRegistryEntry } from "../providers/registry"; import { providerMatchesRegistryTransportWithStaticGuards } from "../providers/static-model-discovery"; import { resolveModelPolicy } from "../providers/resolved-model-policy"; import { customRowInputModalities } from "../vision/eligibility"; -import { applyProviderContextCap, providerContextCap } from "../providers/context-cap"; +import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../providers/context-cap"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; import { reasoningCapabilitiesFromConfig } from "../providers/reasoning-capabilities"; import { hasNativeOpenAiCapabilityMetadata, nativeContextLimits, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiContextWindow, nativeReasoningEfforts } from "../codex/catalog/metadata"; @@ -39,6 +39,13 @@ const REMOVE_USAGE = "Usage: ocx models remove [--ye const LIST_CUSTOM_USAGE = "Usage: ocx models list-custom [--json]"; const ALLOWED_MODALITIES = new Set(["text", "image", "audio"]); +function printJson(value: unknown): void { + const encoded = JSON.stringify(value, null, 2); + if (encoded === undefined) throw new TypeError("Cannot serialize model output"); + console.log(encoded.replace(/[\u007f-\u009f\u2028\u2029]/g, character => + `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`)); +} + import { parseReasoningArgs } from "./models-custom-input"; import { redactSecretArgs } from "./secret-args"; export { parseReasoningArgs } from "./models-custom-input"; @@ -122,10 +129,19 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] ...nativeContextLimits(config), ...(custom.contextWindow ? { modelWindows: { ...nativeContextLimits(config).modelWindows, [model]: custom.contextWindow } } : {}), }) : undefined; - const inferredContext = canInferVendorContext(provName, effective) + const cap = providerContextCap(config, provName); + const explicitContext = nativeCustomContext ?? custom?.contextWindow; + const configuredContext = configuredContextWindow(effective, model); + const canInfer = canInferVendorContext(provName, effective); + const hintEligible = !custom || canInfer || (prov.models ?? []).includes(model) || prov.defaultModel === model; + const azureWindow = explicitContext === undefined && configuredContext === undefined && hintEligible + ? azureVendorModelMetadata(effective.baseUrl, model, getConfigDir())?.contextWindow : undefined; + const azureContext = Number.isSafeInteger(azureWindow) && azureWindow! > 0 ? azureWindow : undefined; + const inferredContext = explicitContext === undefined && configuredContext === undefined && azureContext === undefined && canInfer ? vendorContextInference(model)?.contextWindow : undefined; - const contextWindow = nativeCustomContext ?? custom?.contextWindow - ?? applyProviderContextCap(configuredContextWindow(effective, model) ?? inferredContext, providerContextCap(config, provName)); + const fallbackContext = hintEligible && cap !== undefined ? resolveUnknownRoutedContextWindow(cap) : undefined; + const contextWindow = explicitContext + ?? applyProviderContextCap(configuredContext ?? azureContext ?? inferredContext ?? fallbackContext, cap); entries.push({ provider: provName, @@ -174,8 +190,8 @@ function consumeFlagValue(args: string[], flag: string): string | undefined { } function fail(message: string, usage?: string): never { - console.error(`Error: ${message}`); - if (usage) console.error(usage); + console.error(`Error: ${terminalSafeText(message)}`); + if (usage) console.error(terminalSafeText(usage)); process.exit(1); } @@ -205,13 +221,13 @@ function printCustomMutation(action: "added" | "removed", model: OcxCustomModel, const complete = sync.configApplied === true && sync.ok; if (sync.status !== "not-attempted" && (!sync.ok || sync.status === "refused")) process.exitCode = 1; if (wantsJson) { - console.log(JSON.stringify({ action, + printJson({ action, model: action === "added" ? model : { id: model.id, provider: model.provider, modelId: model.modelId }, - needsSync: !complete, sync }, null, 2)); + needsSync: !complete, sync }); return; } - if (sync.warning) console.log(`Warning: ${sync.warning}`); - console.log(`${action === "added" ? "Added" : "Removed"} custom model ${routedSlug(model.provider, model.modelId)}${action === "added" ? ` (${model.id})` : ""}.`); + if (sync.warning) console.log(`Warning: ${terminalSafeText(sync.warning)}`); + console.log(`${action === "added" ? "Added" : "Removed"} custom model ${terminalSafeText(routedSlug(model.provider, model.modelId))}${action === "added" ? ` (${terminalSafeText(model.id)})` : ""}.`); if (sync.status === "skipped") console.log("Custom model saved; client sync was skipped by the integration policy."); else if (!complete && sync.status !== "not-attempted") console.log("Custom model saved; client/catalog sync remains incomplete. Inspect the target before retrying."); } @@ -296,7 +312,7 @@ async function confirmCustomRemoval(model: OcxCustomModel): Promise { } const rl = createInterface({ input: process.stdin, output: process.stdout }); try { - const answer = (await rl.question(`Remove custom model ${routedSlug(model.provider, model.modelId)}? [y/N] `)).trim().toLowerCase(); + const answer = (await rl.question(`Remove custom model ${terminalSafeText(routedSlug(model.provider, model.modelId))}? [y/N] `)).trim().toLowerCase(); return answer === "y" || answer === "yes"; } finally { rl.close(); @@ -386,11 +402,11 @@ function customModelCells(model: OcxCustomModel): string[] { } function printCustomModelGroup(provider: string, models: OcxCustomModel[]): void { - const rows = models.map(customModelCells); + const rows = models.map(model => customModelCells(model).map(terminalSafeText)); const headers = ["ID", "MODEL", "DISPLAY NAME", "CONTEXT", "MODALITIES", "EFFORTS", "DEFAULT EFFORT"]; const widths = headers.map((header, column) => Math.max(header.length, ...rows.map(row => row[column].length))); const line = (cells: string[]) => cells.map((cell, column) => cell.padEnd(widths[column])).join(" "); - console.log(`${provider}:`); + console.log(`${terminalSafeText(provider)}:`); console.log(` ${line(headers)}`); for (const row of rows) console.log(` ${line(row)}`); console.log(); @@ -402,7 +418,7 @@ function handleCustomList(args: string[]): void { rejectUnexpectedArgs(rest, LIST_CUSTOM_USAGE); const models = loadConfig().customModels ?? []; if (wantsJson) { - console.log(JSON.stringify(models, null, 2)); + printJson(models); return; } if (models.length === 0) { @@ -427,9 +443,9 @@ function handleConfiguredModels(args: string[]): void { const shown = redactSecretArgs(restArgs); const unknown = shown.filter(a => a.startsWith("-")); if (unknown.length > 0) { - console.error(`Unknown flag(s): ${unknown.join(", ")}`); + console.error(`Unknown flag(s): ${terminalSafeText(unknown.join(", "))}`); } else { - console.error(`Unexpected argument(s): ${shown.join(", ")}`); + console.error(`Unexpected argument(s): ${terminalSafeText(shown.join(", "))}`); } console.error("Usage: ocx models [--provider ] [--json]"); process.exit(1); @@ -438,7 +454,7 @@ function handleConfiguredModels(args: string[]): void { const config = loadConfig(); if (providerFilter && !hasOwnProvider(config.providers, providerFilter)) { - console.error(`Provider "${providerFilter}" is not configured. See: ocx provider list`); + console.error(`Provider "${terminalSafeText(providerFilter)}" is not configured. See: ocx provider list`); process.exit(1); } @@ -446,10 +462,10 @@ function handleConfiguredModels(args: string[]): void { const note = "Local saved and router-known IDs include registry seeds, custom models and this process's discovery cache. Use ocx models live for the running catalog."; if (wantsJson) { - console.log(JSON.stringify({ + printJson({ models, note, - }, null, 2)); + }); return; } diff --git a/tests/cli/cli-models.test.ts b/tests/cli/cli-models.test.ts index 43ebe328c93..01b56a25377 100644 --- a/tests/cli/cli-models.test.ts +++ b/tests/cli/cli-models.test.ts @@ -286,6 +286,28 @@ describe("ocx models", () => { } finally { removeTreeWithRetry(dir); } }); + test("listing prefers Azure metadata and applies configured-row cap fallback offline", () => { + const { dir } = freshConfig({ + providers: { "custom-azure": { adapter: "openai-chat", baseUrl: "https://resource.openai.azure.com/openai", + inferContextWindow: true, models: ["claude-opus-5-5", "unknown-model"] } }, + providerContextCaps: { "custom-azure": 350000 }, + customModels: [{ id: "11111111-1111-4111-8111-111111111111", provider: "custom-azure", + modelId: "claude-sonnet-5-5", contextWindow: 190000 }], + }); + writeFileSync(join(dir, "azure-model-metadata-cache.json"), JSON.stringify({ + version: 1, fetchedAt: Date.now(), models: { "claude-opus-5-5": { contextWindow: 333333 } }, + })); + try { + const result = runCli(["models", "--provider", "custom-azure", "--json"], { OPENCODEX_HOME: dir }, true); + expect(result.status).toBe(0); + const rows = JSON.parse(result.stdout).models; + expect(rows.find((row: { model: string }) => row.model === "claude-opus-5-5").contextWindow).toBe(333333); + expect(rows.find((row: { model: string }) => row.model === "unknown-model").contextWindow).toBe(350000); + expect(rows.find((row: { model: string }) => row.model === "claude-sonnet-5-5").contextWindow).toBe(190000); + expect(existsSync(join(dir, "network-attempts.log"))).toBe(false); + } finally { removeTreeWithRetry(dir); } + }); + test("human listing renders identifiers safely while JSON preserves their values", () => { const modelId = `custom${String.fromCharCode(27)}a${String.fromCharCode(0x9d)}b${String.fromCharCode(0x2028)}c`; const { dir } = freshConfig({ providers: { relay: { adapter: "openai-chat", baseUrl: "https://example.test/v1", models: [] } }, @@ -297,7 +319,16 @@ describe("ocx models", () => { expect(human.stdout).toContain("custom\\x1ba\\u009db\\u2028c"); const json = runCli(["models", "--provider", "relay", "--json"], { OPENCODEX_HOME: dir }); expect(json.status).toBe(0); + for (const code of [27, 0x9d, 0x2028]) expect(json.stdout).not.toContain(String.fromCharCode(code)); expect(JSON.parse(json.stdout).models[0].model).toBe(modelId); + const customHuman = runCli(["models", "list-custom"], { OPENCODEX_HOME: dir }); + expect(customHuman.status).toBe(0); + for (const code of [27, 0x9d, 0x2028]) expect(customHuman.stdout).not.toContain(String.fromCharCode(code)); + expect(customHuman.stdout).toContain("custom\\x1ba\\u009db\\u2028c"); + const customJson = runCli(["models", "list-custom", "--json"], { OPENCODEX_HOME: dir }); + expect(customJson.status).toBe(0); + for (const code of [27, 0x9d, 0x2028]) expect(customJson.stdout).not.toContain(String.fromCharCode(code)); + expect(JSON.parse(customJson.stdout)[0].modelId).toBe(modelId); } finally { removeTreeWithRetry(dir); } }); From 412e96ceb9a548be2e1e9c14610bfb1d15ccf191 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 6 Nov 2026 14:09:54 +0900 Subject: [PATCH 08/15] fix(models): preserve custom context inheritance --- src/cli/models.ts | 8 ++++++-- tests/cli/cli-models.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/cli/models.ts b/src/cli/models.ts index f7969dcaa6e..9cf94505653 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -131,9 +131,13 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] }) : undefined; const cap = providerContextCap(config, provName); const explicitContext = nativeCustomContext ?? custom?.contextWindow; - const configuredContext = configuredContextWindow(effective, model); const canInfer = canInferVendorContext(provName, effective); - const hintEligible = !custom || canInfer || (prov.models ?? []).includes(model) || prov.defaultModel === model; + const modelListEmpty = (effective.models?.length ?? 0) === 0; + const seededDefault = effective.defaultModel === model && modelListEmpty + && (effective.liveModels === false || (effective.adapter === "google" && effective.googleMode === "vertex")); + const hintEligible = !custom || canInfer || (effective.models ?? []).includes(model) + || (effective.retainModels ?? []).includes(model) || seededDefault; + const configuredContext = hintEligible ? configuredContextWindow(effective, model) : undefined; const azureWindow = explicitContext === undefined && configuredContext === undefined && hintEligible ? azureVendorModelMetadata(effective.baseUrl, model, getConfigDir())?.contextWindow : undefined; const azureContext = Number.isSafeInteger(azureWindow) && azureWindow! > 0 ? azureWindow : undefined; diff --git a/tests/cli/cli-models.test.ts b/tests/cli/cli-models.test.ts index 01b56a25377..586ca97dfa9 100644 --- a/tests/cli/cli-models.test.ts +++ b/tests/cli/cli-models.test.ts @@ -308,6 +308,29 @@ describe("ocx models", () => { } finally { removeTreeWithRetry(dir); } }); + test.each([ + ["xai", "grok-composer-2.5-fast", 350000], + ["relay", "custom-only", null], + ])("custom context inheritance for %s/%s", (providerName, modelId, expected) => { + const { dir } = freshConfig({ + providers: { + xai: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1" }, + relay: { adapter: "openai-chat", baseUrl: "https://example.test/v1", models: [], contextWindow: 200000 }, + }, + providerContextCaps: { xai: 350000, relay: 100000 }, + customModels: [ + { id: "11111111-1111-4111-8111-111111111111", provider: "xai", modelId: "grok-composer-2.5-fast" }, + { id: "22222222-2222-4222-8222-222222222222", provider: "relay", modelId: "custom-only" }, + ], + }); + try { + const result = runCli(["models", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const rows = JSON.parse(result.stdout).models; + expect(rows.find((row: { provider: string; model: string }) => row.provider === providerName && row.model === modelId).contextWindow).toBe(expected); + } finally { removeTreeWithRetry(dir); } + }); + test("human listing renders identifiers safely while JSON preserves their values", () => { const modelId = `custom${String.fromCharCode(27)}a${String.fromCharCode(0x9d)}b${String.fromCharCode(0x2028)}c`; const { dir } = freshConfig({ providers: { relay: { adapter: "openai-chat", baseUrl: "https://example.test/v1", models: [] } }, From a29818067e0c05f9b3e7faf149bd16572dfd467d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 6 Nov 2026 14:20:42 +0900 Subject: [PATCH 09/15] fix(models): include registry and Azure hints --- src/cli/models.ts | 20 +++++++++++--------- tests/cli/cli-models.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/cli/models.ts b/src/cli/models.ts index 9cf94505653..4c4bca44d89 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -88,6 +88,7 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] Object.assign(effective, policy); } effective.inputModalities ??= registryInputModalitiesForDestination(provName, prov); + const routerKnown = new Set(knownModelIdsForProvider(provName, prov)); /** Append one model with resolved metadata, ignoring duplicates within this provider. */ const addModel = (model: string, isDefault: boolean) => { @@ -100,12 +101,19 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] ? { ...prov, authMode: "forward" as const } : prov; const canonicalNativeCustom = nativeMetadata && provName === OPENAI_CODEX_PROVIDER_ID && isCanonicalOpenAiForwardProvider(canonicalProvider); + const canInfer = canInferVendorContext(provName, effective); + const modelListEmpty = (effective.models?.length ?? 0) === 0; + const seededDefault = effective.defaultModel === model && modelListEmpty + && (effective.liveModels === false || (effective.adapter === "google" && effective.googleMode === "vertex")); + const hintEligible = !custom || canInfer || routerKnown.has(model) || (effective.models ?? []).includes(model) + || (effective.retainModels ?? []).includes(model) || seededDefault; + const azureMetadata = hintEligible ? azureVendorModelMetadata(effective.baseUrl, model, getConfigDir()) : undefined; const capability = Object.hasOwn(effective.modelCapabilities ?? {}, model) ? effective.modelCapabilities?.[model]?.inputModalities : undefined; const customModalities = customRowInputModalities(config, provName, model); const modalities = capability ?? customModalities ?? (modelInList(effective.noVisionModels, model) ? ["text"] : modelRecordValue(effective.modelInputModalities, model) ?? effective.inputModalities - ?? (canonicalNativeCustom ? nativeInputModalities(model) : null)); + ?? azureMetadata?.input ?? (canonicalNativeCustom ? nativeInputModalities(model) : null)); // Same reason, for the ladder: `configuredReasoningEfforts` is what the catalog // (`provider-fetch`) and the effort cap (`effort-policy`) resolve through, and it // does three things this expression did not — it returns [] for a noReasoningModels @@ -131,15 +139,9 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] }) : undefined; const cap = providerContextCap(config, provName); const explicitContext = nativeCustomContext ?? custom?.contextWindow; - const canInfer = canInferVendorContext(provName, effective); - const modelListEmpty = (effective.models?.length ?? 0) === 0; - const seededDefault = effective.defaultModel === model && modelListEmpty - && (effective.liveModels === false || (effective.adapter === "google" && effective.googleMode === "vertex")); - const hintEligible = !custom || canInfer || (effective.models ?? []).includes(model) - || (effective.retainModels ?? []).includes(model) || seededDefault; const configuredContext = hintEligible ? configuredContextWindow(effective, model) : undefined; - const azureWindow = explicitContext === undefined && configuredContext === undefined && hintEligible - ? azureVendorModelMetadata(effective.baseUrl, model, getConfigDir())?.contextWindow : undefined; + const azureWindow = explicitContext === undefined && configuredContext === undefined + ? azureMetadata?.contextWindow : undefined; const azureContext = Number.isSafeInteger(azureWindow) && azureWindow! > 0 ? azureWindow : undefined; const inferredContext = explicitContext === undefined && configuredContext === undefined && azureContext === undefined && canInfer ? vendorContextInference(model)?.contextWindow : undefined; diff --git a/tests/cli/cli-models.test.ts b/tests/cli/cli-models.test.ts index 586ca97dfa9..2d868d993aa 100644 --- a/tests/cli/cli-models.test.ts +++ b/tests/cli/cli-models.test.ts @@ -331,6 +331,34 @@ describe("ocx models", () => { } finally { removeTreeWithRetry(dir); } }); + test("custom rows inherit context for guarded registry-known IDs", () => { + const { dir } = freshConfig({ + providers: { xai: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", models: ["grok-4.7"] } }, + providerContextCaps: { xai: 350000 }, + customModels: [{ id: "11111111-1111-4111-8111-111111111111", provider: "xai", modelId: "grok-4.6" }], + }); + try { + const result = runCli(["models", "--provider", "xai", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).models.find((row: { model: string }) => row.model === "grok-4.6").contextWindow).toBe(350000); + } finally { removeTreeWithRetry(dir); } + }); + + test("listing uses offline Azure modalities beneath explicit restrictions", () => { + const { dir } = freshConfig({ providers: { + azureopen: { adapter: "openai-chat", baseUrl: "https://resource.openai.azure.com/openai", models: ["gpt-4o"] }, + azurerestricted: { adapter: "openai-chat", baseUrl: "https://resource.openai.azure.com/openai", models: ["gpt-4o"], noVisionModels: ["gpt-4o"] }, + } }); + try { + const result = runCli(["models", "--json"], { OPENCODEX_HOME: dir }, true); + expect(result.status).toBe(0); + const rows = JSON.parse(result.stdout).models; + expect(rows.find((row: { provider: string; model: string }) => row.provider === "azureopen" && row.model === "gpt-4o").inputModalities).toEqual(["text", "image"]); + expect(rows.find((row: { provider: string; model: string }) => row.provider === "azurerestricted" && row.model === "gpt-4o").inputModalities).toEqual(["text"]); + expect(existsSync(join(dir, "network-attempts.log"))).toBe(false); + } finally { removeTreeWithRetry(dir); } + }); + test("human listing renders identifiers safely while JSON preserves their values", () => { const modelId = `custom${String.fromCharCode(27)}a${String.fromCharCode(0x9d)}b${String.fromCharCode(0x2028)}c`; const { dir } = freshConfig({ providers: { relay: { adapter: "openai-chat", baseUrl: "https://example.test/v1", models: [] } }, From bb05d8bc9dbe01a6f33ce42ba2f587cb8a32a03d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 6 Nov 2026 14:55:47 +0900 Subject: [PATCH 10/15] fix(models): keep listings passive and sync translations --- .../fr/reference/cli/providers-accounts.md | 5 ++- .../ja/reference/cli/providers-accounts.md | 4 +-- .../ko/reference/cli/providers-accounts.md | 4 +-- .../ru/reference/cli/providers-accounts.md | 5 ++- .../zh-cn/reference/cli/providers-accounts.md | 6 ++-- .../zh-tw/reference/cli/providers-accounts.md | 4 +-- src/cli/models.ts | 5 +-- structure/cli-management.md | 2 +- tests/cli/cli-models.test.ts | 31 ++++++++++++++++++- 9 files changed, 48 insertions(+), 18 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md index b378545e45b..b39a3fcebc7 100644 --- a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md @@ -441,8 +441,7 @@ déplacé car son AAD chiffré et l'identité du trousseau de clés du système ### `ocx models [subcommand]` · `ocx model ` -`ocx model` est un alias de `ocx models`. Sans sous-commande, répertoriez les modèles implantés statiquement dans -fournisseurs configurés. `--provider` filtre un fournisseur configuré et `--json` renvoie le modèle +`ocx model` est un alias de `ocx models`. Sans sous-commande, liste les ID enregistrés et connus du routeur, notamment les modèles du registre, les modèles personnalisés et le cache de découverte de ce processus CLI. Le cache d’un nouveau processus peut être vide : ce n’est pas le catalogue du proxy actif. Certains anciens fournisseurs à clé conservent les modèles du registre après un changement de destination. `--provider` filtre un fournisseur configuré et `--json` renvoie le modèle métadonnées. `live` lit le catalogue en cours d'exécution ; `add`, `edit`, `remove` et `list-custom` gèrent le manuel entrées de catalogue ; `enable`, `disable` et `provider` contrôlent la visibilité ; `selected` contrôle un liste d'autorisation des fournisseurs ; `context` contrôle les plafonds de contexte du fournisseur ; et `shadow` gère l'arrière-plan @@ -452,7 +451,7 @@ interception d'appel fantôme. | Sous-commande | Drapeaux pris en charge | Actions | | --- | --- | --- | -| `list` (par défaut) | `--provider `, `--json` | Répertoriez les modèles prédéfinis dans les fournisseurs configurés. | +| `list` (par défaut) | `--provider `, `--json` | Liste les modèles locaux enregistrés et connus du routeur. | | `live` | `--provider `, `--json` | Lisez le catalogue en cours d'exécution, y compris les modèles découverts lors de l'exécution. Les lignes sont marquées `native`/`routed`, `custom` et `enabled`/`disabled`. | | `add ` | `--display-name `, `--context-window `, `--modalities `, `--live`, `--json` | Enregistre un modèle personnalisé localement, ou sur le proxy actif avec `--live`. | | `edit ` | `--model-id `, `--display-name `, `--context-window `, `--modalities `, `--json` | Modifiez un modèle personnalisé. `-` libère un champ ; `0` efface la fenêtre contextuelle. | diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md index 0a939338274..7db9d58ec47 100644 --- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md @@ -343,13 +343,13 @@ native-main トラフィックまたはジャーナル復旧を受け入れる ### `ocx models [subcommand]`・`ocx model ` -`ocx model` は `ocx models` の別名です。サブコマンドを使用しない場合、構成されたプロバイダーに静的にシードされたモデルを一覧表示します。 `--provider` は 1 つの構成済みプロバイダーをフィルターし、`--json` はモデル メタデータを返します。 `live` は実行中のカタログを読み取ります。 `add`、`edit`、`remove`、および `list-custom` は手動カタログ エントリを管理します。 `enable`、`disable`、および `provider` は可視性を制御します。 `selected` はプロバイダー許可リストを制御します。 `context` はプロバイダーのコンテキストの上限を制御します。 `shadow` はバックグラウンドのシャドウ コール インターセプトを管理します。 +`ocx model` は `ocx models` の別名です。サブコマンドを使用しない場合、保存済みモデルとルーターが認識するモデル ID を一覧表示します。レジストリのシード、カスタムモデル、この CLI プロセス自身の検出キャッシュが含まれます。新しい CLI プロセスのキャッシュは空の場合があり、実行中のプロキシのカタログとは異なります。従来のキー方式プリセットでは、接続先を変更してもレジストリのシードが残る場合があります。 `--provider` は 1 つの構成済みプロバイダーをフィルターし、`--json` はモデル メタデータを返します。 `live` は実行中のカタログを読み取ります。 `add`、`edit`、`remove`、および `list-custom` は手動カタログ エントリを管理します。 `enable`、`disable`、および `provider` は可視性を制御します。 `selected` はプロバイダー許可リストを制御します。 `context` はプロバイダーのコンテキストの上限を制御します。 `shadow` はバックグラウンドのシャドウ コール インターセプトを管理します。 `add` と `remove` は既定でローカル保存し、`--live` で稼働中のプロキシを変更します。ローカル `--json` 保存時にプロキシがなければ `sync.status: "not-attempted"`、`needsSync: true`、終了コード 0 を返します。試行した同期が失敗しても保存は維持され、終了コードは非ゼロです。`list-custom` はローカル一覧です。`display-name` は raw upstream ID、`order` は picker の public ID を使います。完全な並べ替え、featured 接頭部、native 順序のリセット制約は[英語版](/reference/cli/providers-accounts/#display-names-and-picker-identities)を参照してください。 |サブコマンド |サポートされているフラグ |アクション | | --- | --- | --- | -| `list` (デフォルト) | `--provider `、`--json` |構成されたプロバイダーにシードされたモデルをリストします。 | +| `list` (デフォルト) | `--provider `、`--json` | 保存済みモデルとローカルでルーターが認識するモデルを一覧表示します。 | | `live` | `--provider `、`--json` |実行時に検出されたモデルを含む、実行中のカタログを読み取ります。行には、`native`/`routed`、`custom`、および `enabled`/`disabled` というフラグが付けられます。 | | `add ` | `--display-name `、`--context-window `、`--modalities `, `--live`, `--json` | カスタムモデルをローカルに、または `--live` で稼働中のプロキシに登録します。 | | `edit ` | `--model-id `、`--display-name `、`--context-window `、`--modalities `、`--json` |カスタムモデルを編集します。 `-` はフィールドをクリアします。 `0` はコンテキスト ウィンドウをクリアします。 | diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 8e77626c88f..66b0ff59430 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -469,13 +469,13 @@ native-main 트래픽이나 저널 복구를 허용하기 전에 수명 주기 ### `ocx models [subcommand]` · `ocx model ` -`ocx model`은 `ocx models`의 별칭입니다. 하위 명령이 없으면 설정된 제공자에 사전 등록된 모델을 나열합니다. `--provider`는 설정된 제공자 하나를 필터링하고 `--json`은 모델 메타데이터를 반환합니다. `live`는 실행 중인 카탈로그를 읽습니다. `add`, `edit`, `remove`, `list-custom`은 수동 카탈로그 항목을 관리합니다. `enable`, `disable`, `provider`는 가시성을 제어합니다. `selected`는 제공자 허용 목록을 제어합니다. `context`는 제공자 컨텍스트 한도를 제어합니다. `shadow`는 백그라운드 shadow-call 가로채기를 관리합니다. +`ocx model`은 `ocx models`의 별칭입니다. 하위 명령이 없으면 저장된 모델과 라우터가 아는 모델 ID를 나열합니다. 레지스트리 시드, 사용자 정의 모델, 현재 CLI 프로세스의 검색 캐시가 포함됩니다. 새 CLI 프로세스의 캐시는 비어 있을 수 있으며 실행 중인 프록시 카탈로그와 다릅니다. 일부 기존 키 방식 프리셋은 접속 주소를 바꾼 뒤에도 레지스트리 시드를 유지할 수 있습니다. `--provider`는 설정된 제공자 하나를 필터링하고 `--json`은 모델 메타데이터를 반환합니다. `live`는 실행 중인 카탈로그를 읽습니다. `add`, `edit`, `remove`, `list-custom`은 수동 카탈로그 항목을 관리합니다. `enable`, `disable`, `provider`는 가시성을 제어합니다. `selected`는 제공자 허용 목록을 제어합니다. `context`는 제공자 컨텍스트 한도를 제어합니다. `shadow`는 백그라운드 shadow-call 가로채기를 관리합니다. `add`·`remove`는 기본적으로 로컬 저장이며 `--live`를 주면 실행 중인 프록시를 바꿉니다. 로컬 `--json` 저장은 프록시가 없으면 `sync.status: "not-attempted"`, `needsSync: true`, 종료 코드 0을 반환합니다. 실제 동기화 시도가 실패하면 저장은 유지하고 0이 아닌 코드로 끝납니다. `list-custom`은 로컬 목록입니다. 발견된 모델의 표시 이름은 raw upstream ID를 받는 `display-name`으로 바꾸고, picker 순서는 public ID를 받는 `order`로 바꿉니다. [영문 절차](/reference/cli/providers-accounts/#display-names-and-picker-identities)에서 전체 목록·featured 접두부·native 순서 초기화 제약을 확인하세요. | 하위 명령 | 지원 플래그 | 동작 | | --- | --- | --- | -| `list` (기본값) | `--provider `, `--json` | 설정된 제공자에 사전 등록된 모델을 나열합니다. | +| `list` (기본값) | `--provider `, `--json` | 로컬에 저장된 모델과 라우터가 아는 모델을 나열합니다. | | `live` | `--provider `, `--json` | 런타임에 발견된 모델을 포함해 실행 중인 카탈로그를 읽습니다. 행에는 `native`/`routed`, `custom`, `enabled`/`disabled` 표시가 붙습니다. | | `add ` | `--display-name `, `--context-window `, `--modalities `, `--live`, `--json` | 사용자 정의 모델을 로컬에 등록하거나 `--live`로 실행 중인 프록시에 등록합니다. | | `edit ` | `--model-id `, `--display-name `, `--context-window `, `--modalities `, `--json` | 사용자 지정 모델을 수정합니다. `-`는 필드를 지우고, `0`은 컨텍스트 창을 지웁니다. | diff --git a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md index 8adca4b9a4e..c88a653aee7 100644 --- a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md @@ -397,8 +397,7 @@ ocx account main recover [--rollback --yes] [--json] ### `ocx models [subcommand]` · `ocx model ` -`ocx model` — alias команды `ocx models`. Без подкоманды команда показывает модели, статически -засеянные в настроенных провайдерах. `--provider` фильтрует один провайдер, а `--json` возвращает +`ocx model` — alias команды `ocx models`. Без подкоманды команда показывает сохранённые и известные маршрутизатору ID моделей, включая записи реестра, пользовательские модели и кэш обнаружения этого процесса CLI. Кэш нового процесса может быть пустым и не является каталогом работающего прокси. Некоторые старые пресеты с ключом сохраняют записи реестра после смены адреса. `--provider` фильтрует один провайдер, а `--json` возвращает метаданные моделей. `live` читает работающий каталог; `add`, `edit`, `remove` и `list-custom` управляют ручными записями каталога; `enable`, `disable` и `provider` управляют видимостью; `selected` управляет allowlist'ом провайдера; `context` — provider context cap'ами; `shadow` @@ -408,7 +407,7 @@ ocx account main recover [--rollback --yes] [--json] | Подкоманда | Поддерживаемые флаги | Действие | | --- | --- | --- | -| `list` (default) | `--provider `, `--json` | Показать модели, засеянные в настроенных провайдерах. | +| `list` (default) | `--provider `, `--json` | Показать локально сохранённые и известные маршрутизатору модели. | | `live` | `--provider `, `--json` | Прочитать работающий каталог, включая модели, обнаруженные во время выполнения. Строки помечаются как `native`/`routed`, `custom` и `enabled`/`disabled`. | | `add ` | `--display-name `, `--context-window `, `--modalities `, `--live`, `--json` | Регистрирует custom-модель локально или на работающем прокси с `--live`. | | `edit ` | `--model-id `, `--display-name `, `--context-window `, `--modalities `, `--json` | Изменить custom-модель. `-` очищает поле; `0` очищает context window. | diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md index 1c3f0893a21..a32dec39b92 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md @@ -371,7 +371,9 @@ v1 恢复矩阵覆盖的是事务文件通过重命名发布后 OpenCodex 进程 ### `ocx models [subcommand]` · `ocx model ` -`ocx model` 是 `ocx models` 的别名。没有子命令时,列出已配置提供方中静态预置的模型。 +`ocx model` 是 `ocx models` 的别名。没有子命令时,列出已保存及路由器已知的模型 ID, +包括注册表预置、自定义模型和当前 CLI 进程的发现缓存。新 CLI 进程的缓存可能为空, +这不是运行中代理的目录。部分旧版密钥预设即使更改接入地址也可能保留注册表预置。 `--provider` 可过滤单个已配置提供方,而 `--json` 会返回模型元数据。`live` 读取运行中 的目录;`add`、`edit`、`remove` 和 `list-custom` 管理手动目录条目;`enable`、 `disable` 和 `provider` 控制可见性;`selected` 控制提供方允许列表;`context` 控制提供方 @@ -381,7 +383,7 @@ v1 恢复矩阵覆盖的是事务文件通过重命名发布后 OpenCodex 进程 | 子命令 | 支持的标志 | 操作 | | --- | --- | --- | -| `list` (默认) | `--provider `, `--json` | 列出已配置提供方中预置的模型。 | +| `list` (默认) | `--provider `, `--json` | 列出本地已保存及路由器已知的模型。 | | `live` | `--provider `, `--json` | 读取运行中的目录,包括运行时发现的模型。各行会标记为 `native`/`routed`、`custom`,以及 `enabled`/`disabled`。 | | `add ` | `--display-name `, `--context-window `, `--modalities `, `--live`, `--json` | 在本地注册自定义模型,或使用 `--live` 在运行中的代理上注册。 | | `edit ` | `--model-id `, `--display-name `, `--context-window `, `--modalities `, `--json` | 编辑自定义模型。`-` 会清空字段;`0` 会清空上下文窗口。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md index c43aaade0e3..b6126805ab7 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md @@ -352,13 +352,13 @@ Preview 建置使用 `/native-main-profiles`。該配置絕不 ### `ocx models [subcommand]` · `ocx model ` -`ocx model` 是 `ocx models` 的別名。無子指令時,列出已設定供應商中靜態播種的模型。`--provider` 過濾一個已設定的供應商,而 `--json` 回傳模型中繼資料。`live` 讀取執行中的目錄;`add`、`edit`、`remove` 與 `list-custom` 管理手動目錄項目;`enable`、`disable` 與 `provider` 控制可見性;`selected` 控制供應商允許清單;`context` 控制供應商 context 上限;而 `shadow` 管理背景 shadow-call 攔截。 +`ocx model` 是 `ocx models` 的別名。無子指令時,列出已儲存及路由器已知的模型 ID,包括 registry 預設、自訂模型和目前 CLI 程序的探索快取。新 CLI 程序的快取可能為空,這不是執行中代理的目錄。部分舊版金鑰預設在變更連線位址後仍可能保留 registry 預設。`--provider` 過濾一個已設定的供應商,而 `--json` 回傳模型中繼資料。`live` 讀取執行中的目錄;`add`、`edit`、`remove` 與 `list-custom` 管理手動目錄項目;`enable`、`disable` 與 `provider` 控制可見性;`selected` 控制供應商允許清單;`context` 控制供應商 context 上限;而 `shadow` 管理背景 shadow-call 攔截。 `add` 與 `remove` 預設儲存至本機;`--live` 修改執行中的代理。本機 `--json` 儲存時若無代理,回傳 `sync.status: "not-attempted"`、`needsSync: true`,結束碼為 0。實際嘗試的同步失敗時保留儲存結果,以非零碼結束。`list-custom` 是本機清單。`display-name` 使用原始上游 ID,`order` 使用 picker 的 public ID。完整排列、featured 前綴與 native 順序重設限制請見[英文步驟](/reference/cli/providers-accounts/#display-names-and-picker-identities)。 | 子指令 | 支援的旗標 | 動作 | | --- | --- | --- | -| `list`(預設) | `--provider `, `--json` | 列出已設定供應商中播種的模型。 | +| `list`(預設) | `--provider `, `--json` | 列出本機已儲存及路由器已知的模型。 | | `live` | `--provider `, `--json` | 讀取執行中的目錄,包含 runtime 探索的模型。列標記為 `native`/`routed`、`custom` 與 `enabled`/`disabled`。 | | `add ` | `--display-name `, `--context-window `, `--modalities `, `--live`, `--json` | 在本機註冊自訂模型,或使用 `--live` 在執行中的代理上註冊。 | | `edit ` | `--model-id `, `--display-name `, `--context-window `, `--modalities `, `--json` | 編輯自訂模型。`-` 清除欄位;`0` 清除 context window。 | diff --git a/src/cli/models.ts b/src/cli/models.ts index 4c4bca44d89..dd8b6f6c094 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -27,6 +27,7 @@ import { reasoningCapabilitiesFromConfig } from "../providers/reasoning-capabili import { hasNativeOpenAiCapabilityMetadata, nativeContextLimits, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiContextWindow, nativeReasoningEfforts } from "../codex/catalog/metadata"; import { canInferVendorContext, vendorContextInference } from "../codex/catalog/vendor-context"; import { terminalSafeText } from "./runtime-api"; +import { runPassiveRead } from "../lib/passive-read"; import { findLiveProxy } from "../server/proxy-liveness"; import { modelInList, type OcxConfig, type OcxCustomModel } from "../types"; import type { RuntimeApiDeps } from "./runtime-api"; @@ -529,7 +530,7 @@ export async function handleModels(args: string[], deps: ModelsCommandDeps = {}) return; } if (subcommand === "list-custom") { - handleCustomList(rest); + await runPassiveRead(async () => { handleCustomList(rest); }); return; } if (isModelsRuntimeSubcommand(subcommand)) { @@ -538,5 +539,5 @@ export async function handleModels(args: string[], deps: ModelsCommandDeps = {}) if (code !== null) process.exitCode = code; return; } - handleConfiguredModels(subcommand === "list" ? rest : args); + await runPassiveRead(async () => { handleConfiguredModels(subcommand === "list" ? rest : args); }); } diff --git a/structure/cli-management.md b/structure/cli-management.md index 26059ce27fd..fcde205fb68 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -91,7 +91,7 @@ live and latest durable rows are captured under the same mutation lock as deleti ## Model and routing task commands -`src/cli/models.ts` lists saved and router-known IDs locally, including guarded registry seeds, custom rows and its own process cache; `models live` reads the running catalog. Local listing derives metadata from a detached effective policy and does not refresh reasoning metadata. It keeps local custom-model authoring as default and selects explicit live operations before local state. `src/cli/models-custom-runtime.ts` resolves deletion against the complete target roster through the shared slug contract and addresses an exact stored id, refusing ambiguous or malformed identities. `src/cli/models-custom-input.ts` owns the shared pure reasoning grammar, re-exported from the original module. Local JSON writes report saved state and opportunistic sync: no proxy is pending success; attempted failure is nonzero. `src/cli/local-sync-result.ts` is the pure safe projection shared with provider writes; caller-specific required/opportunistic policies remain separate. +`src/cli/models.ts` lists saved and router-known IDs locally, including guarded registry seeds, custom rows and its own process cache; `models live` reads the running catalog. Local listings use passive config reads, derive metadata from a detached effective policy and do not refresh reasoning metadata. It keeps local custom-model authoring as default and selects explicit live operations before local state. `src/cli/models-custom-runtime.ts` resolves deletion against the complete target roster through the shared slug contract and addresses an exact stored id, refusing ambiguous or malformed identities. `src/cli/models-custom-input.ts` owns the shared pure reasoning grammar, re-exported from the original module. Local JSON writes report saved state and opportunistic sync: no proxy is pending success; attempted failure is nonzero. `src/cli/local-sync-result.ts` is the pure safe projection shared with provider writes; caller-specific required/opportunistic policies remain separate. `src/cli/models-order.ts` owns raw upstream display-name addressing and routed picker operations. Status preserves saved native identities, while manual/preset replacement requires explicit reset of native-inclusive order. Manual input is a full unique routed permutation with the observed featured prefix; settings/identity rereads reject observable drift without claiming CAS. `src/cli/model-picker-ordering.ts` mirrors the GUI's pure identity/sort projection without importing GUI code into the CLI. Independent ordering fixtures and test-only GUI conformance exercise the deliberate shared contract. Most-used consumes complete all/all requested-model usage, not representative resolved-model attribution. The registry declares the existing display-name PUT; its handler remains authoritative, including saved-but-failed HTTP503. diff --git a/tests/cli/cli-models.test.ts b/tests/cli/cli-models.test.ts index 2d868d993aa..fcfb034f6d5 100644 --- a/tests/cli/cli-models.test.ts +++ b/tests/cli/cli-models.test.ts @@ -1,6 +1,6 @@ import { beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -79,6 +79,35 @@ describe("ocx models", () => { await warmModuleGraph({ graph: "cli-index/models", entry: cliPath }); }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); + test.each(["valid", "malformed", "retired-provider"])("local listings leave %s configuration untouched", kind => { + const { dir } = freshConfig(kind === "retired-provider" ? { + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, "opencode-free": {} }, + } : undefined); + const configPath = join(dir, "config.json"); + const authPath = join(dir, "auth.json"); + mkdirSync(join(dir, "codex"), { recursive: true }); + writeFileSync(join(dir, "no-live-sync.ts"), ""); + writeFileSync(authPath, "{}"); + if (kind === "malformed") writeFileSync(configPath, "{ invalid json"); + if (process.platform !== "win32") { + chmodSync(dir, 0o755); + chmodSync(configPath, 0o644); + chmodSync(authPath, 0o644); + } + try { + const contents = readFileSync(configPath, "utf8"); + const names = readdirSync(dir).sort(); + const modes = [dir, configPath, authPath].map(path => statSync(path).mode & 0o777); + for (const args of [["models", "--json"], ["models", "list-custom", "--json"]]) { + const result = runCli(args, { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + expect(readFileSync(configPath, "utf8")).toBe(contents); + expect(readdirSync(dir).sort()).toEqual(names); + if (process.platform !== "win32") expect([dir, configPath, authPath].map(path => statSync(path).mode & 0o777)).toEqual(modes); + } + } finally { removeTreeWithRetry(dir); } + }); + test("lists a custom-only router-known model with its saved metadata", () => { const { dir } = freshConfig({ providers: { test: { adapter: "openai-chat", baseUrl: "https://example.test/v1", models: [] } }, From a412687570cdd7c859424ab7c15bcbecb4cd156f Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 6 Nov 2026 15:05:30 +0900 Subject: [PATCH 11/15] fix(models): preserve passive listing behavior --- src/cli/codex-shim-autorestore.ts | 5 +++++ src/cli/models.ts | 4 ++-- src/cli/registry.ts | 2 +- src/config/removed-opencode-free-backup.ts | 9 ++++++--- src/lib/passive-read.ts | 13 ++++++++---- structure/config.md | 2 +- tests/cli/cli-models.test.ts | 20 +++++++++++++++++++ .../codex-shim-autorestore.test.ts | 20 +++++++++++++++++++ 8 files changed, 64 insertions(+), 11 deletions(-) diff --git a/src/cli/codex-shim-autorestore.ts b/src/cli/codex-shim-autorestore.ts index bac12067179..9b259cd914a 100644 --- a/src/cli/codex-shim-autorestore.ts +++ b/src/cli/codex-shim-autorestore.ts @@ -35,6 +35,11 @@ export function skipsCodexShimAutoRestore(command: string | undefined, args: str // `resolve` is read-only inspection for embedding shells: a lookup made to populate // a consent surface must not trigger a shim repair side effect first. if (command === "resolve") return true; + // Local model inventory is read-only; mutations and live commands keep preflight. + if (command === "models" || command === "model") { + const subcommand = args[1]; + if (!subcommand || subcommand.startsWith("-") || subcommand === "list" || subcommand === "list-custom") return true; + } // `service status --json` is a passive inventory read for peers such as GOAT: a shim repair // before dispatch would change the installation it was asked only to describe. // Flags may precede the verb (`ocx service --json status`), exactly as parseServiceArgs reads it. diff --git a/src/cli/models.ts b/src/cli/models.ts index dd8b6f6c094..b2bc1b5da40 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -530,7 +530,7 @@ export async function handleModels(args: string[], deps: ModelsCommandDeps = {}) return; } if (subcommand === "list-custom") { - await runPassiveRead(async () => { handleCustomList(rest); }); + await runPassiveRead(async () => { handleCustomList(rest); }, { onDiagnostic: message => console.error(message) }); return; } if (isModelsRuntimeSubcommand(subcommand)) { @@ -539,5 +539,5 @@ export async function handleModels(args: string[], deps: ModelsCommandDeps = {}) if (code !== null) process.exitCode = code; return; } - await runPassiveRead(async () => { handleConfiguredModels(subcommand === "list" ? rest : args); }); + await runPassiveRead(async () => { handleConfiguredModels(subcommand === "list" ? rest : args); }, { onDiagnostic: message => console.error(message) }); } diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 53db87d00e0..a9f48283878 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -379,7 +379,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ usage: "ocx models ...", summary: "List models and manage custom (manually registered) models.", details: [ - "List available models from static config with no subcommand (liveModels may add more at runtime).", + "List available models: saved and router-known IDs include custom models and the process-local cache; models live reads the running proxy catalog.", "add/remove support --live for the selected proxy; live or JSON removal requires --yes.", "Local add/remove JSON reports saved state and opportunistic sync; no proxy keeps the save successful/pending.", "display-name --set