0a86dfb8c5
A new **AI Router** tab in Settings, next to Codex, stores named router
endpoints (9Router, OpenRouter, a self-hosted gateway) as profiles: a base
URL, an API key, a default model and an optional per-tier override. The tab
only stores them; a lane CHOOSES one in the Add-lane modal, via a Native /
AI Router radio, and that choice is written into the lane's own config.
The two CLIs are configured differently, and the difference is theirs:
- a claude lane gets eight `env` keys merged into its own
`.claude/settings.local.json` (`.local` because the value holds a key);
- a codex lane gets `$CODEX_HOME/<name>.config.toml` — the layer
`codex -p <name>` loads — because Codex has NO project-level config.
Its base `config.toml` is never touched.
Native is a built-in profile rather than a code branch, so "go back to
native" is the same write path. It blanks the two connection variables
instead of removing them (an absent key at project scope lets a USER-level
router apply) and removes the model names instead of writing them (writing
them would override the user's own global model choice).
Key material never leaves the server: profiles live one file each under
`<CLAUDE_HOME>/agenthub-routers/` (0700/0600), `GET /api/ai-routers`
reports only `hasKey`, and there is deliberately no route returning one
profile in full.
Three things this had to get right, each found by review and verified
against the real tools rather than assumed:
- `wire_api = "chat"` is rejected outright by codex-cli 0.149 ("no longer
supported"), so every codex lane on a router would have failed to start.
The layer writes "responses", and writes `env_key` only when the profile
actually has a key — naming an unset variable is itself a startup error.
- The key must never be TYPED into a pane. `tmux.sendCommand` used
`send-keys` with a `KEY='…' cmd` prefix, which the dashboard streams to
every attached browser and bash appends to history; it now takes over the
idle pane with `respawn-pane -k -e`, passing env through argv the way
`newSession` always has. Verified on tmux 3.4: multi-element argv arrives
intact and the pane renders no trace of the value.
- Attaching COPIES a profile, so editing or deleting one in Settings has to
reach the lanes already using it. An edit re-applies it (a rotated key
would otherwise never reach the lane still holding the revoked one); a
delete resets those lanes to native and removes the Codex layer file.
Both report per-lane failures, which the tab surfaces.
`.claude/settings.local.json` is added to the lane's git exclude, for both
native and router lanes: `agenthub lanes reset` runs `git clean -fd` without
`-x`, so an un-excluded settings file is deleted on reset — and untracked
meant the key was committable.
DB: `lanes.router_profile`, nullable, additive. Written only by
`setRouterProfile` beside the disk write, never through `PATCHABLE`.
320 lines
10 KiB
JavaScript
320 lines
10 KiB
JavaScript
/**
|
|
* @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
|
|
* `<CLAUDE_HOME>/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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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<string,string>}} 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,
|
|
};
|