/** * @file ai-routers.js * @description Named AI Router profiles — an endpoint (9Router, OpenRouter, a * self-hosted gateway) plus the model names to use against it, saved once and * attached to a lane when that lane is created. * * A profile is exactly what the two CLIs need to talk to a router and nothing * more: `baseUrl`, `apiKey`, a default `model`, and one slot per tier mirroring * the env keys Claude Code reads. Per-tier slots because a router may expose a * different id per tier — and the `model` field fills every one of them, * because the common case is a single symbolic name for everything. * * Profiles are stored one JSON file per profile under * `/agenthub-routers/` (dir 0700, files 0600), the same shape and * the same reasoning as `cc-profiles.js`: they hold a live API key, so **no * route ever returns key material** — `listRouters` reports `hasKey` and * nothing else about it. * * `native` is a built-in profile rather than a branch in the caller: it carries * empty connection fields and the vendor's own model names, so "switch back to * native" is the same single write path as "switch to a router". It cannot be * edited, removed, or shadowed by a saved profile. * * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { getClaudeHome } = require("./claude-home"); /** * A profile's `name` is its filename, so the charset is deliberately narrow. * What the user reads and edits is `label`, which accepts spaces and diacritics * precisely because it never touches a path. Same split as cc-profiles.js. */ const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$/; const LABEL_MIN = 3; const LABEL_MAX = 60; /** The per-tier model overrides Claude Code reads, in the order the UI shows them. */ const MODEL_SLOTS = ["opus", "sonnet", "haiku", "smallFast", "subagent"]; const BUILTIN_NAME = "native"; /** * The vendor's own defaults, used when a lane is set back to native. Empty * connection fields rather than absent ones: the native reference settings.json * sets both to "", and an absent key would let a user-level router base URL * leak into a lane that asked for native. */ const NATIVE = Object.freeze({ name: BUILTIN_NAME, label: "Native (Claude / Codex)", builtin: true, baseUrl: "", apiKey: "", model: "claude-sonnet-5", models: Object.freeze({ opus: "claude-opus-5", sonnet: "claude-sonnet-5", haiku: "claude-haiku-4-5-20251001", smallFast: "claude-haiku-4-5-20251001", subagent: "claude-haiku-4-5-20251001", }), }); function err(code, message) { const e = new Error(message); e.code = code; return e; } function routersDir() { return path.join(getClaudeHome(), "agenthub-routers"); } function routerFile(name) { if (typeof name !== "string" || !NAME_RE.test(name)) { throw err("EBADNAME", "name must be 1-40 chars of letters, digits, dot, dash or underscore"); } return path.join(routersDir(), `${name}.json`); } function readJson(file) { let raw; try { raw = fs.readFileSync(file, "utf8"); } catch { return null; } try { return JSON.parse(raw); } catch { throw err("EBADJSON", `${file} is not valid JSON - fix it by hand first`); } } /** Write JSON at 0600 through a temp file, so a crash never truncates the original. */ function writeJson(file, value) { fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); const tmp = `${file}.agenthub-tmp`; fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); fs.renameSync(tmp, file); } /** Reduce any text to the narrow NAME_RE charset, for use as a filename. */ function slugify(text) { const base = String(text || "") .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") // drop the combining accents NFD split off .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 32); return NAME_RE.test(base) ? base : "router"; } /** `base`, or `base-2`, `base-3`… — the first that is neither taken nor built in. */ function freeName(base) { let name = base; for ( let n = 2; name === BUILTIN_NAME || fs.existsSync(path.join(routersDir(), `${name}.json`)); ) { name = `${base}-${n}`; n += 1; } return name; } function normalizeLabel(label) { const clean = typeof label === "string" ? label.trim() : ""; if (clean.length < LABEL_MIN || clean.length > LABEL_MAX || /[\u0000-\u001f\u007f]/.test(clean)) { throw err( "EBADLABEL", `label must be ${LABEL_MIN}-${LABEL_MAX} characters and no control characters` ); } return clean; } /** * A router endpoint is an http(s) URL and nothing else. A `file:` or * `javascript:` base URL is never a router — it is a mistake, or an attempt to * point a CLI somewhere it should not go. */ function normalizeBaseUrl(baseUrl) { const raw = typeof baseUrl === "string" ? baseUrl.trim() : ""; if (!raw) throw err("EBADURL", "baseUrl is required"); let parsed; try { parsed = new URL(raw); } catch { throw err("EBADURL", `not a valid URL: ${raw}`); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw err("EBADURL", "baseUrl must be http:// or https://"); } return raw.replace(/\/+$/, ""); } /** A model name is free text — a router names its own models — but not blank. */ function normalizeModel(model) { const clean = typeof model === "string" ? model.trim() : ""; if (!clean || clean.length > 200 || /[\u0000-\u001f\u007f]/.test(clean)) { throw err("EBADMODEL", "model must be 1-200 characters and no control characters"); } return clean; } /** * Fill every slot from `model`, keeping any the caller set explicitly. This * is what makes "one symbolic name for every tier" — the 9Router case — a * single field to type instead of six. */ function normalizeModels(model, models) { const given = models && typeof models === "object" ? models : {}; const out = {}; for (const slot of MODEL_SLOTS) { out[slot] = given[slot] === undefined ? model : normalizeModel(given[slot]); } return out; } function assertNotBuiltin(name) { if (name === BUILTIN_NAME) { throw err("EBUILTIN", `'${BUILTIN_NAME}' is the built-in profile and cannot be changed`); } } /** Read one stored profile (key included), or null when it does not exist. */ function readRouter(name) { const stored = readJson(routerFile(name)); return stored && typeof stored === "object" ? stored : null; } /** The browser-safe view: everything except the key, plus whether one is set. */ function publicView(profile) { const { apiKey, ...rest } = profile; return { ...rest, hasKey: Boolean(apiKey) }; } /** * Every saved profile, with the built-in native one first. * @returns `{ items }` — key material is never included. */ function listRouters() { let names = []; try { names = fs .readdirSync(routersDir()) .filter((f) => f.endsWith(".json")) .map((f) => f.slice(0, -5)); } catch { /* nothing saved yet */ } const items = [publicView(NATIVE)]; for (const name of names.sort()) { if (!NAME_RE.test(name) || name === BUILTIN_NAME) continue; let stored; try { stored = readRouter(name); } catch { continue; // a corrupt profile file is skipped, not fatal for the list } if (!stored) continue; items.push(publicView({ ...stored, name, builtin: false })); } return { items }; } /** * One profile INCLUDING its key — for the server's own writers only. No route * may return this shape; `listRouters` is what the browser sees. */ function getRouter(name) { if (name === BUILTIN_NAME) return { ...NATIVE, models: { ...NATIVE.models } }; const stored = readRouter(name); if (!stored) throw err("ENOTFOUND", `no router profile named '${name}'`); return { ...stored, name, builtin: false }; } /** * @param {{label:string, baseUrl:string, apiKey?:string, model:string, * models?:Record}} input * @returns the browser-safe view of what was saved */ function createRouter(input = {}) { const label = normalizeLabel(input.label); const slug = slugify(label); assertNotBuiltin(slug); const model = normalizeModel(input.model); const profile = { label, baseUrl: normalizeBaseUrl(input.baseUrl), apiKey: typeof input.apiKey === "string" ? input.apiKey.trim() : "", model, models: normalizeModels(model, input.models), savedAt: new Date().toISOString(), }; const name = freeName(slug); writeJson(routerFile(name), profile); return publicView({ ...profile, name, builtin: false }); } /** * Merge changes into a saved profile. The `name` never changes — a lane stores * it, and renaming would orphan that lane. An omitted `apiKey` keeps the stored * one: the edit form cannot show a key, so it cannot send one back either. */ function updateRouter(name, changes = {}) { assertNotBuiltin(name); const stored = readRouter(name); if (!stored) throw err("ENOTFOUND", `no router profile named '${name}'`); const model = changes.model === undefined ? stored.model : normalizeModel(changes.model); const next = { ...stored, label: changes.label === undefined ? stored.label : normalizeLabel(changes.label), baseUrl: changes.baseUrl === undefined ? stored.baseUrl : normalizeBaseUrl(changes.baseUrl), apiKey: typeof changes.apiKey === "string" && changes.apiKey.trim() ? changes.apiKey.trim() : stored.apiKey, model, models: changes.model === undefined && changes.models === undefined ? stored.models : normalizeModels(model, changes.models ?? stored.models), savedAt: new Date().toISOString(), }; writeJson(routerFile(name), next); return publicView({ ...next, name, builtin: false }); } function removeRouter(name) { assertNotBuiltin(name); const file = routerFile(name); if (!fs.existsSync(file)) throw err("ENOTFOUND", `no router profile named '${name}'`); fs.rmSync(file); return { removed: name }; } module.exports = { listRouters, getRouter, createRouter, updateRouter, removeRouter, MODEL_SLOTS, BUILTIN_NAME, NATIVE, };