00a2aedb09
Two gaps left by the AI Router feature: a profile could be saved without ever
being reachable, and a lane's router could only be chosen at creation.
**POST /api/ai-routers/test** answers the question saving cannot. It sends one
Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages`
with a 15s timeout — the wire shape a claude lane's eight env keys point at, so
what is tested is what will actually run. A router that answers is a 200 here
whatever it replies: `{ ok, status, message }` carries the router's own words,
and `status` is null when nothing answered at all. Only bad input is a 4xx.
The stored key is only ever sent to the endpoint it was stored against. A blank
`apiKey` falls back to the key of the profile named by `name`, but only while
the submitted `baseUrl` still equals the saved one — so testing a retyped
endpoint needs a retyped key (`EBADKEY`). Without that rule this route would be
a way to make the server POST a saved API key to any URL a browser names.
Network failures report `error.cause`, not `error.message`: node's fetch reports
every transport failure as the same useless "fetch failed" and hides the part
the user can act on (ECONNREFUSED, ENOTFOUND, a TLS error) one level down.
**The lane card carries an AI Router select.** The Add-lane modal was the only
place a router was chosen, and a lane outlives its creation. Switching goes
through `POST /api/lanes/:id/router/apply` — never a lane PATCH — because the
DB column and the lane's own config on disk may only move together. A failed
apply puts the select back rather than leaving the card claiming a router the
lane did not get. Hidden when `native` is the only profile: a select with one
option is a control that cannot do anything.
Adds `Lane.router_profile` to the client type (the payload already carried it).
108 lines
3.9 KiB
JavaScript
108 lines
3.9 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 = {
|
|
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.
|
|
//
|
|
// 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;
|