/** * @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ĩ */ 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;