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:
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user