feat: v2.5.5 — AI Router renamed AI Proxy, reload onto new builds, update no longer spins
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
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* @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`).
|
||||
* @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/router-apply.js`),
|
||||
* 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
|
||||
@@ -18,8 +20,8 @@
|
||||
|
||||
const express = require("express");
|
||||
|
||||
const routers = require("../lib/ai-routers");
|
||||
const { refreshProfileFiles, removeProfileFiles } = require("../lib/router-apply");
|
||||
const proxies = require("../lib/ai-proxies");
|
||||
const { refreshProfileFiles, removeProfileFiles } = require("../lib/proxy-apply");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
|
||||
const router = express.Router();
|
||||
@@ -40,48 +42,50 @@ function fail(res, e) {
|
||||
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.
|
||||
// GET /api/ai-proxies — every profile, built-in `native` first. Never a key.
|
||||
router.get("/", (_req, res) => {
|
||||
try {
|
||||
res.json(routers.listRouters());
|
||||
res.json(proxies.listProxies());
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/ai-routers — body: { label, baseUrl, apiKey?, model, models? }
|
||||
// POST /api/ai-proxies — body: { label, baseUrl, apiKey?, model, models?, behavesAs? }
|
||||
router.post("/", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
res.status(201).json(routers.createRouter(req.body || {}));
|
||||
res.status(201).json(proxies.createProxy(req.body || {}));
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/ai-routers/test — does this endpoint + key + model actually answer?
|
||||
// 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 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).
|
||||
// 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 routers.testRouter(req.body || {}));
|
||||
res.json(await proxies.testProxy(req.body || {}));
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/ai-routers/:name — same fields, all optional. An omitted `apiKey`
|
||||
// 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 = routers.updateRouter(req.params.name, req.body || {});
|
||||
const saved = proxies.updateProxy(req.params.name, req.body || {});
|
||||
refreshProfileFiles(saved.name);
|
||||
res.json(saved);
|
||||
} catch (e) {
|
||||
@@ -89,10 +93,10 @@ router.patch("/:name", sameOriginGuard, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/ai-routers/:name — and the session files that copy its key.
|
||||
// DELETE /api/ai-proxies/:name — and the session files that copy its key.
|
||||
router.delete("/:name", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const removed = routers.removeRouter(req.params.name);
|
||||
const removed = proxies.removeProxy(req.params.name);
|
||||
removeProfileFiles(req.params.name);
|
||||
res.json(removed);
|
||||
} catch (e) {
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file HTTP surface for AgentHub's own git credentials (git-credentials.js):
|
||||
* list, save/replace, delete. Like the AI Router routes, nothing here ever
|
||||
* list, save/replace, delete. Like the AI Proxy routes, nothing here ever
|
||||
* returns a password — `GET` reports `hasToken` — and every write is behind
|
||||
* the same-origin guard. A lane's fetch/pull/push also saves one, through
|
||||
* `POST /api/lanes/:id/git/:action` with `credentials.remember`.
|
||||
|
||||
+10
-9
@@ -28,7 +28,7 @@ const laneFeatures = require("../lib/lane-features");
|
||||
const proofLib = require("../lib/proof");
|
||||
const { broadcast } = require("../websocket");
|
||||
const runs = require("../lib/pty-run");
|
||||
const { resumeRouter } = require("../lib/router-apply");
|
||||
const { resumeProxy } = require("../lib/proxy-apply");
|
||||
const { sameOriginGuard } = require("./run");
|
||||
const { writeLaneFile } = require("../lib/lane-file-write");
|
||||
const { pickPath } = require("../lib/native-dialog");
|
||||
@@ -927,7 +927,7 @@ router.get("/:id/git/file", async (req, res) => {
|
||||
);
|
||||
res.json({ available: true, ...file });
|
||||
} catch (err) {
|
||||
// A secret file (the router's API key) is refused at every rev.
|
||||
// A secret file (where a proxy's API key once lived) is refused at every rev.
|
||||
if (err.code === "ESECRET") {
|
||||
return res.status(403).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
@@ -2002,12 +2002,13 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
// A NEW session in a worktree of its own (`claude --worktree`).
|
||||
worktree: body.worktree === true && !body.resumeSessionId,
|
||||
tool: sessionTool || current.tool || "claude",
|
||||
// The SESSION's AI Router, by name; a resume that names none goes
|
||||
// back to the one its session last ran on.
|
||||
router:
|
||||
body.router !== undefined
|
||||
? body.router
|
||||
: resumeRouter(body.resumeSessionId || (body.resume ? current.session_id : null)),
|
||||
// The SESSION's AI Proxy, by name; a resume that names none goes
|
||||
// back to the one its session last ran on. `router` is the
|
||||
// pre-rename field, still taken from an older client.
|
||||
proxy:
|
||||
(body.proxy ?? body.router) !== undefined
|
||||
? (body.proxy ?? body.router)
|
||||
: resumeProxy(body.resumeSessionId || (body.resume ? current.session_id : null)),
|
||||
model: body.model,
|
||||
permissionMode: body.permissionMode,
|
||||
effort: body.effort,
|
||||
@@ -2020,7 +2021,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
});
|
||||
startedRunId = handle.id;
|
||||
// An extra pane's session is not the lane's run: `run_id` stays the
|
||||
// pane-0 session every other path (reset, router restart) names.
|
||||
// pane-0 session every other path (reset, stop) names.
|
||||
if (!slot) lanesLib.updateLane(current.id, { run_id: handle.id, status: "running" });
|
||||
return {};
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ const tmux = require("../lib/tmux");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const { resumeRouter } = require("../lib/router-apply");
|
||||
const { resumeProxy } = require("../lib/proxy-apply");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -341,12 +341,13 @@ router.post("/", (req, res) => {
|
||||
laneId,
|
||||
cwd,
|
||||
tool: lane?.tool || "claude",
|
||||
// The session's AI Router, by name (key and endpoint from the store); a
|
||||
// The session's AI Proxy, by name (key and endpoint from the store); a
|
||||
// resume that names none goes back to the one its session last ran on.
|
||||
router:
|
||||
body.router !== undefined
|
||||
? body.router
|
||||
: resumeRouter(typeof body.resumeSessionId === "string" ? body.resumeSessionId : null),
|
||||
// `router` is the pre-rename field, still taken from an older client.
|
||||
proxy:
|
||||
(body.proxy ?? body.router) !== undefined
|
||||
? (body.proxy ?? body.router)
|
||||
: resumeProxy(typeof body.resumeSessionId === "string" ? body.resumeSessionId : null),
|
||||
model: typeof body.model === "string" && body.model ? body.model : null,
|
||||
permissionMode:
|
||||
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
|
||||
|
||||
@@ -461,7 +461,7 @@ function homeRoutes(route, key, field, get, set) {
|
||||
|
||||
// GET/PUT /api/settings/claude-home
|
||||
homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaudeHome);
|
||||
// GET/PUT /api/settings/codex-home — a session's AI Router layer is written
|
||||
// GET/PUT /api/settings/codex-home — a session's AI Proxy layer is written
|
||||
// into the current home at its spawn, so a move needs no repair here.
|
||||
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user