5fe5d51b1b
AI Proxy (was AI Router): - server/lib/ai-proxies.js, proxy-apply.js, routes/ai-proxies.js mounted at /api/ai-proxies and the old /api/ai-routers; `router` still accepted on the start routes; profiles move to <CLAUDE_HOME>/agenthub-proxies/ - dashboard_runs.router renamed in place to proxy: an older build on the same DB fails on that column, so dev and prod sharing one DB update together - a proxy's baseUrl stored as its root (a typed /v1 made /v1/v1/messages); Test probes Claude /v1/messages and Codex /v1/responses - Settings → AI Proxy (/settings/ai-router redirects), ProxyPicker Updates: - every tab reloads onto the new UI build when its WebSocket reconnects to a server of another version (lib/newBuildReload.ts, once per version) - Update now stops waiting on a plugin chain that failed or pulled nothing (the old server never goes down), and reloads as soon as a new version answers Also: - a pane whose command exits at once says so (EPANEEXITED) instead of "Command failed: tmux set-option … mouse on" - the pane drag preview draws where the pane actually lands
108 lines
3.6 KiB
JavaScript
108 lines
3.6 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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;
|