Files
AgentHub/server/routes/ai-routers.js
T
nntrivi2001 c507d37baf feat: AI Router per session; Settings at the sidebar's foot; shipped tool icons
AI Router is a session's choice, not a workspace's:
- an AI Router select beside the model picker in the workspace home and a
  pane's run form (RouterPicker.tsx; hidden off Claude/Codex or with only
  Native; last pick remembered in agenthub.session.router)
- `router` on POST /api/lanes/:id/start and POST /api/run; Claude gets
  --settings=<data dir>/router-settings/<name>.json (0600, outranks project
  and user settings, key never in argv), Codex -p <name> + key via tmux -e
- dashboard_runs.router; a resume naming none goes back to it
- profile PATCH rewrites the session files, DELETE removes them
- gone: POST /api/lanes/:id/router/apply, LaneRouterRow, the Add-workspace
  router radio, the claude-/codex-home `routers` answers; clearLaneRouters
  takes the old keys out of each workspace's settings.local.json at start

Sidebar: Settings pinned to the foot, the collapse toggle beside it (stacked
on the rail) instead of in the brand's corner.

Icons: shipped SVG marks for claude, codex, openclaw and hermes (a
black/white pair swapped by theme) in client/public/ai-tools/, used by
AiToolIcon before the server's favicon.
2026-10-02 14:19:02 +07:00

104 lines
3.4 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 is not here: a new session
* names one (`router` on `POST /api/lanes/:id/start` and `POST /api/run`).
*
* An edit or a delete here does reach the sessions using it: each reads the
* profile through a file written at its spawn (`server/lib/router-apply.js`),
* which an edit rewrites and a delete removes.
*
* 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 { refreshProfileFiles, removeProfileFiles } = require("../lib/router-apply");
const { sameOriginGuard } = require("../lib/same-origin");
const router = express.Router();
const ERR_TO_STATUS = {
EBADKEY: 400,
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);
}
});
// POST /api/ai-routers/test — does this endpoint + key + model actually answer?
// Body: `{ baseUrl, model, apiKey?, name? }`. Registered before `/:name` for
// clarity; no collision today, since `/:name` takes no POST.
//
// A reachable router is a 200 here whatever it replies — `{ ok:false, status }`
// carries the router's own words, which is the whole point. Only bad input is
// a 4xx. The response never echoes the key, including on the path where the
// stored one was used (see `testRouter` on why that path is narrow).
router.post("/test", sameOriginGuard, async (req, res) => {
try {
res.json(await routers.testRouter(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.
//
// A session reads the profile through a file written at its spawn, so the
// edit rewrites those files too — a rotated key reaches live Claude sessions.
router.patch("/:name", sameOriginGuard, (req, res) => {
try {
const saved = routers.updateRouter(req.params.name, req.body || {});
refreshProfileFiles(saved.name);
res.json(saved);
} catch (e) {
return fail(res, e);
}
});
// DELETE /api/ai-routers/:name — and the session files that copy its key.
router.delete("/:name", sameOriginGuard, (req, res) => {
try {
const removed = routers.removeRouter(req.params.name);
removeProfileFiles(req.params.name);
res.json(removed);
} catch (e) {
return fail(res, e);
}
});
module.exports = router;