Compare commits

..

4 Commits

20 changed files with 452 additions and 1070 deletions

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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))

View 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))

View File

@@ -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.

View 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.

View File

@@ -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),
});
}

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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

View 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,
};
}

View File

@@ -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>
```

View File

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

View File

@@ -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/);
});
});

View File

@@ -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"));
}
);
});

View File

@@ -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 });
}
});

View 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, {});
});

View 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"
);
});