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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user