/** * @file ai-proxies.js (routes) * @description CRUD for AI Proxy profiles — the endpoints behind the * **AI Proxy** page of Settings, mounted at `/api/ai-proxies` and, for a * client older than the rename, `/api/ai-routers`. CHOOSING a profile is not * here: a new session names one (`proxy` 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/proxy-apply.js`), * which an edit rewrites and a delete removes. * * Who may write what (server mode; local mode has no user and writes the * shared store as before): an admin writes the SHARED profiles; anyone may * keep PERSONAL ones (`scope: "personal"`, a member's only kind), and only * their owner lists, edits, deletes or tests them — another user, an admin * included, gets the same 404 as a profile that does not exist. An analyst * keeps personal ones like a developer. * * 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. * * `GET /9router` only says whether a `9router` binary is on the server's PATH, * so the page can offer a link to its own config UI; it never runs it. * * @author Nguyễn Ngọc Trí Vĩ */ const path = require("node:path"); const express = require("express"); const proxies = require("../lib/ai-proxies"); const { refreshProfileFiles, removeProfileFiles } = require("../lib/proxy-apply"); const { sameOriginGuard } = require("../lib/same-origin"); const { which } = require("../lib/ai-tools"); const { shellPath } = require("../lib/shell-path"); const router = express.Router(); const ERR_TO_STATUS = { EBADKEY: 400, EBADNAME: 400, EBADLABEL: 400, EBADURL: 400, EBADMODEL: 400, EBADJSON: 409, EBUILTIN: 409, ENOTFOUND: 404, EADMIN: 403, }; const notFound = (name) => Object.assign(new Error(`no proxy profile named '${name}'`), { code: "ENOTFOUND" }); const adminOnly = () => Object.assign(new Error("only an admin may change a shared AI Proxy"), { code: "EADMIN" }); /** Throw unless this request may edit or delete profile `name`. */ function assertMayWrite(req, name) { const owner = proxies.ownerOfName(name); if (owner != null) { if (req.user?.id !== owner) throw notFound(name); } else if (req.user && req.user.role !== "admin") { throw adminOnly(); } } 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-proxies — every profile, built-in `native` first. Never a key. router.get("/", (req, res) => { try { res.json(proxies.listProxies(req.user?.id ?? null)); } catch (e) { return fail(res, e); } }); // GET /api/ai-proxies/9router — is 9router installed here? `{ installed }`. // A file lookup on PATH only; nothing is spawned. router.get("/9router", (_req, res) => { const pathStr = [shellPath(), process.env.PATH].filter(Boolean).join(path.delimiter); res.json({ installed: Boolean(which("9router", pathStr)) }); }); // POST /api/ai-proxies — body: { label, baseUrl, apiKey?, model, models?, // behavesAs?, scope? }. `scope` is "shared" (an admin's default) or "personal" // (a member's only choice); local mode has no user and always writes shared. router.post("/", sameOriginGuard, (req, res) => { try { const body = req.body || {}; const user = req.user; const personal = Boolean(user) && (user.role !== "admin" || body.scope === "personal"); if (user && body.scope === "shared" && user.role !== "admin") throw adminOnly(); res.status(201).json(proxies.createProxy(body, personal ? user.id : null)); } catch (e) { return fail(res, e); } }); // POST /api/ai-proxies/test — does this endpoint + key + model actually answer, // for Claude Code (`/v1/messages`) and for Codex (`/v1/responses`)? // Body: `{ baseUrl, model, apiKey?, name? }`. Registered before `/:name` for // clarity; no collision today, since `/:name` takes no POST. // // A reachable proxy is a 200 here whatever it replies — `{ claude, codex }`, // each `{ ok, status, message }` with the proxy'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 `testProxy` on why that path // is narrow). router.post("/test", sameOriginGuard, async (req, res) => { try { const body = { ...(req.body || {}) }; // The stored-key fallback is the owner's alone: a name that is someone // else's personal profile is dropped, so their key is never sent. const owner = proxies.ownerOfName(body.name); if (owner != null && req.user?.id !== owner) delete body.name; res.json(await proxies.testProxy(body)); } catch (e) { return fail(res, e); } }); // PATCH /api/ai-proxies/: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 { assertMayWrite(req, req.params.name); const saved = proxies.updateProxy(req.params.name, req.body || {}); refreshProfileFiles(saved.name); res.json(saved); } catch (e) { return fail(res, e); } }); // DELETE /api/ai-proxies/:name — and the session files that copy its key. router.delete("/:name", sameOriginGuard, (req, res) => { try { assertMayWrite(req, req.params.name); const removed = proxies.removeProxy(req.params.name); removeProfileFiles(req.params.name); res.json(removed); } catch (e) { return fail(res, e); } }); module.exports = router;