Skip to content

Commit 0135209

Browse files
authored
Merge pull request lidge-jun#2086 from ntdatt812/fix/cli-models-family-resolution
fix(cli): report model capabilities the way the runtime resolves them
2 parents 1a75778 + 5064738 commit 0135209

2 files changed

Lines changed: 109 additions & 6 deletions

File tree

‎src/cli/models.ts‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import { randomUUID } from "node:crypto";
55
import { createInterface } from "node:readline/promises";
66
import { syncModelsToCodex } from "../codex/sync";
77
import { hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config";
8-
import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort } from "../reasoning-effort";
8+
import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort, modelRecordValue } from "../reasoning-effort";
99
import { encodedModelIdCollides, routedSlug, slugEquals } from "../providers/slug-codec";
1010
import { knownModelIdsForProvider } from "../router";
1111
import { findLiveProxy } from "../server/proxy-liveness";
12-
import type { OcxConfig, OcxCustomModel } from "../types";
12+
import { modelInList, type OcxConfig, type OcxCustomModel } from "../types";
1313

1414
const ADD_USAGE = "Usage: ocx models add <provider> <modelId> [--display-name <name>] [--context-window <tokens>] [--modalities text,image,audio] [--reasoning-efforts <none,minimal,low,medium,high,xhigh,max,ultra>] [--default-reasoning-effort <level>]";
1515
const REMOVE_USAGE = "Usage: ocx models remove <customId|provider/modelId> [--yes]";
@@ -98,15 +98,22 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[]
9898
if (seen.has(model)) return;
9999
seen.add(model);
100100

101-
const noVision = prov.noVisionModels?.includes(model);
102-
const modalities = inputModalities[model] ?? (noVision ? ["text"] : null);
103-
const efforts = reasoningEfforts[model] ?? prov.reasoningEfforts ?? null;
101+
// Resolve exactly as the runtime does, or this command reports capabilities the
102+
// proxy will not honour: `isModelTextOnly` matches noVisionModels with modelInList
103+
// and reads modelInputModalities with modelRecordValue, so a `gpt-oss` entry covers
104+
// `gpt-oss:120b`. A bare lookup reported that model as unclassified on every field.
105+
// noVisionModels is checked first because `isModelTextOnly` returns true on that
106+
// match before it ever reads modelInputModalities: a `gpt-oss` noVision entry beats
107+
// an exact `gpt-oss:120b` entry that lists "image", and the proxy rejects the image.
108+
const noVision = modelInList(prov.noVisionModels, model);
109+
const modalities = noVision ? ["text"] : (modelRecordValue(inputModalities, model) ?? null);
110+
const efforts = modelRecordValue(reasoningEfforts, model) ?? prov.reasoningEfforts ?? null;
104111

105112
entries.push({
106113
provider: provName,
107114
model,
108115
isDefault,
109-
contextWindow: contextWindows[model] ?? globalContext,
116+
contextWindow: modelRecordValue(contextWindows, model) ?? globalContext,
110117
inputModalities: modalities,
111118
reasoningEfforts: efforts,
112119
});

‎tests/cli-models.test.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { tmpdir } from "node:os";
55
import { dirname, join } from "node:path";
66
import { fileURLToPath } from "node:url";
77
import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "./helpers/test-budget";
8+
import { isModelTextOnly } from "../src/vision";
9+
import type { OcxProviderConfig } from "../src/types";
810

911
const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url)));
1012
const cliPath = join(repoRoot, "src", "cli", "index.ts");
@@ -165,6 +167,100 @@ describe("ocx models richer metadata", () => {
165167
}
166168
});
167169

170+
test("a family entry classifies its tagged siblings, as the runtime does", () => {
171+
// isModelTextOnly matches noVisionModels with modelInList and reads
172+
// modelInputModalities with modelRecordValue, so a `gpt-oss` entry covers
173+
// `gpt-oss:120b`. This command must not report a different answer.
174+
const dir = mkdtempSync(join(tmpdir(), "ocx-models-family-"));
175+
const provider = {
176+
adapter: "openai-chat",
177+
baseUrl: "http://localhost:8080/v1",
178+
allowPrivateNetwork: true,
179+
defaultModel: "gpt-oss:120b",
180+
models: ["gpt-oss:120b"],
181+
modelContextWindows: { "gpt-oss": 131000 },
182+
noVisionModels: ["gpt-oss"],
183+
modelReasoningEfforts: { "gpt-oss": ["low", "high"] },
184+
};
185+
writeFileSync(
186+
join(dir, "config.json"),
187+
JSON.stringify({ port: 10121, providers: { test: provider }, defaultProvider: "test" }),
188+
"utf8",
189+
);
190+
try {
191+
// Ground truth first: what the proxy itself will do with this config.
192+
expect(isModelTextOnly(provider as unknown as OcxProviderConfig, "gpt-oss:120b")).toBe(true);
193+
194+
const result = runCli(["models", "--json"], { OPENCODEX_HOME: dir });
195+
expect(result.status).toBe(0);
196+
const row = JSON.parse(result.stdout).models
197+
.find((m: { model: string }) => m.model === "gpt-oss:120b");
198+
expect(row.inputModalities).toEqual(["text"]);
199+
expect(row.contextWindow).toBe(131000);
200+
expect(row.reasoningEfforts).toEqual(["low", "high"]);
201+
} finally {
202+
rmSync(dir, { recursive: true, force: true });
203+
}
204+
});
205+
206+
test("a noVision family entry beats an exact modality entry, as the runtime does", () => {
207+
// isModelTextOnly returns true on the noVisionModels match before it ever reads
208+
// modelInputModalities, so an exact entry listing "image" does not grant vision.
209+
// Reporting ["text", "image"] here would advertise support the proxy then rejects.
210+
const dir = mkdtempSync(join(tmpdir(), "ocx-models-novision-"));
211+
const provider = {
212+
adapter: "openai-chat",
213+
baseUrl: "http://localhost:8080/v1",
214+
allowPrivateNetwork: true,
215+
defaultModel: "gpt-oss:120b",
216+
models: ["gpt-oss:120b"],
217+
noVisionModels: ["gpt-oss"],
218+
modelInputModalities: { "gpt-oss:120b": ["text", "image"] },
219+
};
220+
writeFileSync(
221+
join(dir, "config.json"),
222+
JSON.stringify({ port: 10123, providers: { test: provider }, defaultProvider: "test" }),
223+
"utf8",
224+
);
225+
try {
226+
// Ground truth first: the proxy treats this model as text-only.
227+
expect(isModelTextOnly(provider as unknown as OcxProviderConfig, "gpt-oss:120b")).toBe(true);
228+
229+
const result = runCli(["models", "--json"], { OPENCODEX_HOME: dir });
230+
expect(result.status).toBe(0);
231+
const row = JSON.parse(result.stdout).models
232+
.find((m: { model: string }) => m.model === "gpt-oss:120b");
233+
expect(row.inputModalities).toEqual(["text"]);
234+
} finally {
235+
rmSync(dir, { recursive: true, force: true });
236+
}
237+
});
238+
239+
test("an exact entry still wins over the family entry", () => {
240+
const dir = mkdtempSync(join(tmpdir(), "ocx-models-exact-"));
241+
const provider = {
242+
adapter: "openai-chat",
243+
baseUrl: "http://localhost:8080/v1",
244+
allowPrivateNetwork: true,
245+
defaultModel: "gpt-oss:20b",
246+
models: ["gpt-oss:20b"],
247+
modelContextWindows: { "gpt-oss": 131000, "gpt-oss:20b": 32000 },
248+
};
249+
writeFileSync(
250+
join(dir, "config.json"),
251+
JSON.stringify({ port: 10122, providers: { test: provider }, defaultProvider: "test" }),
252+
"utf8",
253+
);
254+
try {
255+
const result = runCli(["models", "--json"], { OPENCODEX_HOME: dir });
256+
const row = JSON.parse(result.stdout).models
257+
.find((m: { model: string }) => m.model === "gpt-oss:20b");
258+
expect(row.contextWindow).toBe(32000);
259+
} finally {
260+
rmSync(dir, { recursive: true, force: true });
261+
}
262+
});
263+
168264
test("models rejects unknown flags", () => {
169265
const { dir } = freshConfig();
170266
try {

0 commit comments

Comments
 (0)