From 71ed29de91ccf034804781bc648df7cbc1162f4a Mon Sep 17 00:00:00 2001 From: Nguyen Thanh Dat Date: Wed, 19 Aug 2026 09:16:19 +0700 Subject: [PATCH 1/2] fix(cli): report model capabilities the way the runtime resolves them `ocx models` classified each row with bare lookups while the proxy resolves the same four fields through modelInList / modelRecordValue, which accept a family entry for a tagged id. With models ["gpt-oss:120b"], noVisionModels ["gpt-oss"], modelContextWindows {"gpt-oss": 131072} and modelReasoningEfforts {"gpt-oss": ["low","high"]}: runtime isModelTextOnly = true, window 131072, efforts [low, high] ocx models {"contextWindow":null,"inputModalities":null, "reasoningEfforts":null} Every field came back unclassified, so a text-only model reads as image-capable and a configured window reads as unset -- for a config the proxy honours in full. Two tests. The first asserts isModelTextOnly first, so the command is pinned to the runtime's answer rather than to a copy of it; it is red without the src change. The second pins exact-over-family precedence and passes either way -- it guards the fix from over-reaching, it is not evidence of the bug. 237 tests green across cli-models, vision-eligibility, codex-catalog and input-admission. tsc --noEmit clean. --- src/cli/models.ts | 16 ++++++---- tests/cli-models.test.ts | 63 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/cli/models.ts b/src/cli/models.ts index db4f20742d..59632e8e07 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -5,11 +5,11 @@ import { randomUUID } from "node:crypto"; import { createInterface } from "node:readline/promises"; import { syncModelsToCodex } from "../codex/sync"; import { hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config"; -import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort } from "../reasoning-effort"; +import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort, modelRecordValue } from "../reasoning-effort"; import { encodedModelIdCollides, routedSlug, slugEquals } from "../providers/slug-codec"; import { knownModelIdsForProvider } from "../router"; import { findLiveProxy } from "../server/proxy-liveness"; -import type { OcxConfig, OcxCustomModel } from "../types"; +import { modelInList, type OcxConfig, type OcxCustomModel } from "../types"; const ADD_USAGE = "Usage: ocx models add [--display-name ] [--context-window ] [--modalities text,image,audio] [--reasoning-efforts ] [--default-reasoning-effort ]"; const REMOVE_USAGE = "Usage: ocx models remove [--yes]"; @@ -98,15 +98,19 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] if (seen.has(model)) return; seen.add(model); - const noVision = prov.noVisionModels?.includes(model); - const modalities = inputModalities[model] ?? (noVision ? ["text"] : null); - const efforts = reasoningEfforts[model] ?? prov.reasoningEfforts ?? null; + // 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. + const noVision = modelInList(prov.noVisionModels, model); + const modalities = modelRecordValue(inputModalities, model) ?? (noVision ? ["text"] : null); + const efforts = modelRecordValue(reasoningEfforts, model) ?? prov.reasoningEfforts ?? null; entries.push({ provider: provName, model, isDefault, - contextWindow: contextWindows[model] ?? globalContext, + contextWindow: modelRecordValue(contextWindows, model) ?? globalContext, inputModalities: modalities, reasoningEfforts: efforts, }); diff --git a/tests/cli-models.test.ts b/tests/cli-models.test.ts index 2c62326e71..0ad1a18719 100644 --- a/tests/cli-models.test.ts +++ b/tests/cli-models.test.ts @@ -5,6 +5,8 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "./helpers/test-budget"; +import { isModelTextOnly } from "../src/vision"; +import type { OcxProviderConfig } from "../src/types"; const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); const cliPath = join(repoRoot, "src", "cli", "index.ts"); @@ -165,6 +167,67 @@ describe("ocx models richer metadata", () => { } }); + test("a family entry classifies its tagged siblings, as the runtime does", () => { + // isModelTextOnly matches noVisionModels with modelInList and reads + // modelInputModalities with modelRecordValue, so a `gpt-oss` entry covers + // `gpt-oss:120b`. This command must not report a different answer. + const dir = mkdtempSync(join(tmpdir(), "ocx-models-family-")); + const provider = { + adapter: "openai-chat", + baseUrl: "http://localhost:8080/v1", + allowPrivateNetwork: true, + defaultModel: "gpt-oss:120b", + models: ["gpt-oss:120b"], + modelContextWindows: { "gpt-oss": 131000 }, + noVisionModels: ["gpt-oss"], + modelReasoningEfforts: { "gpt-oss": ["low", "high"] }, + }; + writeFileSync( + join(dir, "config.json"), + JSON.stringify({ port: 10121, providers: { test: provider }, defaultProvider: "test" }), + "utf8", + ); + try { + // Ground truth first: what the proxy itself will do with this config. + expect(isModelTextOnly(provider as unknown as OcxProviderConfig, "gpt-oss:120b")).toBe(true); + + const result = runCli(["models", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const row = JSON.parse(result.stdout).models + .find((m: { model: string }) => m.model === "gpt-oss:120b"); + expect(row.inputModalities).toEqual(["text"]); + expect(row.contextWindow).toBe(131000); + expect(row.reasoningEfforts).toEqual(["low", "high"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("an exact entry still wins over the family entry", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-models-exact-")); + const provider = { + adapter: "openai-chat", + baseUrl: "http://localhost:8080/v1", + allowPrivateNetwork: true, + defaultModel: "gpt-oss:20b", + models: ["gpt-oss:20b"], + modelContextWindows: { "gpt-oss": 131000, "gpt-oss:20b": 32000 }, + }; + writeFileSync( + join(dir, "config.json"), + JSON.stringify({ port: 10122, providers: { test: provider }, defaultProvider: "test" }), + "utf8", + ); + try { + const result = runCli(["models", "--json"], { OPENCODEX_HOME: dir }); + const row = JSON.parse(result.stdout).models + .find((m: { model: string }) => m.model === "gpt-oss:20b"); + expect(row.contextWindow).toBe(32000); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test("models rejects unknown flags", () => { const { dir } = freshConfig(); try { From f408914101fcb0d9c0385c0fdf0187c58979f1ee Mon Sep 17 00:00:00 2001 From: Nguyen Thanh Dat Date: Wed, 19 Aug 2026 16:13:43 +0700 Subject: [PATCH 2/2] fix(cli): give noVisionModels precedence over an exact modality entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isModelTextOnly returns true on the noVisionModels match before it ever reads modelInputModalities, so a `gpt-oss` noVision entry beats an exact `gpt-oss:120b` entry that lists "image". Resolving the exact entry first made `ocx models` advertise image support the proxy then rejects — the same class of drift this PR set out to remove. Add the conflicting-config regression case, which asserts the runtime's answer via isModelTextOnly before comparing the CLI's. Thanks @coderabbitai for catching it. --- src/cli/models.ts | 5 ++++- tests/cli-models.test.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/cli/models.ts b/src/cli/models.ts index 59632e8e07..a20ba18472 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -102,8 +102,11 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] // 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 = modelRecordValue(inputModalities, model) ?? (noVision ? ["text"] : null); + const modalities = noVision ? ["text"] : (modelRecordValue(inputModalities, model) ?? null); const efforts = modelRecordValue(reasoningEfforts, model) ?? prov.reasoningEfforts ?? null; entries.push({ diff --git a/tests/cli-models.test.ts b/tests/cli-models.test.ts index 0ad1a18719..0827f61ae6 100644 --- a/tests/cli-models.test.ts +++ b/tests/cli-models.test.ts @@ -203,6 +203,39 @@ describe("ocx models richer metadata", () => { } }); + test("a noVision family entry beats an exact modality entry, as the runtime does", () => { + // isModelTextOnly returns true on the noVisionModels match before it ever reads + // modelInputModalities, so an exact entry listing "image" does not grant vision. + // Reporting ["text", "image"] here would advertise support the proxy then rejects. + const dir = mkdtempSync(join(tmpdir(), "ocx-models-novision-")); + const provider = { + adapter: "openai-chat", + baseUrl: "http://localhost:8080/v1", + allowPrivateNetwork: true, + defaultModel: "gpt-oss:120b", + models: ["gpt-oss:120b"], + noVisionModels: ["gpt-oss"], + modelInputModalities: { "gpt-oss:120b": ["text", "image"] }, + }; + writeFileSync( + join(dir, "config.json"), + JSON.stringify({ port: 10123, providers: { test: provider }, defaultProvider: "test" }), + "utf8", + ); + try { + // Ground truth first: the proxy treats this model as text-only. + expect(isModelTextOnly(provider as unknown as OcxProviderConfig, "gpt-oss:120b")).toBe(true); + + const result = runCli(["models", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const row = JSON.parse(result.stdout).models + .find((m: { model: string }) => m.model === "gpt-oss:120b"); + expect(row.inputModalities).toEqual(["text"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test("an exact entry still wins over the family entry", () => { const dir = mkdtempSync(join(tmpdir(), "ocx-models-exact-")); const provider = {