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
+67 -1
View File
@@ -18,6 +18,11 @@
* An API key is write-only here by design — the server never returns one, so
* the form shows whether a key is saved, never the key.
*
* **Test** asks the endpoint itself before the profile is saved onto a lane.
* "Saved" and "works" are different claims, and the gap between them is only
* ever discovered inside a lane's session otherwise, where the error surfaces
* as a broken CLI rather than a bad URL.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
@@ -51,6 +56,11 @@ export function AiRouterConfig() {
const [form, setForm] = useState(emptyForm());
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
// The router's own verdict on the form as it currently stands. Cleared on
// every field change: a green "works" left sitting above an edited base URL
// is a claim about a configuration that no longer exists.
const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null);
const [testing, setTesting] = useState(false);
// Distinct from "no profiles": an empty table rendered before the first fetch
// resolves tells the user their profiles are gone. CodexConfig draws the same
// distinction for the same reason.
@@ -77,6 +87,9 @@ export function AiRouterConfig() {
void load();
}, [load]);
// Any edit to the form invalidates the last verdict — see `testResult`.
useEffect(() => setTestResult(null), [form]);
const startAdd = () => {
setEditing(null);
setForm(emptyForm());
@@ -105,6 +118,37 @@ export function AiRouterConfig() {
);
};
/**
* Ask the endpoint itself. `name` is sent when editing so a profile whose key
* the form cannot show is still testable — the server only honours it while
* the base URL is unchanged, so a retyped endpoint needs a retyped key.
*/
const test = async () => {
setTesting(true);
setTestResult(null);
try {
const result = await api.aiRouters.test({
baseUrl: form.baseUrl.trim(),
model: form.model.trim(),
...(form.apiKey.trim() ? { apiKey: form.apiKey.trim() } : {}),
...(editing ? { name: editing.name } : {}),
});
setTestResult({
ok: result.ok,
text: result.ok
? t("aiRouter.testOk", { model: result.message })
: t("aiRouter.testFailed", {
status: result.status ?? "—",
message: result.message,
}),
});
} catch (e) {
setTestResult({ ok: false, text: e instanceof Error ? e.message : String(e) });
} finally {
setTesting(false);
}
};
const submit = async () => {
setBusy(true);
setError(null);
@@ -329,7 +373,29 @@ export function AiRouterConfig() {
</details>
</div>
<div className="mt-4 flex justify-end gap-2">
{testResult && (
<p
data-testid="router-test-result"
className={`mt-3 rounded-md border px-3 py-2 text-xs ${
testResult.ok
? "border-status-success/40 bg-status-success/10 text-status-success"
: "border-status-danger/40 bg-status-danger/10 text-status-danger"
}`}
>
{testResult.text}
</p>
)}
<div className="mt-4 flex items-center justify-end gap-2">
<button
type="button"
data-testid="router-test"
onClick={test}
disabled={testing || busy || !form.baseUrl.trim() || !form.model.trim()}
className="mr-auto rounded-md border border-border-light px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:text-fg-primary disabled:opacity-50"
>
{testing ? t("aiRouter.testing") : t("aiRouter.test")}
</button>
<button
type="button"
onClick={() => setOpen(false)}