Compare commits

...

3 Commits

Author SHA1 Message Date
diegosouzapw
5fe056e61c fix(cli): derive model completion from registered commands 2026-09-22 10:08:32 -03:00
diegosouzapw
fd853aede9 test(cli): isolate model catalog credentials and link changelog 2026-09-21 19:32:50 -03:00
diegosouzapw
da1cd3522d feat(cli): manage manual models with verified persistence 2026-09-21 19:24:00 -03:00
11 changed files with 1002 additions and 93 deletions

View File

@@ -5,6 +5,7 @@ import { t } from "../i18n.mjs";
import { apiFetch } from "../api.mjs";
import { resolveDataDir } from "../data-dir.mjs";
import { listManifestTargets } from "../cli-manifest.mjs";
import { loadModelCatalog, ModelCommandError } from "./model-api.mjs";
// Target lists shared with `omniroute run` / `omniroute configure` — always
// derived from the canonical manifest so the completion scripts cannot drift.
@@ -30,14 +31,14 @@ function readCache() {
}
async function refreshCache(opts = {}) {
// Fail before replacing the cache when the selected catalog is unavailable.
const models = (await loadModelCatalog(opts)).map((model) => model.id);
let combos = [],
providers = [],
models = [];
providers = [];
try {
const [cr, pr, mr] = await Promise.allSettled([
const [cr, pr] = await Promise.allSettled([
apiFetch("/api/combos", opts),
apiFetch("/api/providers", opts),
apiFetch("/api/models", opts),
]);
if (cr.status === "fulfilled" && cr.value.ok) {
const j = await cr.value.json();
@@ -47,10 +48,6 @@ async function refreshCache(opts = {}) {
const j = await pr.value.json();
providers = (j.providers || j.items || []).map((p) => p.id || p.name).filter(Boolean);
}
if (mr.status === "fulfilled" && mr.value.ok) {
const j = await mr.value.json();
models = (Array.isArray(j) ? j : j.data || []).map((m) => m.id).filter(Boolean);
}
} catch (err) {
if (process.env.OMNIROUTE_DEBUG_COMPLETION) {
console.error("[omniroute completion] refreshCache failed:", err?.message ?? err);
@@ -82,7 +79,12 @@ function installPath(shell) {
return join(home, ".bash_completion.d", "omniroute");
}
function generateZshScript() {
function modelSubcommandWords(program) {
const models = program?.commands.find((command) => command.name() === "models");
return models?.commands.map((command) => command.name()).join(" ") || "";
}
function generateZshScript(modelCommands) {
return `#compdef omniroute
# OmniRoute zsh completion (dynamic)
@@ -179,6 +181,7 @@ _omniroute() {
_arguments '1:resource:(combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience)' ;;
completion) _arguments '1:subcommand:(zsh bash fish install refresh)' ;;
config) _arguments '1:subcommand:(list get set validate contexts)' ;;
models) _arguments '1:subcommand:(${modelCommands})' ;;
contexts) _arguments '1:subcommand:(list add use current show remove rename export import migrate)' ;;
configure) _arguments '1:target:(${CONFIGURE_TARGET_WORDS})' ;;
run) _arguments '1:target:(${RUN_TARGET_WORDS})' ;;
@@ -204,7 +207,7 @@ compdef _omniroute omniroute
`;
}
function generateBashScript() {
function generateBashScript(modelCommands) {
return `#!/bin/bash
# OmniRoute CLI bash completion (dynamic)
@@ -235,6 +238,7 @@ _omniroute() {
keys) COMPREPLY=($(compgen -W "add list remove regenerate revoke reveal usage" -- "\${cur}")); return 0 ;;
providers) COMPREPLY=($(compgen -W "available list test test-all validate rotate status add import auth remove edit metrics metric" -- "\${cur}")); return 0 ;;
config) COMPREPLY=($(compgen -W "list get set validate contexts" -- "\${cur}")); return 0 ;;
models) COMPREPLY=($(compgen -W "${modelCommands}" -- "\${cur}")); return 0 ;;
completion) COMPREPLY=($(compgen -W "zsh bash fish install refresh" -- "\${cur}")); return 0 ;;
open) COMPREPLY=($(compgen -W "combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience" -- "\${cur}")); return 0 ;;
contexts) COMPREPLY=($(compgen -W "list add use current show remove rename export import migrate" -- "\${cur}")); return 0 ;;
@@ -262,7 +266,7 @@ complete -F _omniroute omniroute
`;
}
function generateFishScript() {
function generateFishScript(modelCommands) {
return `# OmniRoute CLI fish completion (dynamic)
complete -c omniroute -f
@@ -277,6 +281,7 @@ complete -c omniroute -n '__fish_seen_subcommand_from combo' -a 'list switch cre
complete -c omniroute -n '__fish_seen_subcommand_from keys' -a 'add list remove regenerate revoke reveal usage'
complete -c omniroute -n '__fish_seen_subcommand_from providers' -a 'available list test test-all validate rotate status add import auth remove edit metrics metric'
complete -c omniroute -n '__fish_seen_subcommand_from config' -a 'list get set validate contexts'
complete -c omniroute -n '__fish_seen_subcommand_from models' -a '${modelCommands}'
complete -c omniroute -n '__fish_seen_subcommand_from completion' -a 'zsh bash fish install refresh'
complete -c omniroute -n '__fish_seen_subcommand_from open' -a 'combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience'
complete -c omniroute -n '__fish_seen_subcommand_from contexts' -a 'list add use current show remove rename export import migrate'
@@ -315,17 +320,17 @@ export function registerCompletion(program) {
comp
.command("zsh")
.description(t("completion.zsh") || "Print zsh completion script")
.action(async () => process.stdout.write(generateZshScript()));
.action(async () => process.stdout.write(generateZshScript(modelSubcommandWords(program))));
comp
.command("bash")
.description(t("completion.bash") || "Print bash completion script")
.action(async () => process.stdout.write(generateBashScript()));
.action(async () => process.stdout.write(generateBashScript(modelSubcommandWords(program))));
comp
.command("fish")
.description(t("completion.fish") || "Print fish completion script")
.action(async () => process.stdout.write(generateFishScript()));
.action(async () => process.stdout.write(generateFishScript(modelSubcommandWords(program))));
comp
.command("install [shell]")
@@ -339,7 +344,7 @@ export function registerCompletion(program) {
}
const dest = installPath(target);
mkdirSync(dirname(dest), { recursive: true });
writeFileSync(dest, gen());
writeFileSync(dest, gen(modelSubcommandWords(program)));
process.stdout.write(
`Installed ${target} completion at ${dest}\nRestart your shell or source the file.\n`
);
@@ -351,7 +356,18 @@ export function registerCompletion(program) {
.option("--quiet", "Suppress output")
.action(async (opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
const data = await refreshCache(globalOpts);
let data;
try {
data = await refreshCache(globalOpts);
} catch (error) {
console.error(
error instanceof ModelCommandError
? error.message
: "Unable to refresh model completions."
);
process.exitCode = error.exitCode || 1;
return;
}
if (!opts.quiet && !globalOpts.quiet) {
process.stdout.write(
`Cached: ${data.combos.length} combos, ${data.providers.length} providers, ${data.models.length} models\n`
@@ -370,17 +386,17 @@ export function registerCompletion(program) {
process.stderr.write(`Unknown shell: ${shell}. Valid: bash, zsh, fish\n`);
process.exit(1);
}
process.stdout.write(gen());
process.stdout.write(gen(modelSubcommandWords(program)));
});
}
// Legacy export for backward compatibility
export async function runCompletionCommand(shell) {
export async function runCompletionCommand(shell, program) {
const gen = generators[shell];
if (!gen) {
process.stderr.write(`Unknown shell: ${shell}. Valid: bash, zsh, fish\n`);
return 1;
}
process.stdout.write(gen());
process.stdout.write(gen(modelSubcommandWords(program)));
return 0;
}

View File

@@ -0,0 +1,131 @@
import { apiFetch, statusToExitCode } from "../api.mjs";
export class ModelCommandError extends Error {
constructor(message, exitCode = 2) {
super(message);
this.exitCode = exitCode;
}
}
export async function modelRequest(path, opts, init = {}) {
let response;
try {
response = await apiFetch(path, {
...opts,
...init,
retry: false,
timeout: opts.timeout ?? 30000,
redirect: "error",
acceptNotOk: true,
});
} catch (error) {
throw new ModelCommandError(
"Unable to reach the selected OmniRoute server.",
error.exitCode === 124 ? 124 : 1
);
}
if (!response.ok) {
const error = new ModelCommandError(
`Model request failed (HTTP ${response.status}).`,
statusToExitCode(response.status)
);
error.status = response.status;
throw error;
}
try {
return await response.json();
} catch {
throw new ModelCommandError("The server returned an invalid model response.", 1);
}
}
export async function loadModelCatalog(opts = {}) {
let data;
try {
data = await modelRequest("/api/v1/models", opts);
} catch (error) {
if (![404, 405, 501].includes(error.status)) throw error;
data = await modelRequest("/api/models", opts);
}
const models = Array.isArray(data) ? data : (data?.data ?? data?.models);
if (!Array.isArray(models)) throw new ModelCommandError("Invalid model catalog.", 1);
return models.filter((model) => model && typeof model === "object").map(publicModel);
}
// Public model metadata only; never forward credentials or compatibility headers.
const PUBLIC_FIELDS = [
"id",
"name",
"object",
"created",
"owned_by",
"provider",
"description",
"source",
"context_length",
"contextWindow",
"contextWindowOverride",
"contextWindowOverrideSource",
"max_input_tokens",
"max_output_tokens",
"inputTokenLimit",
"outputTokenLimit",
"apiFormat",
"supportedEndpoints",
"targetFormat",
"supportsVision",
"supports_vision",
"supports_tools",
"supports_reasoning",
"modelType",
"isFree",
"dimensions",
"root",
"parent",
"type",
"free",
"custom",
"api_format",
"supported_endpoints",
"input_modalities",
"output_modalities",
"supported_parameters",
"supportedInputTypes",
];
export function publicModel(model) {
const result = {};
for (const field of PUBLIC_FIELDS) {
const value = model?.[field];
if (value === null || ["string", "number", "boolean"].includes(typeof value))
result[field] = value;
else if (Array.isArray(value) && value.every((item) => typeof item === "string"))
result[field] = value;
}
if (model?.capabilities && typeof model.capabilities === "object") {
result.capabilities = Object.fromEntries(
Object.entries(model.capabilities).filter(
([key, value]) =>
[
"vision",
"reasoning",
"tool_calling",
"structured_output",
"streaming",
"audio",
"video",
].includes(key) && typeof value === "boolean"
)
);
}
result.id = String(model?.id || model?.name || "unknown");
result.provider = String(model?.provider || model?.owned_by || "unknown");
result.contextWindow =
result.contextWindowOverride ??
result.context_length ??
result.max_input_tokens ??
result.inputTokenLimit ??
result.contextWindow ??
"-";
return result;
}

View File

@@ -0,0 +1,174 @@
import { z } from "zod";
import { emit } from "../output.mjs";
import { ModelCommandError, modelRequest, publicModel } from "./model-api.mjs";
// Runtime CLI subset of providerModelMutationSchema. The API validates again.
const identity = z.object({
provider: z.string().trim().min(1).max(120),
modelId: z.string().trim().min(1).max(240),
});
const patchSchema = z.object({
modelName: z.string().trim().min(1).max(240).optional(),
apiFormat: z
.enum([
"chat-completions",
"responses",
"embeddings",
"rerank",
"audio-transcriptions",
"audio-speech",
"images-generations",
"video",
])
.optional(),
max_input_tokens: z.number().int().positive().safe().optional(),
max_output_tokens: z.number().int().positive().safe().optional(),
contextWindowOverride: z.number().int().positive().safe().nullable().optional(),
});
const FORMAT_ENDPOINT = {
"chat-completions": "chat",
responses: "chat",
embeddings: "embeddings",
rerank: "rerank",
"audio-transcriptions": "audio-transcriptions",
"audio-speech": "audio-speech",
"images-generations": "images",
video: "videos",
};
function payloadFor(action, provider, modelId, opts) {
const id = identity.safeParse({ provider, modelId });
const patch = {};
if (opts.name !== undefined) patch.modelName = opts.name;
if (opts.apiFormat !== undefined) patch.apiFormat = opts.apiFormat;
if (opts.contextWindow !== undefined) {
patch[action === "add" ? "max_input_tokens" : "contextWindowOverride"] = Number(
opts.contextWindow
);
}
if (opts.clearContextWindow) {
if (opts.contextWindow !== undefined || action !== "edit")
throw new ModelCommandError("Invalid context-window options.");
patch.contextWindowOverride = null;
}
if (opts.maxOutputTokens !== undefined) patch.max_output_tokens = Number(opts.maxOutputTokens);
const parsed = patchSchema.safeParse(patch);
if (!id.success || !parsed.success)
throw new ModelCommandError("Invalid model identifier or metadata.");
if (action === "edit" && Object.keys(patch).length === 0)
throw new ModelCommandError("Provide at least one metadata change.");
return {
...id.data,
...parsed.data,
...(parsed.data.apiFormat
? { supportedEndpoints: [FORMAT_ENDPOINT[parsed.data.apiFormat]] }
: {}),
...(action === "add" ? { source: "manual" } : {}),
};
}
export async function listManualModels(provider, opts = {}) {
const parsed = identity.shape.provider.safeParse(provider);
if (!parsed.success) throw new ModelCommandError("Invalid provider identifier.");
const data = await modelRequest(
`/api/provider-models?${new URLSearchParams({ provider: parsed.data })}`,
opts
);
if (!Array.isArray(data?.models)) throw new ModelCommandError("Invalid manual model catalog.", 1);
return data.models;
}
export async function modifyManualModel(action, provider, modelId, opts = {}) {
if (!["add", "edit", "remove"].includes(action))
throw new ModelCommandError("Invalid model operation.");
if (action === "remove" && !opts.yes && !opts.dryRun)
throw new ModelCommandError("Removal requires --yes (or --dry-run).");
const body = payloadFor(action, provider, modelId, opts);
const before = (await listManualModels(body.provider, opts)).find(
(model) => model.id === body.modelId
);
if (action === "add" && before)
throw new ModelCommandError("The custom model already exists; use edit.");
if (action !== "add" && (!before || (before.source && before.source !== "manual"))) {
throw new ModelCommandError("The selected model is not an existing manual model.");
}
if (opts.dryRun)
return { action, dryRun: true, provider: body.provider, modelId: body.modelId, changes: body };
const query = new URLSearchParams({
provider: body.provider,
model: body.modelId,
resetOverride: "true",
});
await modelRequest(
action === "remove" ? `/api/provider-models?${query}` : "/api/provider-models",
opts,
{
method: { add: "POST", edit: "PUT", remove: "DELETE" }[action],
...(action === "remove" ? {} : { body }),
}
);
const after = (await listManualModels(body.provider, opts)).find(
(model) => model.id === body.modelId
);
const mapping = {
modelName: "name",
max_input_tokens: "inputTokenLimit",
max_output_tokens: "outputTokenLimit",
};
const mismatch =
action === "remove"
? Boolean(after)
: !after ||
Object.entries(body).some(([key, value]) => {
if (["provider", "modelId"].includes(key)) return false;
const actual = after[mapping[key] || key];
if (Array.isArray(value)) return JSON.stringify(actual) !== JSON.stringify(value);
return value === null ? actual != null : actual !== value;
});
if (mismatch)
throw new ModelCommandError(
"Model readback did not confirm the requested change; inspect the server before retrying.",
1
);
return {
action,
persistenceVerified: true,
inferenceValidation: "not-run",
provider: body.provider,
modelId: body.modelId,
...(after ? { model: publicModel({ ...after, provider: body.provider }) } : {}),
};
}
export function modelMutationAction(action) {
return async (provider, modelId, options, cmd) => {
try {
const opts = { ...cmd.optsWithGlobals(), ...options };
emit(await modifyManualModel(action, provider, modelId, opts), {
...opts,
output: opts.output || "json",
});
} catch (error) {
console.error(error instanceof ModelCommandError ? error.message : "Model operation failed.");
process.exitCode = error.exitCode || 1;
}
};
}
export async function manualListAction(provider, options, cmd) {
const opts = { ...cmd.optsWithGlobals(), ...options };
try {
emit(
(await listManualModels(provider, opts))
.filter((model) => !model.source || model.source === "manual")
.map((model) => publicModel({ ...model, provider })),
{ ...opts, output: opts.output || "json" }
);
} catch (error) {
console.error(
error instanceof ModelCommandError ? error.message : "Unable to list manual models."
);
process.exitCode = error.exitCode || 1;
}
}

View File

@@ -1,92 +1,75 @@
import { apiFetch, isServerUp } from "../api.mjs";
import { emit } from "../output.mjs";
import { modelListSchema } from "../schemas/output-schemas.mjs";
import { t } from "../i18n.mjs";
import { loadModelCatalog } from "./model-api.mjs";
import { manualListAction, modelMutationAction } from "./model-crud.mjs";
export function registerModels(program) {
program
const models = program
.command("models [provider]")
.description(t("models.description"))
.option("--search <query>", t("models.search"))
.option("--json", "Output as JSON")
.action(async (provider, opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
const exitCode = await runModelsCommand(provider, { ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
process.exitCode = await runModelsCommand(provider, { ...cmd.optsWithGlobals(), ...opts });
});
models
.command("manual <provider>")
.description("List manual model metadata from the selected server")
.action(manualListAction);
models
.command("add <provider> <model-id>")
.description("Add an unverified manual model, then verify persistence")
.option("--name <name>", "Display name")
.option("--api-format <format>", "API format, e.g. chat-completions or responses")
.option("--context-window <tokens>", "Positive integer input/context limit")
.option("--max-output-tokens <tokens>", "Positive integer output limit")
.option("--dry-run", "Preview without writing or inference")
.action(modelMutationAction("add"));
models
.command("edit <provider> <model-id>")
.description("Edit manual model metadata, then verify persistence")
.option("--name <name>", "Display name")
.option("--api-format <format>", "API format, e.g. chat-completions or responses")
.option("--context-window <tokens>", "Positive integer context override")
.option("--clear-context-window", "Clear the manual context-window override")
.option("--dry-run", "Preview without writing or inference")
.action(modelMutationAction("edit"));
models
.command("remove <provider> <model-id>")
.description("Remove only a manual model override, then verify persistence")
.option("--yes", "Confirm removal of the manual override only")
.option("--dry-run", "Preview without writing or inference")
.action(modelMutationAction("remove"));
}
export async function runModelsCommand(provider, opts = {}) {
const serverUp = await isServerUp();
if (!serverUp) {
console.error(t("models.noServer"));
return 1;
}
let models = [];
try {
const res = await apiFetch("/api/models", { retry: false, timeout: 5000, acceptNotOk: true });
if (res.ok) {
const data = await res.json();
models = Array.isArray(data) ? data : data.models || [];
let models = await loadModelCatalog(opts);
if (provider) {
const filter = provider.toLowerCase();
models = models.filter(
(model) =>
model.provider.toLowerCase().includes(filter) || model.id.toLowerCase().startsWith(filter)
);
}
} catch {}
if (models.length === 0) {
try {
const res = await apiFetch("/api/v1/models", {
retry: false,
timeout: 5000,
acceptNotOk: true,
});
if (res.ok) {
const data = await res.json();
models = Array.isArray(data) ? data : data.data || [];
}
} catch {}
}
if (provider) {
const filter = provider.toLowerCase();
models = models.filter(
(m) =>
(m.provider && m.provider.toLowerCase().includes(filter)) ||
(m.id && m.id.toLowerCase().startsWith(filter)) ||
(m.name && m.name.toLowerCase().includes(filter))
);
}
if (opts.search) {
const search = opts.search.toLowerCase();
models = models.filter(
(m) =>
(m.id && m.id.toLowerCase().includes(search)) ||
(m.name && m.name.toLowerCase().includes(search)) ||
(m.provider && m.provider.toLowerCase().includes(search)) ||
(m.description && m.description.toLowerCase().includes(search))
);
}
if (models.length === 0) {
console.log(t("models.noModels"));
if (opts.search) {
const search = opts.search.toLowerCase();
models = models.filter((model) =>
[model.id, model.name, model.provider, model.description].some((value) =>
String(value || "")
.toLowerCase()
.includes(search)
)
);
}
const table = opts.output === "table" || (!opts.output && !opts.json && process.stdout.isTTY);
emit(table ? models.slice(0, 50) : models, opts, modelListSchema);
if (table && models.length > 50)
console.log(`... and ${models.length - 50} more. Use --output json for the full list.`);
return 0;
} catch (error) {
console.error(error.exitCode ? error.message : "Unable to read the model catalog.");
return error.exitCode || 1;
}
const normalized = models.map((m) => ({
id: m.id || m.name || "unknown",
provider: m.provider || "unknown",
contextWindow: String(m.context_length || m.max_tokens || m.contextWindow || "-"),
}));
const display = normalized.slice(0, 50);
emit(display, opts, modelListSchema);
if (models.length > 50) {
console.log(
`\x1b[2m ... and ${models.length - 50} more. Use --output json for full list.\x1b[0m`
);
}
return 0;
}

View File

@@ -0,0 +1 @@
- **feat(cli):** Add remote/context-aware manual model CRUD with metadata validation, dry-run, protected override removal and persistence readback; prefer the public model catalog and keep JSON/JSONL complete. Manual models remain inference-unverified. ([#14392](https://github.com/diegosouzapw/OmniRoute/pull/14392))

View File

@@ -0,0 +1,74 @@
---
title: "CLI model catalog and manual metadata"
version: "3.8.51"
lastUpdated: "2026-09-21"
---
# CLI model catalog and manual metadata
`omniroute models` reads the selected server's public model catalog first, including
its custom and synchronized entries. `--output json` and `--output jsonl` return all
matching rows, without a table truncation message. Interactive tables show at most
50 rows. JSON preserves public context limits, modalities, capabilities, model source
when supplied by the server, and API-format metadata; credentials and compatibility
headers are not output. A legacy `/api/models` fallback is used only when the public
endpoint returns HTTP 404, 405 or 501, never after an authentication failure.
`omniroute completion refresh` uses that same catalog. A failed model-catalog request
returns a nonzero exit code and preserves the previous completion cache. Bash, Zsh
and Fish completion scripts advertise the manual model subcommands.
## Manual model lifecycle
These operations use the existing authenticated `/api/provider-models` management
API. They honor the same global `--context` and `--base-url` selection as other CLI
commands. Use the canonical provider ID and the provider's model ID separately; a
model ID containing slashes is valid. Use a configured management context rather
than putting credentials into command arguments.
```bash
omniroute models openai --output json
omniroute models manual openai
omniroute models add openai my-model --name "My model" --context-window 32768 --dry-run
omniroute models add openai my-model --name "My model" --context-window 32768
omniroute models edit openai my-model --context-window 65536
omniroute models edit openai my-model --clear-context-window
omniroute models remove openai my-model --dry-run
omniroute models remove openai my-model --yes
```
Examples describe syntax; replace provider/model IDs with entries belonging to your
server. The add operation does not create a provider connection.
- `add` refuses an existing custom entry. Optional `--max-output-tokens` sets the
output limit at creation; `--api-format` sets the model's API format and its
matching endpoint metadata, such as `embeddings` rather than `chat`.
- `edit` requires an existing manual entry. Its context limit uses the backend's
independent manual context override; clearing it removes that override, not the
original input limit or synchronized metadata.
- `remove` requires `--yes`, except with `--dry-run`. It requests `resetOverride=true`,
preserving a synchronized entry with the same ID. Bulk deletion is not exposed.
- `--dry-run` performs the authenticated preflight read but no write or inference.
- Every actual mutation reads the manual catalog back. `persistenceVerified: true`
means the requested metadata was observed after the write; it does not mean the
model can generate text or call tools. `inferenceValidation` is always `not-run`.
- A readback failure returns a nonzero exit code. The write may already have taken
effect; inspect the server before retrying. Concurrent writers are not serialized
by this CLI, and the existing API does not provide a compare-and-swap transaction.
## Test & Add: not implemented
Manual entries are unverified. There is no `--validate` flag and these commands do
not issue inference requests or execute tools. A future opt-in validation flow must
separate inference credentials from management authentication and establish which
exact provider connection produced all three proofs: generation, a nonce-bound
synthetic tool call, and continuation after a synthetic tool result. No real tool
execution is needed for those proofs.
The existing `x-omniroute-connection` pin alone is insufficient as a proof receipt:
credential selection can intentionally release a pin for cooldown, quota or
excluded-account cases, and session affinity participates in selection. See
`src/sse/services/auth.ts` and the `forcedConnectionId` handling in
`src/sse/handlers/chat.ts`. Before implementing Test & Add, decide a strict-pin or
verifiable connection-receipt contract. Tests must then prove no persistence after
any failed, truncated, timed-out or wrong-connection response. This is a separate
backend contract decision, not a promise made by manual CRUD.

View File

@@ -29,3 +29,64 @@ omniroute --version
```bash
omniroute models [provider]
```
### `models manual <provider>`
List manual model metadata from the selected server
**Example:**
```bash
omniroute models manual <provider>
```
### `models add <provider> <model-id>`
Add an unverified manual model, then verify persistence
**Flags:**
- `--name <name>`
- `--api-format <format>`
- `--context-window <tokens>`
- `--max-output-tokens <tokens>`
- `--dry-run`
**Example:**
```bash
omniroute models add <provider> <model-id>
```
### `models edit <provider> <model-id>`
Edit manual model metadata, then verify persistence
**Flags:**
- `--name <name>`
- `--api-format <format>`
- `--context-window <tokens>`
- `--clear-context-window`
- `--dry-run`
**Example:**
```bash
omniroute models edit <provider> <model-id>
```
### `models remove <provider> <model-id>`
Remove only a manual model override, then verify persistence
**Flags:**
- `--yes`
- `--dry-run`
**Example:**
```bash
omniroute models remove <provider> <model-id>
```

View File

@@ -66,7 +66,7 @@ test("models --json returns 0 and prints JSON when server responds", async () =>
if (String(url).includes("/api/health")) {
return makeResponse({ status: "ok" });
}
if (String(url).includes("/api/models")) {
if (String(url).includes("/api/v1/models")) {
return makeResponse(mockModels);
}
throw new Error("unexpected URL: " + url);

View File

@@ -0,0 +1,155 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import test from "node:test";
import { modifyManualModel } from "../../../bin/cli/commands/model-crud.mjs";
type Model = Record<string, unknown>;
async function fixture(
run: (
opts: { baseUrl: string; cliToken: string; headers: { authorization: string } },
state: { models: Model[]; calls: string[]; ignoreWrite: boolean }
) => Promise<void>
) {
const state = { models: [] as Model[], calls: [] as string[], ignoreWrite: false };
const server = createServer(async (req, res) => {
state.calls.push(`${req.method} ${req.url}`);
let text = "";
for await (const chunk of req) text += chunk;
const body = text ? JSON.parse(text) : {};
if (!state.ignoreWrite && req.method === "POST")
state.models.push({
id: body.modelId,
name: body.modelName,
source: "manual",
inputTokenLimit: body.max_input_tokens,
outputTokenLimit: body.max_output_tokens,
apiFormat: body.apiFormat,
supportedEndpoints: body.supportedEndpoints,
});
if (!state.ignoreWrite && req.method === "PUT")
state.models = state.models.map((model) => ({
...model,
name: body.modelName ?? model.name,
apiFormat: body.apiFormat ?? model.apiFormat,
supportedEndpoints: body.supportedEndpoints ?? model.supportedEndpoints,
contextWindowOverride: Object.hasOwn(body, "contextWindowOverride")
? body.contextWindowOverride
: model.contextWindowOverride,
}));
if (!state.ignoreWrite && req.method === "DELETE") state.models = [];
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify(
req.method === "GET" ? { models: state.models } : { model: state.models[0], removed: true }
)
);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assert.ok(address && typeof address !== "string");
try {
await run(
{
baseUrl: `http://127.0.0.1:${address.port}`,
cliToken: "fixture",
headers: { authorization: "Bearer fixture-model-api-key" },
},
state
);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
test("manual model add, edit and remove use the selected server and verify readback", async () => {
await fixture(async (opts, state) => {
const added = await modifyManualModel("add", "custom", "model/x", {
...opts,
name: "Model",
contextWindow: "8192",
});
assert.equal(added.model.id, "model/x");
assert.equal(added.model.inputTokenLimit, 8192);
const edited = await modifyManualModel("edit", "custom", "model/x", {
...opts,
name: "Updated",
contextWindow: "4096",
});
assert.equal(edited.model.contextWindowOverride, 4096);
const removed = await modifyManualModel("remove", "custom", "model/x", { ...opts, yes: true });
assert.equal(removed.persistenceVerified, true);
assert.equal(added.inferenceValidation, "not-run");
assert.ok(
state.calls.includes(
"DELETE /api/provider-models?provider=custom&model=model%2Fx&resetOverride=true"
)
);
assert.equal(state.calls.filter((call) => call.startsWith("GET")).length, 6);
});
});
test("manual metadata retains output limits and clears only a context override", async () => {
await fixture(async (opts, state) => {
const added = await modifyManualModel("add", "custom", "m", {
...opts,
apiFormat: "responses",
contextWindow: "8192",
maxOutputTokens: "2048",
});
assert.equal(added.model.outputTokenLimit, 2048);
assert.equal(added.model.apiFormat, "responses");
await modifyManualModel("edit", "custom", "m", { ...opts, contextWindow: "4096" });
const cleared = await modifyManualModel("edit", "custom", "m", {
...opts,
clearContextWindow: true,
});
assert.equal(cleared.model.contextWindowOverride, null);
assert.equal(cleared.model.inputTokenLimit, 8192);
assert.equal(state.calls.filter((call) => call.startsWith("POST")).length, 1);
});
});
test("non-chat model format persists matching endpoint metadata", async () => {
await fixture(async (opts) => {
const added = await modifyManualModel("add", "custom", "embedding", {
...opts,
apiFormat: "embeddings",
});
assert.deepEqual(added.model.supportedEndpoints, ["embeddings"]);
const edited = await modifyManualModel("edit", "custom", "embedding", {
...opts,
apiFormat: "rerank",
});
assert.deepEqual(edited.model.supportedEndpoints, ["rerank"]);
});
});
test("manual mutations reject invalid limits, duplicates, synced rows and unconfirmed removal", async () => {
await fixture(async (opts, state) => {
await assert.rejects(
modifyManualModel("add", "custom", "m", { ...opts, contextWindow: "0" }),
/Invalid/
);
assert.equal(state.calls.length, 0);
state.models = [{ id: "m", source: "manual" }];
await assert.rejects(modifyManualModel("add", "custom", "m", opts), /already exists/);
await assert.rejects(modifyManualModel("remove", "custom", "m", opts), /--yes/);
state.models = [{ id: "m", source: "synced" }];
await assert.rejects(
modifyManualModel("edit", "custom", "m", { ...opts, name: "x" }),
/manual/
);
assert.ok(state.calls.every((call) => call.startsWith("GET")));
});
});
test("dry run makes no writes and a successful HTTP response without readback is not success", async () => {
await fixture(async (opts, state) => {
const plan = await modifyManualModel("add", "custom", "m", { ...opts, dryRun: true });
assert.equal(plan.dryRun, true);
assert.ok(state.calls.every((call) => call.startsWith("GET")));
state.ignoreWrite = true;
await assert.rejects(modifyManualModel("add", "custom", "m", opts), /readback/);
});
});

View File

@@ -0,0 +1,186 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import test from "node:test";
import { spawn } from "node:child_process";
async function withCatalog(
handler: (url: string) => { status?: number; body: unknown },
run: (baseUrl: string, requests: string[]) => Promise<void>
) {
const requests: string[] = [];
const server = createServer((req, res) => {
requests.push(req.url || "");
const result = handler(req.url || "");
res.writeHead(result.status ?? 200, { "content-type": "application/json" });
res.end(JSON.stringify(result.body));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assert.ok(address && typeof address !== "string");
const previousUrl = process.env.OMNIROUTE_BASE_URL;
process.env.OMNIROUTE_BASE_URL = `http://127.0.0.1:${address.port}`;
try {
await run(`http://127.0.0.1:${address.port}`, requests);
} finally {
if (previousUrl === undefined) delete process.env.OMNIROUTE_BASE_URL;
else process.env.OMNIROUTE_BASE_URL = previousUrl;
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
async function capture(opts: Record<string, unknown>, argv?: string[]) {
const moduleUrl = new URL("../../../bin/cli/commands/models.mjs", import.meta.url).href;
const child = spawn(
process.execPath,
[
"--input-type=module",
"--eval",
`const {runModelsCommand, registerModels} = await import(process.argv[1]); const args = JSON.parse(process.argv[3]); if (args) { const {Command} = await import('commander'); const program = new Command().option('--base-url <url>').option('--output <format>').option('--api-key <key>', '', 'fixture-model-api-key'); registerModels(program); await program.parseAsync(args, {from:'user'}); } else { process.exitCode = await runModelsCommand(undefined, JSON.parse(process.argv[2])); }`,
moduleUrl,
JSON.stringify({ ...opts, apiKey: "fixture-model-api-key" }),
JSON.stringify(argv ?? null),
],
{
env: {
...process.env,
OMNIROUTE_CLI_TOKEN: "fixture",
OMNIROUTE_API_KEY: "ambient-not-used",
OMNIROUTE_BASE_URL: "http://127.0.0.1:1",
},
}
);
let output = "";
let error = "";
child.stdout.on("data", (chunk) => {
output += chunk;
});
child.stderr.on("data", (chunk) => {
error += chunk;
});
const code = await new Promise<number | null>((resolve, reject) => {
child.once("error", reject);
child.once("close", resolve);
});
return { code, output: () => output, error };
}
test("models uses the selected remote public catalog, including manual metadata", async () => {
await withCatalog(
(url) => ({
body:
url === "/api/v1/models"
? {
data: [
{
id: "custom/manual",
owned_by: "custom",
context_length: 8192,
source: "manual",
supports_vision: false,
capabilities: { vision: false, tool_calling: true },
input_modalities: ["text"],
api_format: "responses",
apiKey: "never-print",
},
],
}
: { models: [{ id: "static-only" }] },
}),
async (baseUrl, requests) => {
const result = await capture({ baseUrl, json: true });
assert.equal(result.code, 0);
const rows = JSON.parse(result.output());
assert.equal(rows[0].id, "custom/manual");
assert.equal(rows[0].source, "manual");
assert.equal(rows[0].context_length, 8192);
assert.equal(rows[0].supports_vision, false);
assert.deepEqual(rows[0].capabilities, { vision: false, tool_calling: true });
assert.equal(rows[0].api_format, "responses");
assert.ok(!result.output().includes("never-print"));
assert.ok(!requests.includes("/api/models"));
}
);
});
test("models subcommands inherit global server options and support dry-run", async () => {
await withCatalog(
() => ({ body: { models: [] } }),
async (baseUrl, requests) => {
const result = await capture({}, [
"--base-url",
baseUrl,
"models",
"add",
"custom",
"new-model",
"--context-window",
"8192",
"--dry-run",
]);
assert.equal(result.code, 0, result.error);
assert.equal(JSON.parse(result.output()).changes.max_input_tokens, 8192);
assert.deepEqual(requests, ["/api/provider-models?provider=custom"]);
}
);
});
test("models falls back only when the public endpoint is unavailable", async () => {
await withCatalog(
(url) =>
url === "/api/v1/models"
? { status: 404, body: {} }
: { body: { models: [{ id: "legacy" }] } },
async (baseUrl, requests) => {
const result = await capture({ baseUrl, json: true });
assert.equal(result.code, 0);
assert.equal(JSON.parse(result.output())[0].id, "legacy");
assert.deepEqual(requests, ["/api/v1/models", "/api/models"]);
}
);
});
test("models JSON and JSONL include every row without human trailers", async () => {
const rows = Array.from({ length: 55 }, (_, index) => ({ id: `custom/m${index}` }));
await withCatalog(
() => ({ body: { data: rows } }),
async (baseUrl) => {
const json = await capture({ baseUrl, output: "json" });
assert.equal(json.code, 0);
assert.equal(JSON.parse(json.output()).length, 55);
const jsonl = await capture({ baseUrl, output: "jsonl" });
assert.equal(jsonl.code, 0);
assert.equal(
jsonl
.output()
.trim()
.split("\n")
.map((line) => JSON.parse(line)).length,
55
);
}
);
});
test("models returns an empty structured catalog rather than prose", async () => {
await withCatalog(
() => ({ body: { data: [] } }),
async (baseUrl) => {
const result = await capture({ baseUrl, json: true });
assert.equal(result.code, 0);
assert.deepEqual(JSON.parse(result.output()), []);
}
);
});
test("models does not hide authentication failures behind a fallback catalog", async () => {
await withCatalog(
() => ({ status: 401, body: { error: "private token=do-not-print" } }),
async (baseUrl, requests) => {
const result = await capture({ baseUrl, json: true });
assert.equal(result.code, 4);
assert.deepEqual(requests, ["/api/v1/models"]);
assert.ok(!result.output().includes("do-not-print"));
}
);
});

View File

@@ -0,0 +1,128 @@
import assert from "node:assert/strict";
import { execFileSync, spawn } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
async function invoke(args: string[], dataDir: string) {
const moduleUrl = new URL("../../../bin/cli/commands/completion.mjs", import.meta.url).href;
const modelsUrl = new URL("../../../bin/cli/commands/models.mjs", import.meta.url).href;
const child = spawn(
process.execPath,
[
"--input-type=module",
"--eval",
`const {Command}=await import('commander'); const {registerCompletion}=await import(process.argv[1]); const {registerModels}=await import(process.argv[3]); const program=new Command().option('--base-url <url>').option('--api-key <key>', '', 'fixture-model-api-key'); registerCompletion(program); registerModels(program); await program.parseAsync(JSON.parse(process.argv[2]),{from:'user'});`,
moduleUrl,
JSON.stringify(args),
modelsUrl,
],
{
env: {
...process.env,
DATA_DIR: dataDir,
HOME: dataDir,
OMNIROUTE_CLI_TOKEN: "fixture",
OMNIROUTE_API_KEY: "ambient-not-used",
OMNIROUTE_BASE_URL: "http://127.0.0.1:1",
},
}
);
let out = "";
let err = "";
child.stdout.on("data", (chunk) => {
out += chunk;
});
child.stderr.on("data", (chunk) => {
err += chunk;
});
const code = await new Promise<number | null>((resolve, reject) => {
child.once("close", resolve);
child.once("error", reject);
});
return { out, err, code };
}
for (const status of [200, 401])
test(`model completion refresh preserves catalog/auth semantics (${status})`, async () => {
const dataDir = mkdtempSync(join(tmpdir(), "omniroute-model-completion-"));
const cache = join(dataDir, "completion-cache.json");
const previous = JSON.stringify({ models: ["keep-on-failure"], ts: 123 });
writeFileSync(cache, previous);
const requests: string[] = [];
const server = createServer((req, res) => {
requests.push(req.url || "");
const models = req.url?.includes("models");
res.writeHead(models ? status : 200, { "content-type": "application/json" });
res.end(
JSON.stringify(
models ? { data: [{ id: "manual/model" }], error: "secret-do-not-print" } : {}
)
);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assert.ok(address && typeof address !== "string");
try {
const result = await invoke(
["--base-url", `http://127.0.0.1:${address.port}`, "completion", "refresh", "--quiet"],
dataDir
);
assert.equal(result.code, status === 200 ? 0 : 4, result.err);
assert.ok(requests.includes("/api/v1/models"));
assert.ok(!requests.includes("/api/models"));
assert.ok(!result.err.includes("secret-do-not-print"));
if (status === 200)
assert.deepEqual(JSON.parse(readFileSync(cache, "utf8")).models, ["manual/model"]);
else assert.equal(readFileSync(cache, "utf8"), previous);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
rmSync(dataDir, { recursive: true, force: true });
}
});
test("all shell completion scripts advertise manual model commands", async () => {
const dataDir = mkdtempSync(join(tmpdir(), "omniroute-model-shell-"));
try {
for (const shell of ["bash", "zsh", "fish"]) {
const result = await invoke(["completion", shell], dataDir);
assert.equal(result.code, 0);
assert.ok(result.out.includes("manual add edit remove"), shell);
const branch =
shell === "bash"
? /^\s*models\).*COMPREPLY/gm
: shell === "zsh"
? /^\s*models\).*_arguments/gm
: /^complete .*__fish_seen_subcommand_from models'/gm;
assert.equal([...result.out.matchAll(branch)].length, 1, `${shell} has one model branch`);
if (shell === "bash" && process.platform !== "win32") {
const output = execFileSync("/bin/bash", ["--noprofile", "--norc"], {
input:
result.out +
'\nCOMP_WORDS=(omniroute models "")\nCOMP_CWORD=2\n_omniroute\nprintf "%s\\n" "${COMPREPLY[@]}"\n',
encoding: "utf8",
timeout: 5000,
env: { PATH: "/usr/bin:/bin" },
});
assert.deepEqual(output.trim().split("\n"), ["manual", "add", "edit", "remove"]);
}
}
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
});
test("installed completion includes models registered after completion", async () => {
const dataDir = mkdtempSync(join(tmpdir(), "omniroute-model-install-"));
try {
const result = await invoke(["completion", "install", "bash"], dataDir);
assert.equal(result.code, 0, result.err);
const script = readFileSync(join(dataDir, ".bash_completion.d", "omniroute"), "utf8");
assert.match(script, /manual add edit remove/);
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
});