Files
AgentHub/server/routes/ai-routers.js
T
nntrivi2001 0a86dfb8c5 feat(settings): add AI Router profiles and attach one per lane
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`.
2026-09-14 16:04:57 +07:00

91 lines
3.1 KiB
JavaScript

/**
* @file ai-routers.js (routes)
* @description CRUD for AI Router profiles — the endpoints behind the
* **AI Router** tab of Settings. CHOOSING a profile for a lane is not here: it
* happens once, when the lane is created (`POST /api/lanes/:id/router/apply`),
* because the write target is the lane's own directory.
*
* An edit or a delete here does reach the lanes that already chose it, though.
* Attaching COPIES the profile into the lane's own config, so a store-only
* write would be inert exactly where it matters most — a rotated key would
* never reach the lanes still authenticating with the revoked one.
*
* No response on this router carries key material. `GET /` returns the store's
* public view (`hasKey: true|false`), and there is deliberately no
* `GET /:name` — a route that returned one profile in full would be the one
* way an API key could leave this process.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const express = require("express");
const routers = require("../lib/ai-routers");
const { reapplyToLanes, detachFromLanes } = require("../lib/router-apply");
const { sameOriginGuard } = require("../lib/same-origin");
const router = express.Router();
const ERR_TO_STATUS = {
EBADNAME: 400,
EBADLABEL: 400,
EBADURL: 400,
EBADMODEL: 400,
EBADJSON: 409,
EBUILTIN: 409,
ENOTFOUND: 404,
};
function fail(res, e) {
const status = ERR_TO_STATUS[e.code] || 500;
return res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
}
// GET /api/ai-routers — every profile, built-in `native` first. Never a key.
router.get("/", (_req, res) => {
try {
res.json(routers.listRouters());
} catch (e) {
return fail(res, e);
}
});
// POST /api/ai-routers — body: { label, baseUrl, apiKey?, model, models? }
router.post("/", sameOriginGuard, (req, res) => {
try {
res.status(201).json(routers.createRouter(req.body || {}));
} catch (e) {
return fail(res, e);
}
});
// PATCH /api/ai-routers/:name — same fields, all optional. An omitted `apiKey`
// keeps the stored one; the edit form cannot show a key, so it cannot echo one.
//
// Attaching COPIES a profile into the lane's own config, so an edit that only
// wrote the store here would be inert: rotating a leaked key would leave every
// claude lane on the revoked one. `reapplyToLanes` pushes the change out, and
// reports per-lane failures instead of failing the edit.
router.patch("/:name", sameOriginGuard, (req, res) => {
try {
const saved = routers.updateRouter(req.params.name, req.body || {});
res.json({ ...saved, lanes: reapplyToLanes(saved.name) });
} catch (e) {
return fail(res, e);
}
});
// DELETE /api/ai-routers/:name — every lane using it is reset to native, in its
// config on disk as well as in the DB. Leaving the lanes alone would keep the
// deleted router's key and base URL live in each claude lane's settings file.
router.delete("/:name", sameOriginGuard, (req, res) => {
try {
const removed = routers.removeRouter(req.params.name);
res.json({ ...removed, lanes: detachFromLanes(req.params.name) });
} catch (e) {
return fail(res, e);
}
});
module.exports = router;