mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 23:02:18 +03:00
Compare commits
4 Commits
feat/relay
...
fix/relay-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b33f306059 | ||
|
|
1a325166bf | ||
|
|
f6e30a261e | ||
|
|
88c3a880f5 |
@@ -5,7 +5,6 @@ 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.
|
||||
@@ -31,14 +30,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 = [];
|
||||
providers = [],
|
||||
models = [];
|
||||
try {
|
||||
const [cr, pr] = await Promise.allSettled([
|
||||
const [cr, pr, mr] = 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();
|
||||
@@ -48,6 +47,10 @@ 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);
|
||||
@@ -79,12 +82,7 @@ function installPath(shell) {
|
||||
return join(home, ".bash_completion.d", "omniroute");
|
||||
}
|
||||
|
||||
function modelSubcommandWords(program) {
|
||||
const models = program?.commands.find((command) => command.name() === "models");
|
||||
return models?.commands.map((command) => command.name()).join(" ") || "";
|
||||
}
|
||||
|
||||
function generateZshScript(modelCommands) {
|
||||
function generateZshScript() {
|
||||
return `#compdef omniroute
|
||||
|
||||
# OmniRoute zsh completion (dynamic)
|
||||
@@ -181,7 +179,6 @@ _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})' ;;
|
||||
@@ -207,7 +204,7 @@ compdef _omniroute omniroute
|
||||
`;
|
||||
}
|
||||
|
||||
function generateBashScript(modelCommands) {
|
||||
function generateBashScript() {
|
||||
return `#!/bin/bash
|
||||
# OmniRoute CLI bash completion (dynamic)
|
||||
|
||||
@@ -238,7 +235,6 @@ _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 ;;
|
||||
@@ -266,7 +262,7 @@ complete -F _omniroute omniroute
|
||||
`;
|
||||
}
|
||||
|
||||
function generateFishScript(modelCommands) {
|
||||
function generateFishScript() {
|
||||
return `# OmniRoute CLI fish completion (dynamic)
|
||||
complete -c omniroute -f
|
||||
|
||||
@@ -281,7 +277,6 @@ 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'
|
||||
@@ -320,17 +315,17 @@ export function registerCompletion(program) {
|
||||
comp
|
||||
.command("zsh")
|
||||
.description(t("completion.zsh") || "Print zsh completion script")
|
||||
.action(async () => process.stdout.write(generateZshScript(modelSubcommandWords(program))));
|
||||
.action(async () => process.stdout.write(generateZshScript()));
|
||||
|
||||
comp
|
||||
.command("bash")
|
||||
.description(t("completion.bash") || "Print bash completion script")
|
||||
.action(async () => process.stdout.write(generateBashScript(modelSubcommandWords(program))));
|
||||
.action(async () => process.stdout.write(generateBashScript()));
|
||||
|
||||
comp
|
||||
.command("fish")
|
||||
.description(t("completion.fish") || "Print fish completion script")
|
||||
.action(async () => process.stdout.write(generateFishScript(modelSubcommandWords(program))));
|
||||
.action(async () => process.stdout.write(generateFishScript()));
|
||||
|
||||
comp
|
||||
.command("install [shell]")
|
||||
@@ -344,7 +339,7 @@ export function registerCompletion(program) {
|
||||
}
|
||||
const dest = installPath(target);
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
writeFileSync(dest, gen(modelSubcommandWords(program)));
|
||||
writeFileSync(dest, gen());
|
||||
process.stdout.write(
|
||||
`Installed ${target} completion at ${dest}\nRestart your shell or source the file.\n`
|
||||
);
|
||||
@@ -356,18 +351,7 @@ export function registerCompletion(program) {
|
||||
.option("--quiet", "Suppress output")
|
||||
.action(async (opts, cmd) => {
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
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;
|
||||
}
|
||||
const data = await refreshCache(globalOpts);
|
||||
if (!opts.quiet && !globalOpts.quiet) {
|
||||
process.stdout.write(
|
||||
`Cached: ${data.combos.length} combos, ${data.providers.length} providers, ${data.models.length} models\n`
|
||||
@@ -386,17 +370,17 @@ export function registerCompletion(program) {
|
||||
process.stderr.write(`Unknown shell: ${shell}. Valid: bash, zsh, fish\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(gen(modelSubcommandWords(program)));
|
||||
process.stdout.write(gen());
|
||||
});
|
||||
}
|
||||
|
||||
// Legacy export for backward compatibility
|
||||
export async function runCompletionCommand(shell, program) {
|
||||
export async function runCompletionCommand(shell) {
|
||||
const gen = generators[shell];
|
||||
if (!gen) {
|
||||
process.stderr.write(`Unknown shell: ${shell}. Valid: bash, zsh, fish\n`);
|
||||
return 1;
|
||||
}
|
||||
process.stdout.write(gen(modelSubcommandWords(program)));
|
||||
process.stdout.write(gen());
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
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,75 +1,92 @@
|
||||
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) {
|
||||
const models = program
|
||||
program
|
||||
.command("models [provider]")
|
||||
.description(t("models.description"))
|
||||
.option("--search <query>", t("models.search"))
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (provider, opts, cmd) => {
|
||||
process.exitCode = await runModelsCommand(provider, { ...cmd.optsWithGlobals(), ...opts });
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const exitCode = await runModelsCommand(provider, { ...opts, output: globalOpts.output });
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
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 = {}) {
|
||||
try {
|
||||
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)
|
||||
);
|
||||
}
|
||||
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 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 || [];
|
||||
}
|
||||
} 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"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
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 +0,0 @@
|
||||
- **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))
|
||||
1
changelog.d/fixes/14390-relay-opencode-session.md
Normal file
1
changelog.d/fixes/14390-relay-opencode-session.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(opencode): preserve explicit native and Claude conversation IDs before request translation, keeping the existing canonical session shape and fingerprint fallback. ([#14390](https://github.com/diegosouzapw/OmniRoute/pull/14390))
|
||||
@@ -1,74 +0,0 @@
|
||||
---
|
||||
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
docs/reference/OPENCODE-SESSION-IDENTITY.md
Normal file
29
docs/reference/OPENCODE-SESSION-IDENTITY.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: "OpenCode conversation identity"
|
||||
version: 3.8.51
|
||||
lastUpdated: 2026-09-21
|
||||
---
|
||||
|
||||
# OpenCode conversation identity
|
||||
|
||||
OpenCode and OpenCode Go reuse explicit conversation IDs across turns. The
|
||||
resolver checks `x-opencode-session`, existing affinity/session headers, native
|
||||
CLI session/thread headers, request metadata, and then top-level session/thread
|
||||
fields. Claude's JSON-encoded `metadata.user_id` is inspected for a session ID;
|
||||
an opaque account/user ID alone is never treated as a conversation ID.
|
||||
|
||||
Values containing control characters, empty IDs and IDs longer than 256
|
||||
characters are ignored. JSON metadata parsing is bounded. Before translation,
|
||||
the executor header normalizer preserves this identity only for the OpenCode
|
||||
providers; it does not inject OpenCode headers for other providers.
|
||||
|
||||
The existing canonical `ses_` encoding and conversation fingerprint fallback
|
||||
remain unchanged. With no explicit ID, the first user message and other
|
||||
existing fingerprint inputs continue to determine continuity. This is not a
|
||||
new authorization boundary or proof of upstream content isolation.
|
||||
|
||||
The implementation lives in `open-sse/utils/opencodeSessionIdentity.ts`,
|
||||
`open-sse/utils/opencodeHeaders.ts` and
|
||||
`open-sse/handlers/chatCore/executorClientHeaders.ts`. Regression tests cover
|
||||
native IDs, Claude metadata, precedence, invalid values and the existing
|
||||
fingerprint behavior without paid upstream calls.
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
forwardOpencodeClientHeaders,
|
||||
resolveOpencodeCliDefaults,
|
||||
} from "../utils/opencodeHeaders.ts";
|
||||
import { projectOpencodeSessionBody } from "../utils/opencodeSessionIdentity.ts";
|
||||
import {
|
||||
type AccountProxyConfig,
|
||||
type RotatableAccount,
|
||||
@@ -29,7 +30,12 @@ import {
|
||||
isEmptyUpstreamRejection,
|
||||
extractChatcmplId,
|
||||
} from "./accountRotation.ts";
|
||||
import { markCooldown, markOutcome, markSuccess, noteResponseServed } from "./opencodeAccountHealth.ts";
|
||||
import {
|
||||
markCooldown,
|
||||
markOutcome,
|
||||
markSuccess,
|
||||
noteResponseServed,
|
||||
} from "./opencodeAccountHealth.ts";
|
||||
import {
|
||||
isOpencodeFreeTierRefusal,
|
||||
isOpencodeGeoBlocked,
|
||||
@@ -1065,30 +1071,12 @@ export class OpencodeExecutor extends BaseExecutor {
|
||||
gatedScope
|
||||
);
|
||||
|
||||
this._clientSession = clientSuppliedOpencodeSession(clientHeaders);
|
||||
this._clientSession = clientSuppliedOpencodeSession(clientHeaders, body);
|
||||
if (clientHeaders || cliDefaults) {
|
||||
const b = body && typeof body === "object" ? (body as Record<string, unknown>) : null;
|
||||
forwardOpencodeClientHeaders(headers, clientHeaders ?? {}, {
|
||||
synthesizeRequestId: true,
|
||||
cliDefaults,
|
||||
sessionBody: b
|
||||
? {
|
||||
model: typeof b.model === "string" ? b.model : undefined,
|
||||
system: b.system,
|
||||
messages: Array.isArray(b.messages)
|
||||
? (b.messages as Array<{ role?: string; content?: unknown }>)
|
||||
: undefined,
|
||||
// The Responses surface carries the conversation under `input`; without it the
|
||||
// fingerprint collapses to the model alone and every conversation on that model
|
||||
// would share one upstream session.
|
||||
input: Array.isArray(b.input)
|
||||
? (b.input as Array<{ role?: string; content?: unknown }>)
|
||||
: undefined,
|
||||
tools: Array.isArray(b.tools)
|
||||
? (b.tools as Array<{ name?: string; function?: { name?: string } }>)
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
sessionBody: projectOpencodeSessionBody(body),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -512,6 +512,8 @@ export async function handleChatCore({
|
||||
defaultThinkingEffort,
|
||||
});
|
||||
let { provider, model, extendedContext } = modelInfo;
|
||||
const getExecutorClientHeaders = () =>
|
||||
buildExecutorClientHeaders(clientRawRequest?.headers, userAgent, { provider, body });
|
||||
// Keep the selected rule across format conversion, retries and refreshed credentials.
|
||||
// Each combo leg gets its own execution context; nothing is written to shared accounts.
|
||||
const reasoningRuleDirective = body?._omnirouteReasoningRule;
|
||||
@@ -3198,10 +3200,7 @@ export async function handleChatCore({
|
||||
log,
|
||||
extendedContext,
|
||||
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
|
||||
clientHeaders: buildExecutorClientHeaders(
|
||||
clientRawRequest?.headers,
|
||||
userAgent
|
||||
),
|
||||
clientHeaders: getExecutorClientHeaders(),
|
||||
clientResponseFormat,
|
||||
onCredentialsRefreshed,
|
||||
skipUpstreamRetry,
|
||||
@@ -3385,10 +3384,7 @@ export async function handleChatCore({
|
||||
log,
|
||||
extendedContext,
|
||||
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
|
||||
clientHeaders: buildExecutorClientHeaders(
|
||||
clientRawRequest?.headers,
|
||||
userAgent
|
||||
),
|
||||
clientHeaders: getExecutorClientHeaders(),
|
||||
clientResponseFormat,
|
||||
onCredentialsRefreshed,
|
||||
skipUpstreamRetry,
|
||||
@@ -4499,7 +4495,7 @@ export async function handleChatCore({
|
||||
log,
|
||||
extendedContext,
|
||||
upstreamExtraHeaders: buildUpstreamHeadersForExecute(retryModelId),
|
||||
clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent),
|
||||
clientHeaders: getExecutorClientHeaders(),
|
||||
clientResponseFormat,
|
||||
onCredentialsRefreshed,
|
||||
skipUpstreamRetry: isCombo,
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
* Pure helper extracted from chatCore: normalizes a Headers instance or a plain header object into a
|
||||
* lowercased-tolerant Record<string,string>, and backfills the client User-Agent (both casings) when
|
||||
* one is supplied and not already present. Returns null when nothing was collected. Side-effect-free;
|
||||
* behaviour is byte-identical to the previous module-level function.
|
||||
* OpenCode additionally retains explicit conversation identity before body translation.
|
||||
*/
|
||||
import { preserveOpencodeSessionIdentity } from "../../utils/opencodeSessionIdentity.ts";
|
||||
|
||||
export function buildExecutorClientHeaders(
|
||||
headers: Headers | Record<string, unknown> | null | undefined,
|
||||
userAgent?: string | null
|
||||
userAgent?: string | null,
|
||||
request?: { provider?: string; body?: unknown }
|
||||
) {
|
||||
const normalized: Record<string, string> = {};
|
||||
const isLeaseControlHeader = (key: string) => {
|
||||
@@ -38,5 +40,6 @@ export function buildExecutorClientHeaders(
|
||||
normalized["User-Agent"] = normalizedUserAgent;
|
||||
}
|
||||
|
||||
preserveOpencodeSessionIdentity(normalized, request);
|
||||
return Object.keys(normalized).length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createHash, randomBytes, randomUUID } from "crypto";
|
||||
import { setUserAgentHeader } from "../executors/base.ts";
|
||||
import { generateSessionId } from "../services/sessionManager.ts";
|
||||
import {
|
||||
resolveOpencodeSessionIdentity,
|
||||
type OpencodeSessionBody,
|
||||
} from "./opencodeSessionIdentity.ts";
|
||||
|
||||
/**
|
||||
* Default synthesized User-Agent. The upstream only parses the version, so this literal
|
||||
@@ -35,13 +39,10 @@ export function satisfiesOpencodeUserAgentContract(userAgent: string | null | un
|
||||
* follows it differ — including in their tool list, which is the very thing being joined.
|
||||
*/
|
||||
export function clientSuppliedOpencodeSession(
|
||||
clientHeaders: Record<string, string> | null | undefined
|
||||
clientHeaders: Record<string, string> | null | undefined,
|
||||
body?: unknown
|
||||
): string | undefined {
|
||||
if (!clientHeaders) return undefined;
|
||||
const value =
|
||||
findHeader(clientHeaders, "x-opencode-session") ?? findHeader(clientHeaders, "x-session-id");
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
return resolveOpencodeSessionIdentity(clientHeaders, body);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,13 +156,7 @@ export function forwardOpencodeClientHeaders(
|
||||
options?: {
|
||||
synthesizeRequestId?: boolean;
|
||||
cliDefaults?: { userAgent: string; client: string; project: string };
|
||||
sessionBody?: {
|
||||
model?: string;
|
||||
system?: unknown;
|
||||
messages?: Array<{ role?: string; content?: unknown }>;
|
||||
input?: Array<{ role?: string; content?: unknown }>;
|
||||
tools?: Array<{ name?: string; function?: { name?: string } }>;
|
||||
};
|
||||
sessionBody?: OpencodeSessionBody;
|
||||
}
|
||||
): void {
|
||||
// 1. Forward User-Agent
|
||||
@@ -187,19 +182,8 @@ export function forwardOpencodeClientHeaders(
|
||||
}
|
||||
|
||||
// 3. OpencodeExecutor-only: synthesize session/request id from fallback headers
|
||||
if (options?.synthesizeRequestId && !headers["x-opencode-session"]) {
|
||||
const sessionAffinity =
|
||||
findHeader(clientHeaders, "x-session-affinity") || findHeader(clientHeaders, "x-session-id");
|
||||
if (sessionAffinity) {
|
||||
// Kept as-is here. When identity synthesis is on, applyCliDefaults renders it in the
|
||||
// canonical shape below; with the synthesis opted out this path stays byte-identical
|
||||
// to before, since opting out means no fabricated identity at all.
|
||||
headers["x-opencode-session"] = sessionAffinity;
|
||||
|
||||
if (!headers["x-opencode-request"]) {
|
||||
headers["x-opencode-request"] = randomUUID();
|
||||
}
|
||||
}
|
||||
if (options?.synthesizeRequestId || options?.cliDefaults) {
|
||||
applySessionFallback(headers, clientHeaders, options.sessionBody);
|
||||
}
|
||||
|
||||
// 4. OpencodeExecutor-only: synthesize the OpenCode CLI identity Cloudflare expects
|
||||
@@ -209,6 +193,21 @@ export function forwardOpencodeClientHeaders(
|
||||
}
|
||||
}
|
||||
|
||||
/** Fill missing session/request identity without changing the CLI synthesis policy. */
|
||||
function applySessionFallback(
|
||||
headers: Record<string, string>,
|
||||
clientHeaders: Record<string, string>,
|
||||
sessionBody?: OpencodeSessionBody
|
||||
): void {
|
||||
if (headers["x-opencode-session"]) return;
|
||||
const sessionAffinity = resolveOpencodeSessionIdentity(clientHeaders, sessionBody);
|
||||
if (!sessionAffinity) return;
|
||||
// Keep the caller's identity as-is when CLI synthesis is disabled; applyCliDefaults
|
||||
// renders it in the canonical shape only when that policy is enabled.
|
||||
headers["x-opencode-session"] = sessionAffinity;
|
||||
headers["x-opencode-request"] ||= randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the OpenCode CLI identity headers Cloudflare requires on VPS egress. For
|
||||
* x-opencode-* headers, client values always win (defaults only fill gaps). The
|
||||
@@ -221,13 +220,7 @@ export function forwardOpencodeClientHeaders(
|
||||
function applyCliDefaults(
|
||||
headers: Record<string, string>,
|
||||
cliDefaults: { userAgent: string; client: string; project: string },
|
||||
sessionBody?: {
|
||||
model?: string;
|
||||
system?: unknown;
|
||||
messages?: Array<{ role?: string; content?: unknown }>;
|
||||
input?: Array<{ role?: string; content?: unknown }>;
|
||||
tools?: Array<{ name?: string; function?: { name?: string } }>;
|
||||
}
|
||||
sessionBody?: OpencodeSessionBody
|
||||
): void {
|
||||
// A client User-Agent is kept only when it already satisfies the upstream contract.
|
||||
// The previous rule kept anything starting with `opencode-cli/`, which carries no
|
||||
|
||||
105
open-sse/utils/opencodeSessionIdentity.ts
Normal file
105
open-sse/utils/opencodeSessionIdentity.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/** Explicit conversation identity, before the existing fingerprint fallback. */
|
||||
const HEADER_NAMES = [
|
||||
"x-opencode-session",
|
||||
"x-session-affinity",
|
||||
"x-session-id",
|
||||
"x-claude-code-session-id",
|
||||
"session_id",
|
||||
"session-id",
|
||||
"x-session_id",
|
||||
"thread_id",
|
||||
"thread-id",
|
||||
"x-thread-id",
|
||||
] as const;
|
||||
const BODY_NAMES = [
|
||||
"session_id",
|
||||
"sessionId",
|
||||
"thread_id",
|
||||
"threadId",
|
||||
"conversation_id",
|
||||
"conversationId",
|
||||
];
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function identity(value: unknown): string | undefined {
|
||||
if (typeof value !== "string" || value.length > 256 || /[\u0000-\u001f\u007f]/.test(value))
|
||||
return undefined;
|
||||
return value.trim() || undefined;
|
||||
}
|
||||
|
||||
function bodyIdentity(value: unknown): string | undefined {
|
||||
const input = record(value);
|
||||
if (!input) return undefined;
|
||||
for (const key of BODY_NAMES) {
|
||||
const id = identity(input[key]);
|
||||
if (id) return id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function claudeIdentity(value: unknown): string | undefined {
|
||||
if (typeof value !== "string" || value.length > 4096) return undefined;
|
||||
try {
|
||||
return bodyIdentity(JSON.parse(value));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Headers win over metadata; an opaque user/account ID is never a session ID. */
|
||||
export function resolveOpencodeSessionIdentity(
|
||||
headers: Record<string, string> | null | undefined,
|
||||
body?: unknown
|
||||
): string | undefined {
|
||||
const normalized = new Map(
|
||||
Object.entries(headers || {}).map(([key, value]) => [key.toLowerCase(), value])
|
||||
);
|
||||
for (const name of HEADER_NAMES) {
|
||||
const id = identity(normalized.get(name));
|
||||
if (id) return id;
|
||||
}
|
||||
const input = record(body);
|
||||
const metadata = record(input?.metadata);
|
||||
return bodyIdentity(metadata) || claudeIdentity(metadata?.user_id) || bodyIdentity(input);
|
||||
}
|
||||
|
||||
export function preserveOpencodeSessionIdentity(
|
||||
headers: Record<string, string>,
|
||||
request?: { provider?: string; body?: unknown }
|
||||
): void {
|
||||
if (request?.provider !== "opencode" && request?.provider !== "opencode-go") return;
|
||||
const sessionId = resolveOpencodeSessionIdentity(headers, request.body);
|
||||
if (sessionId) headers["x-opencode-session"] = sessionId;
|
||||
}
|
||||
|
||||
export interface OpencodeSessionBody {
|
||||
model?: string;
|
||||
system?: unknown;
|
||||
messages?: Array<{ role?: string; content?: unknown }>;
|
||||
input?: Array<{ role?: string; content?: unknown }>;
|
||||
tools?: Array<{ name?: string; function?: { name?: string } }>;
|
||||
metadata?: unknown;
|
||||
session_id?: unknown;
|
||||
thread_id?: unknown;
|
||||
}
|
||||
|
||||
/** Keep only fingerprint/identity inputs; never add this projection to an upstream body. */
|
||||
export function projectOpencodeSessionBody(body: unknown): OpencodeSessionBody | undefined {
|
||||
const input = record(body);
|
||||
if (!input) return undefined;
|
||||
return {
|
||||
model: typeof input.model === "string" ? input.model : undefined,
|
||||
system: input.system,
|
||||
messages: Array.isArray(input.messages) ? input.messages : undefined,
|
||||
input: Array.isArray(input.input) ? input.input : undefined,
|
||||
tools: Array.isArray(input.tools) ? input.tools : undefined,
|
||||
metadata: input.metadata,
|
||||
session_id: input.session_id,
|
||||
thread_id: input.thread_id,
|
||||
};
|
||||
}
|
||||
@@ -29,64 +29,3 @@ 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/v1/models")) {
|
||||
if (String(url).includes("/api/models")) {
|
||||
return makeResponse(mockModels);
|
||||
}
|
||||
throw new Error("unexpected URL: " + url);
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
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"));
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
});
|
||||
107
tests/unit/opencode-native-session-identity.test.ts
Normal file
107
tests/unit/opencode-native-session-identity.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
clientSuppliedOpencodeSession,
|
||||
forwardOpencodeClientHeaders,
|
||||
} from "../../open-sse/utils/opencodeHeaders.ts";
|
||||
import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts";
|
||||
|
||||
const defaults = { userAgent: "opencode/1.18.31", client: "desktop", project: "global" };
|
||||
const body = { model: "big-pickle", messages: [{ role: "user", content: "same prompt" }] };
|
||||
|
||||
function session(headers: Record<string, string>) {
|
||||
const output: Record<string, string> = {};
|
||||
forwardOpencodeClientHeaders(output, headers, {
|
||||
synthesizeRequestId: true,
|
||||
cliDefaults: defaults,
|
||||
sessionBody: body,
|
||||
});
|
||||
return output["x-opencode-session"];
|
||||
}
|
||||
|
||||
for (const header of ["session_id", "thread_id", "x-claude-code-session-id"]) {
|
||||
test(`${header} separates conversations with identical prompts`, () => {
|
||||
assert.notEqual(
|
||||
session({ [header]: "conversation-a" }),
|
||||
session({ [header]: "conversation-b" })
|
||||
);
|
||||
assert.equal(session({ [header]: "conversation-a" }), session({ [header]: "conversation-a" }));
|
||||
assert.equal(clientSuppliedOpencodeSession({ [header]: "conversation-a" }), "conversation-a");
|
||||
});
|
||||
}
|
||||
|
||||
test("explicit OpenCode session wins over native aliases", () => {
|
||||
assert.equal(
|
||||
session({ "x-opencode-session": "explicit", thread_id: "other" }),
|
||||
session({ "x-opencode-session": "explicit" })
|
||||
);
|
||||
});
|
||||
|
||||
test("executor carries Claude metadata identity to upstream and tool cache", () => {
|
||||
const executor = new OpencodeExecutor("opencode-go");
|
||||
const build = (id: string) =>
|
||||
executor.buildHeaders(
|
||||
null,
|
||||
true,
|
||||
null,
|
||||
body.model,
|
||||
{},
|
||||
{
|
||||
...body,
|
||||
metadata: { user_id: JSON.stringify({ session_id: id }) },
|
||||
}
|
||||
);
|
||||
assert.notEqual(
|
||||
build("conversation-a")["x-opencode-session"],
|
||||
build("conversation-b")["x-opencode-session"]
|
||||
);
|
||||
assert.equal(executor._clientSession, "conversation-b");
|
||||
});
|
||||
|
||||
test("native aliases never add OpenCode headers to a generic forwarding call", () => {
|
||||
const output: Record<string, string> = {};
|
||||
forwardOpencodeClientHeaders(output, { thread_id: "conversation-a" });
|
||||
assert.equal(output["x-opencode-session"], undefined);
|
||||
});
|
||||
|
||||
test("untrusted native IDs reject controls and excessive length", () => {
|
||||
for (const id of ["bad\nheader", "bad\u0000header", "x".repeat(257)]) {
|
||||
assert.equal(clientSuppliedOpencodeSession({ thread_id: id }), undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("native fallback preserves raw identity and an existing request when CLI synthesis is off", () => {
|
||||
const output: Record<string, string> = {};
|
||||
forwardOpencodeClientHeaders(
|
||||
output,
|
||||
{ thread_id: "native-conversation", "x-opencode-request": "existing-request" },
|
||||
{ synthesizeRequestId: true }
|
||||
);
|
||||
assert.equal(output["x-opencode-session"], "native-conversation");
|
||||
assert.equal(output["x-opencode-request"], "existing-request");
|
||||
});
|
||||
|
||||
test("an existing outbound session bypasses fallback request synthesis", () => {
|
||||
const output = { "x-opencode-session": "existing-session" } as Record<string, string>;
|
||||
forwardOpencodeClientHeaders(
|
||||
output,
|
||||
{ thread_id: "native-conversation" },
|
||||
{
|
||||
synthesizeRequestId: true,
|
||||
}
|
||||
);
|
||||
assert.deepEqual(output, { "x-opencode-session": "existing-session" });
|
||||
});
|
||||
|
||||
test("invalid body identity does not synthesize a request without CLI defaults", () => {
|
||||
const output: Record<string, string> = {};
|
||||
forwardOpencodeClientHeaders(
|
||||
output,
|
||||
{},
|
||||
{
|
||||
synthesizeRequestId: true,
|
||||
sessionBody: { metadata: { session_id: "bad\nidentity" } },
|
||||
}
|
||||
);
|
||||
assert.deepEqual(output, {});
|
||||
});
|
||||
69
tests/unit/opencode-session-before-translation.test.ts
Normal file
69
tests/unit/opencode-session-before-translation.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildExecutorClientHeaders } from "../../open-sse/handlers/chatCore/executorClientHeaders.ts";
|
||||
import { resolveOpencodeSessionIdentity } from "../../open-sse/utils/opencodeSessionIdentity.ts";
|
||||
|
||||
test("original Claude metadata survives executor header normalization before translation", () => {
|
||||
const body = { metadata: { user_id: JSON.stringify({ session_id: "claude-conversation" }) } };
|
||||
const result = buildExecutorClientHeaders({}, "claude-cli", { provider: "opencode-go", body });
|
||||
assert.equal(result?.["x-opencode-session"], "claude-conversation");
|
||||
});
|
||||
|
||||
test("body identity is not forwarded for another provider", () => {
|
||||
const result = buildExecutorClientHeaders({}, undefined, {
|
||||
provider: "openai",
|
||||
body: { thread_id: "private" },
|
||||
});
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("OpenCode headers preserve native identity and strip lease-control headers", () => {
|
||||
const result = buildExecutorClientHeaders(
|
||||
new Headers({ Session_Id: "native-conversation", "x-omniroute-lease-owner": "private-owner" }),
|
||||
undefined,
|
||||
{ provider: "opencode" }
|
||||
);
|
||||
assert.equal(result?.["x-opencode-session"], "native-conversation");
|
||||
assert.equal(result?.["x-omniroute-lease-owner"], undefined);
|
||||
});
|
||||
|
||||
test("malformed or account-only Claude metadata never becomes conversation identity", () => {
|
||||
for (const user_id of ["bad-json", JSON.stringify({ account_id: "user" }), "x".repeat(4097)]) {
|
||||
const result = buildExecutorClientHeaders({}, undefined, {
|
||||
provider: "opencode-go",
|
||||
body: { metadata: { user_id } },
|
||||
});
|
||||
assert.equal(result, null);
|
||||
}
|
||||
});
|
||||
|
||||
test("identity precedence is explicit headers, native headers, metadata, then body", () => {
|
||||
const body = { metadata: { session_id: "metadata" }, thread_id: "body" };
|
||||
assert.equal(
|
||||
resolveOpencodeSessionIdentity({ "X-OPENCODE-SESSION": "explicit", thread_id: "native" }, body),
|
||||
"explicit"
|
||||
);
|
||||
assert.equal(resolveOpencodeSessionIdentity({ thread_id: "native" }, body), "native");
|
||||
assert.equal(resolveOpencodeSessionIdentity({}, body), "metadata");
|
||||
assert.equal(resolveOpencodeSessionIdentity(null, { thread_id: "body" }), "body");
|
||||
});
|
||||
|
||||
test("invalid identities are ignored without inventing an account-scoped session", () => {
|
||||
for (const invalid of [null, false, 42, [], {}, "", "\n", "bad\u007fvalue", "x".repeat(257)]) {
|
||||
assert.equal(
|
||||
resolveOpencodeSessionIdentity({}, { metadata: { session_id: invalid } }),
|
||||
undefined
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
resolveOpencodeSessionIdentity({}, { metadata: { user_id: "account-name" } }),
|
||||
undefined
|
||||
);
|
||||
assert.equal(
|
||||
resolveOpencodeSessionIdentity(
|
||||
{},
|
||||
{ metadata: { user_id: JSON.stringify({ session_id: "valid" }) } }
|
||||
),
|
||||
"valid"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user