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)}
@@ -14,7 +14,13 @@ import { api } from "../../lib/api";
vi.mock("../../lib/api", () => ({
api: {
aiRouters: { list: vi.fn(), create: vi.fn(), update: vi.fn(), remove: vi.fn() },
aiRouters: {
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
test: vi.fn(),
},
},
}));
@@ -69,6 +75,9 @@ beforeEach(() => {
vi.mocked(api.aiRouters.remove)
.mockReset()
.mockResolvedValue({ removed: "9router", lanes: { updated: 0, failed: [] } });
vi.mocked(api.aiRouters.test)
.mockReset()
.mockResolvedValue({ ok: true, status: 200, message: "Highest" });
});
describe("AiRouterConfig", () => {
@@ -176,3 +185,75 @@ describe("AiRouterConfig", () => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
});
describe("AiRouterConfig — testing an endpoint before saving it", () => {
it("sends the typed endpoint, key and model, and reports success", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-typed");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
await user.click(screen.getByTestId("router-test"));
await waitFor(() =>
expect(api.aiRouters.test).toHaveBeenCalledWith({
baseUrl: "http://localhost:20128/v1",
model: "Highest",
apiKey: "sk-typed",
})
);
expect(await screen.findByTestId("router-test-result")).toHaveTextContent("aiRouter.testOk");
});
it("names the profile being edited so the server can reuse its stored key", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByLabelText(/aiRouter\.edit 9Router/));
await user.click(screen.getByTestId("router-test"));
await waitFor(() =>
expect(api.aiRouters.test).toHaveBeenCalledWith({
baseUrl: "http://localhost:20128/v1",
model: "Highest",
name: "9router",
})
);
});
it("shows the router's own refusal rather than claiming it works", async () => {
vi.mocked(api.aiRouters.test).mockResolvedValue({
ok: false,
status: 401,
message: "invalid api key",
});
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
await user.click(screen.getByTestId("router-test"));
expect(await screen.findByTestId("router-test-result")).toHaveTextContent(
"aiRouter.testFailed"
);
});
it("drops a stale verdict as soon as a field changes", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
await user.click(screen.getByTestId("router-test"));
expect(await screen.findByTestId("router-test-result")).toBeInTheDocument();
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "2");
await waitFor(() => expect(screen.queryByTestId("router-test-result")).toBeNull());
});
});
@@ -79,6 +79,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
features: { list: r({ features: [] }), show: r({ feature: null }) },
proof: { list: r({ features: [] }), imageUrl: () => "" },
},
aiRouters: { list: r({ items: [] }) },
sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) },
},
};
@@ -140,6 +140,14 @@ vi.mock("../../lib/api", async (importOriginal) => {
}),
},
},
// The lane card's router picker. Only the built-in profile, which is the
// shape that renders no picker — nothing here asserts on it.
aiRouters: {
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/ai-routers");
return { items: [] };
}),
},
run: {
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/run/list");