feat(ai-router): test an endpoint before saving it, and re-point a live lane

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).
This commit is contained in:
2026-09-16 11:26:00 +07:00
parent 9128352d1b
commit 00a2aedb09
19 changed files with 563 additions and 10 deletions
+83
View File
@@ -188,3 +188,86 @@ describe("ai-routers — a corrupt file is skipped, never fatal", () => {
assert.ok(!items.some((i) => i.name === "broken"));
});
});
describe("ai-routers — testRouter", () => {
const realFetch = global.fetch;
after(() => {
global.fetch = realFetch;
});
/** Capture the one request testRouter makes, and answer it however asked. */
function stubFetch(answer) {
const calls = [];
global.fetch = async (url, init) => {
calls.push({ url, init });
if (answer instanceof Error) throw answer;
return answer;
};
return calls;
}
const ok = () => ({ ok: true, status: 200, statusText: "OK", text: async () => "{}" });
it("posts one capped message to the endpoint's /v1/messages", async () => {
const calls = stubFetch(ok());
const result = await routers.testRouter(nineRouter());
assert.equal(result.ok, true);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, "http://localhost:20128/v1/v1/messages");
const body = JSON.parse(calls[0].init.body);
assert.equal(body.max_tokens, 1);
assert.equal(body.model, "Highest");
assert.equal(calls[0].init.headers["x-api-key"], "sk-secret-value");
});
it("reports a router that answered with an error rather than throwing", async () => {
stubFetch({ ok: false, status: 401, statusText: "Unauthorized", text: async () => "bad key" });
const result = await routers.testRouter(nineRouter());
assert.deepEqual(result, { ok: false, status: 401, message: "bad key" });
});
it("reports an endpoint that never answered", async () => {
// The shape node's own fetch throws: a useless outer message wrapping the
// one thing the user can act on.
stubFetch(
Object.assign(new Error("fetch failed"), {
cause: new Error("connect ECONNREFUSED 127.0.0.1:20199"),
})
);
const result = await routers.testRouter(nineRouter());
assert.equal(result.ok, false);
assert.equal(result.status, null);
assert.match(result.message, /ECONNREFUSED/);
});
it("falls back to the stored key for the endpoint it was stored against", async () => {
const created = routers.createRouter(nineRouter());
const calls = stubFetch(ok());
await routers.testRouter({
name: created.name,
baseUrl: "http://localhost:20128/v1",
model: "Highest",
});
assert.equal(calls[0].init.headers["x-api-key"], "sk-secret-value");
});
it("never sends the stored key to a different endpoint", async () => {
const created = routers.createRouter(nineRouter());
const calls = stubFetch(ok());
await assert.rejects(
routers.testRouter({
name: created.name,
baseUrl: "https://attacker.example/v1",
model: "Highest",
}),
{ code: "EBADKEY" }
);
assert.equal(calls.length, 0);
});
it("refuses a base URL that is not http(s)", async () => {
await assert.rejects(routers.testRouter(nineRouter({ baseUrl: "file:///etc/passwd" })), {
code: "EBADURL",
});
});
});
+86
View File
@@ -21,6 +21,11 @@
* native" is the same single write path as "switch to a router". It cannot be
* edited, removed, or shadowed by a saved profile.
*
* `testRouter` is the one thing here that talks to the network: the smallest
* real request a claude lane would make, so "saved" and "actually works" stop
* being the same claim. It is deliberately NOT a generic proxy — see the note
* on its stored-key fallback.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -307,12 +312,93 @@ function removeRouter(name) {
return { removed: name };
}
/** How long a connection test waits before calling the endpoint dead. */
const TEST_TIMEOUT_MS = 15_000;
/** Enough of a failing router's own words to be actionable, not enough to flood. */
const TEST_BODY_MAX = 300;
/**
* Probe a router endpoint with the smallest real request a claude lane makes:
* one Anthropic-style message capped at a single output token.
*
* Deliberately the CLAUDE wire shape and not Codex's `responses` one. A profile
* is not tool-specific, so a test has to pick; `/v1/messages` is what a claude
* lane's eight env keys point at, and it is the path a router has to serve for
* this feature to work at all.
*
* **The stored key is only ever sent to the endpoint it was stored against.**
* A blank `apiKey` falls back to the saved profile named by `name` — but only
* when the submitted `baseUrl` still matches the saved one. Without that check
* this route would be a way to make the server POST a saved API key to any URL
* a browser names, which is exactly the leak `listRouters` exists to prevent.
*
* A router that answers is a success even when it answers with an error: the
* outcome is reported (`ok`, `status`, `message`), never thrown. Only bad INPUT
* throws.
*
* @param {{name?:string, baseUrl:string, apiKey?:string, model:string}} input
* @returns {Promise<{ok:boolean, status:number|null, message:string}>}
*/
async function testRouter(input = {}) {
const baseUrl = normalizeBaseUrl(input.baseUrl);
const model = normalizeModel(input.model);
let apiKey = typeof input.apiKey === "string" ? input.apiKey.trim() : "";
if (!apiKey && input.name) {
const stored = readRouter(input.name);
if (stored && normalizeBaseUrl(stored.baseUrl || "") === baseUrl) apiKey = stored.apiKey || "";
}
if (!apiKey) throw err("EBADKEY", "enter the API key to test this endpoint");
let res;
try {
res = await fetch(`${baseUrl}/v1/messages`, {
method: "POST",
headers: {
"content-type": "application/json",
// Both, because routers disagree on which one they read, and it is the
// same key going to the same endpoint either way.
"x-api-key": apiKey,
authorization: `Bearer ${apiKey}`,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model,
max_tokens: 1,
messages: [{ role: "user", content: "ping" }],
}),
signal: AbortSignal.timeout(TEST_TIMEOUT_MS),
});
} catch (e) {
// Unreachable, DNS failure, TLS refusal, timeout — a real answer about the
// endpoint, so it is a result and not a 500.
//
// `cause` first: 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.
return {
ok: false,
status: null,
message: e.name === "TimeoutError" ? "timed out" : (e.cause && e.cause.message) || e.message,
};
}
const body = await res.text().catch(() => "");
return {
ok: res.ok,
status: res.status,
message: res.ok ? model : body.slice(0, TEST_BODY_MAX) || res.statusText,
};
}
module.exports = {
listRouters,
getRouter,
createRouter,
updateRouter,
removeRouter,
testRouter,
MODEL_SLOTS,
BUILTIN_NAME,
NATIVE,
+17
View File
@@ -27,6 +27,7 @@ const { sameOriginGuard } = require("../lib/same-origin");
const router = express.Router();
const ERR_TO_STATUS = {
EBADKEY: 400,
EBADNAME: 400,
EBADLABEL: 400,
EBADURL: 400,
@@ -59,6 +60,22 @@ router.post("/", sameOriginGuard, (req, res) => {
}
});
// 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.
//