mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 23:02:18 +03:00
Compare commits
3 Commits
fix/relay-
...
feat/relay
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fe056e61c | ||
|
|
fd853aede9 | ||
|
|
da1cd3522d |
@@ -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;
|
||||
}
|
||||
|
||||
131
bin/cli/commands/model-api.mjs
Normal file
131
bin/cli/commands/model-api.mjs
Normal 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;
|
||||
}
|
||||
174
bin/cli/commands/model-crud.mjs
Normal file
174
bin/cli/commands/model-crud.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
1
changelog.d/features/14392-manual-models-cli.md
Normal file
1
changelog.d/features/14392-manual-models-cli.md
Normal 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))
|
||||
74
docs/guides/CLI-MANUAL-MODELS.md
Normal file
74
docs/guides/CLI-MANUAL-MODELS.md
Normal 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.
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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);
|
||||
|
||||
155
tests/unit/cli/model-crud.test.ts
Normal file
155
tests/unit/cli/model-crud.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
186
tests/unit/cli/models-catalog.test.ts
Normal file
186
tests/unit/cli/models-catalog.test.ts
Normal 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"));
|
||||
}
|
||||
);
|
||||
});
|
||||
128
tests/unit/cli/models-completion.test.ts
Normal file
128
tests/unit/cli/models-completion.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user