diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index 0bf9a2b4c2..91cb562033 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -62,6 +62,7 @@ import { registerSetupClaude } from "./setup-claude.mjs"; import { registerSetupOpencode } from "./setup-opencode.mjs"; import { registerSetupCline } from "./setup-cline.mjs"; import { registerSetupKilo } from "./setup-kilo.mjs"; +import { registerSetupContinue } from "./setup-continue.mjs"; import { registerConnect } from "./connect.mjs"; import { registerTokens } from "./tokens.mjs"; import { registerConfigure } from "./configure.mjs"; @@ -134,6 +135,7 @@ export function registerCommands(program) { registerSetupOpencode(program); registerSetupCline(program); registerSetupKilo(program); + registerSetupContinue(program); registerConnect(program); registerTokens(program); registerConfigure(program); diff --git a/bin/cli/commands/setup-continue.mjs b/bin/cli/commands/setup-continue.mjs new file mode 100644 index 0000000000..6320d8a9c4 --- /dev/null +++ b/bin/cli/commands/setup-continue.mjs @@ -0,0 +1,173 @@ +/** + * omniroute setup-continue — configure Continue (continue.dev) for OmniRoute. + * + * Continue uses a file-based, mergeable ~/.continue/config.yaml shared by the VS + * Code / JetBrains extensions AND the `cn` CLI. Models use `provider: openai` + * with a custom `apiBase` (WITH /v1 — Continue appends /chat/completions) and an + * `apiKey: ${{ secrets.OMNIROUTE_API_KEY }}` reference (secret never written to + * config.yaml). Remote-aware; curated model set with Continue roles. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import os from "node:os"; +import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; +import { resolveActiveContext } from "../contexts.mjs"; +import { categoriseModel } from "./setup-codex.mjs"; + +const SECRET_REF = "${{ secrets.OMNIROUTE_API_KEY }}"; + +function ensureV1(url) { + const s = String(url || "").replace(/\/+$/, ""); + return s.endsWith("/v1") ? s : `${s}/v1`; +} + +/** Resolve apiBase (WITH /v1) + apiKey from flags → active context → localhost. */ +export function resolveContinueTarget(opts = {}) { + let root; + if (opts.remote) root = String(opts.remote).replace(/\/+$/, ""); + else { + try { + root = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl; + } catch { + /* none */ + } + if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`; + } + let apiKey = opts.apiKey ?? opts["api-key"]; + if (!apiKey) { + try { + const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT); + apiKey = c?.accessToken || c?.apiKey; + } catch { + /* none */ + } + } + if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || ""; + return { apiBase: ensureV1(root), apiKey }; +} + +/** Build Continue model entries (provider: openai) for the given catalog ids. */ +export function buildContinueModels(modelIds, apiBase) { + const out = []; + for (const id of modelIds) { + const cfg = categoriseModel(id); + if (!cfg) continue; + const roles = ["chat", "edit", "apply"]; + if (cfg.effort === "low") roles.push("autocomplete"); // fast tier → autocomplete + out.push({ + name: `OmniRoute: ${id}`, + provider: "openai", + model: id, + apiBase, + apiKey: SECRET_REF, + roles, + }); + } + return out; +} + +/** + * Merge OmniRoute models into an existing Continue config object: drop any prior + * models pointing at this apiBase, keep everything else, append the new set. + */ +export function mergeContinueConfig(existing, newModels, apiBase) { + const cfg = existing && typeof existing === "object" ? { ...existing } : {}; + const prior = Array.isArray(cfg.models) ? cfg.models : []; + const kept = prior.filter((m) => !m || m.apiBase !== apiBase); + cfg.models = [...kept, ...newModels]; + if (!cfg.name) cfg.name = "OmniRoute Config"; + if (!cfg.version) cfg.version = "1.0"; + if (!cfg.schema) cfg.schema = "v1"; + return cfg; +} + +async function fetchModelIds(apiBase, apiKey) { + try { + const headers = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const res = await fetch(`${apiBase.replace(/\/v1$/, "")}/v1/models`, { + headers, + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json(); + const list = Array.isArray(body) ? body : body.data ?? body.models ?? []; + return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); + } catch (e) { + throw new Error(`Could not fetch models: ${e.message}`); + } +} + +export async function runSetupContinueCommand(opts = {}) { + const { apiBase, apiKey } = resolveContinueTarget(opts); + const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); + const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null; + const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".continue", "config.yaml"); + + printHeading("OmniRoute → Continue (config.yaml)"); + printInfo(`apiBase: ${apiBase}`); + + let ids; + try { + ids = await fetchModelIds(apiBase, apiKey); + } catch (e) { + printError(e.message); + printInfo("Make sure OmniRoute is running and --remote/--api-key are correct."); + return 1; + } + if (only) ids = ids.filter((id) => only.some((f) => id.includes(f))); + + const models = buildContinueModels(ids, apiBase); + if (!models.length) { + printError("No matching curated models in the catalog (try --only or check the server)."); + return 1; + } + + const yaml = await import("js-yaml"); + let existing = {}; + if (existsSync(configPath)) { + try { + existing = yaml.load(readFileSync(configPath, "utf8")) || {}; + } catch { + printInfo("Existing config.yaml unparseable — starting fresh (a .bak is kept)."); + if (!dryRun) writeFileSync(`${configPath}.bak`, readFileSync(configPath)); + existing = {}; + } + } + const merged = mergeContinueConfig(existing, models, apiBase); + const out = yaml.dump(merged, { lineWidth: -1 }); + + if (dryRun) { + console.log("\n" + (out.length > 3500 ? out.slice(0, 3500) + "\n… (truncated)" : out)); + printInfo(`[dry-run] ${models.length} OmniRoute model(s) → ${configPath}`); + return 0; + } + + mkdirSync(join(configPath, ".."), { recursive: true }); + writeFileSync(configPath, out, "utf8"); + printSuccess(`Wrote ${configPath} (${models.length} OmniRoute models)`); + printInfo("\nProvide the key (config.yaml references it, not stores it):"); + printInfo(" cn CLI: export OMNIROUTE_API_KEY=... (read from your shell)"); + printInfo(" IDE: echo 'OMNIROUTE_API_KEY=...' >> ~/.continue/.env"); + printInfo("Run: cn -p \"reply OK\""); + return 0; +} + +export function registerSetupContinue(program) { + program + .command("setup-continue") + .description( + "Generate ~/.continue/config.yaml (Continue / cn CLI) from the OmniRoute model catalog" + ) + .option("--port ", "Local OmniRoute port (ignored when --remote is set)", "20128") + .option("--remote ", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") + .option("--api-key ", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") + .option("--only ", "Comma-separated substrings — keep only matching model IDs") + .option("--config-path ", "config.yaml path (default: ~/.continue/config.yaml)") + .option("--dry-run", "Print what would be written without touching the filesystem") + .action(async (opts) => { + const code = await runSetupContinueCommand(opts); + if (code !== 0) process.exit(code); + }); +} diff --git a/docs/guides/REMOTE-MODE.md b/docs/guides/REMOTE-MODE.md index bb7212efcb..9a46fcf918 100644 --- a/docs/guides/REMOTE-MODE.md +++ b/docs/guides/REMOTE-MODE.md @@ -149,6 +149,7 @@ context, or `--remote --api-key `): | OpenCode | `omniroute setup-opencode` | `~/.config/opencode/opencode.json` — the `omniroute` openai-compatible provider with every catalog model (run `opencode -m omniroute/`) | | Cline | `omniroute setup-cline` | `~/.cline/data/{globalState,secrets}.json` (CLI mode) + prints the VS Code extension settings to paste (OpenAI-compatible, Base URL **without** `/v1`) | | Kilo Code | `omniroute setup-kilo` | `~/.local/share/kilo/auth.json` (CLI) + VS Code `kilocode.*` settings — OpenAI-compatible, Base URL **with** `/v1` | +| Continue | `omniroute setup-continue` | `~/.continue/config.yaml` (VS Code/JetBrains + `cn` CLI) — `provider: openai`, `apiBase` **with** `/v1`, key via `${{ secrets.OMNIROUTE_API_KEY }}` | ```bash # OpenCode (openai-compatible provider, all catalog models, remote VPS) diff --git a/tests/unit/cli/setup-continue.test.ts b/tests/unit/cli/setup-continue.test.ts new file mode 100644 index 0000000000..5f703ed220 --- /dev/null +++ b/tests/unit/cli/setup-continue.test.ts @@ -0,0 +1,55 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + buildContinueModels, + mergeContinueConfig, + resolveContinueTarget, +} from "../../../bin/cli/commands/setup-continue.mjs"; + +test("buildContinueModels emits provider:openai + apiBase + secret ref + roles", () => { + const models = buildContinueModels(["glm/glm-5.2"], "http://vps:20128/v1"); + assert.equal(models.length, 1); + const m = models[0]; + assert.equal(m.provider, "openai"); + assert.equal(m.model, "glm/glm-5.2"); + assert.equal(m.apiBase, "http://vps:20128/v1"); + assert.equal(m.apiKey, "${{ secrets.OMNIROUTE_API_KEY }}"); + assert.ok(m.roles.includes("chat") && m.roles.includes("edit") && m.roles.includes("apply")); +}); + +test("buildContinueModels gives the fast tier an autocomplete role", () => { + const fast = buildContinueModels(["glm/glm-5-turbo"], "http://x/v1")[0]; // fast → effort low + assert.ok(fast.roles.includes("autocomplete")); +}); + +test("buildContinueModels skips uncategorised models", () => { + assert.equal(buildContinueModels(["some/unknown-model"], "http://x/v1").length, 0); +}); + +test("mergeContinueConfig replaces prior OmniRoute models, keeps others", () => { + const existing = { + name: "My Config", + models: [ + { name: "Local Ollama", provider: "ollama", model: "llama3", apiBase: "http://localhost:11434" }, + { name: "old omni", provider: "openai", model: "x", apiBase: "http://vps:20128/v1" }, + ], + }; + const fresh = buildContinueModels(["glm/glm-5.2"], "http://vps:20128/v1"); + const merged = mergeContinueConfig(existing, fresh, "http://vps:20128/v1"); + // kept the ollama model; dropped the old omni one (same apiBase); added the new + const apiBases = merged.models.map((m) => m.apiBase); + assert.ok(merged.models.some((m) => m.provider === "ollama")); + assert.equal(merged.models.filter((m) => m.apiBase === "http://vps:20128/v1").length, 1); + assert.equal(merged.name, "My Config", "preserves existing top-level keys"); +}); + +test("mergeContinueConfig sets defaults on an empty config", () => { + const merged = mergeContinueConfig({}, buildContinueModels(["glm/glm-5.2"], "http://x/v1"), "http://x/v1"); + assert.equal(merged.schema, "v1"); + assert.ok(merged.name && merged.version); +}); + +test("resolveContinueTarget ensures /v1 on apiBase", () => { + assert.equal(resolveContinueTarget({ remote: "http://vps:20128" }).apiBase, "http://vps:20128/v1"); + assert.equal(resolveContinueTarget({ remote: "http://vps:20128/v1/" }).apiBase, "http://vps:20128/v1"); +});