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
+29
View File
@@ -1891,6 +1891,15 @@ export const api = {
method: "PATCH",
body: JSON.stringify(body),
}),
/**
* POST /api/ai-routers/test - ask the endpoint itself whether it works,
* before a profile that cannot reach anything is saved onto a lane.
*/
test: (body: AiRouterTestInput) =>
request<AiRouterTestResult>("/ai-routers/test", {
method: "POST",
body: JSON.stringify(body),
}),
/**
* DELETE /api/ai-routers/:name - also resets every lane using it back to
* native, on disk as well as in the DB.
@@ -2680,6 +2689,26 @@ export interface AiRouter {
savedAt?: string;
}
/** Outcome of POST /api/ai-routers/test. A router that answered with an error
* is still a successful HTTP call: `ok` is the ROUTER's verdict, `status` is
* its HTTP code (null when nothing answered at all), and `message` is its own
* words — truncated server-side, never the key. */
export interface AiRouterTestResult {
ok: boolean;
status: number | null;
message: string;
}
/** Body for POST /api/ai-routers/test. `name` lets the server reuse the stored
* key for a profile being edited — but only while `baseUrl` still matches the
* saved one, so a typed-in endpoint always needs a typed-in key. */
export interface AiRouterTestInput {
baseUrl: string;
model: string;
apiKey?: string;
name?: string;
}
/** How an edit or a delete landed on the lanes already using that profile.
* Attaching copies a profile, so the store alone is not the whole truth: a
* lane listed in `failed` still carries the OLD key and endpoint. */