Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions src/cli/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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>]";
const REMOVE_USAGE = "Usage: ocx models remove <customId|provider/modelId> [--yes]";
Expand Down Expand Up @@ -98,15 +98,22 @@ 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.
// 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 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,
});
Expand Down
96 changes: 96 additions & 0 deletions tests/cli-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -165,6 +167,100 @@ 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("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 = {
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 {
Expand Down
Loading