/** * @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. * * 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 proxies = require("../lib/ai-proxies"); const { refreshProfileFiles, removeProfileFiles } = require("../lib/proxy-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-proxies — every profile, built-in `native` first. Never a key. router.get("/", (_req, res) => { try { res.json(proxies.listProxies()); } catch (e) { return fail(res, e); } }); // POST /api/ai-proxies — body: { label, baseUrl, apiKey?, model, models?, behavesAs? } router.post("/", sameOriginGuard, (req, res) => { try { res.status(201).json(proxies.createProxy(req.body || {})); } 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 { res.json(await proxies.testProxy(req.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 { 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 { const removed = proxies.removeProxy(req.params.name); removeProfileFiles(req.params.name); res.json(removed); } catch (e) { return fail(res, e); } }); module.exports = router;