00a2aedb09
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).
260 lines
9.9 KiB
TypeScript
260 lines
9.9 KiB
TypeScript
/**
|
|
* @file Tests for the AI Router settings tab — listing profiles, creating one,
|
|
* and the two rules that keep a key safe: the form never shows one, and an
|
|
* edit that leaves the field blank must not send an empty key that would wipe
|
|
* the stored one.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { AiRouterConfig } from "../AiRouterConfig";
|
|
import { api } from "../../lib/api";
|
|
|
|
vi.mock("../../lib/api", () => ({
|
|
api: {
|
|
aiRouters: {
|
|
list: vi.fn(),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
remove: vi.fn(),
|
|
test: vi.fn(),
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string, opts?: Record<string, unknown>) =>
|
|
opts && typeof opts.label === "string" ? `${key}:${opts.label}` : key,
|
|
}),
|
|
}));
|
|
|
|
const native = {
|
|
name: "native",
|
|
label: "Native (Claude / Codex)",
|
|
builtin: true,
|
|
baseUrl: "",
|
|
hasKey: false,
|
|
model: "claude-sonnet-5",
|
|
models: {
|
|
opus: "claude-opus-5",
|
|
sonnet: "claude-sonnet-5",
|
|
haiku: "claude-haiku-4-5-20251001",
|
|
smallFast: "claude-haiku-4-5-20251001",
|
|
subagent: "claude-haiku-4-5-20251001",
|
|
},
|
|
};
|
|
|
|
const nineRouter = {
|
|
...native,
|
|
name: "9router",
|
|
label: "9Router",
|
|
builtin: false,
|
|
baseUrl: "http://localhost:20128/v1",
|
|
hasKey: true,
|
|
model: "Highest",
|
|
models: {
|
|
opus: "Highest",
|
|
sonnet: "Highest",
|
|
haiku: "Highest",
|
|
smallFast: "Highest",
|
|
subagent: "Highest",
|
|
},
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(api.aiRouters.list)
|
|
.mockReset()
|
|
.mockResolvedValue({ items: [native] });
|
|
vi.mocked(api.aiRouters.create).mockReset().mockResolvedValue(nineRouter);
|
|
vi.mocked(api.aiRouters.update)
|
|
.mockReset()
|
|
.mockResolvedValue({ ...nineRouter, lanes: { updated: 0, failed: [] } });
|
|
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", () => {
|
|
it("lists the built-in native profile with no edit or delete control", async () => {
|
|
render(<AiRouterConfig />);
|
|
expect(await screen.findByText("Native (Claude / Codex)")).toBeInTheDocument();
|
|
expect(screen.queryByLabelText(/aiRouter\.edit Native/)).toBeNull();
|
|
expect(screen.queryByLabelText(/aiRouter\.remove Native/)).toBeNull();
|
|
});
|
|
|
|
it("saves a new profile with the typed endpoint, key and model", async () => {
|
|
render(<AiRouterConfig />);
|
|
const user = userEvent.setup();
|
|
|
|
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
|
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
|
|
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.getByRole("button", { name: "aiRouter.save" }));
|
|
|
|
await waitFor(() =>
|
|
expect(api.aiRouters.create).toHaveBeenCalledWith({
|
|
label: "9Router",
|
|
baseUrl: "http://localhost:20128/v1",
|
|
model: "Highest",
|
|
// Every tier equals the headline model, so none is sent: the server
|
|
// fills them, which is what makes one field enough.
|
|
models: {},
|
|
apiKey: "sk-typed",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("sends only the tiers that differ from the headline model", async () => {
|
|
render(<AiRouterConfig />);
|
|
const user = userEvent.setup();
|
|
|
|
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
|
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
|
|
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
|
|
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
|
|
await user.type(screen.getByLabelText("aiRouter.tier.opus"), "PremiumCombo");
|
|
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
|
|
|
await waitFor(() =>
|
|
expect(api.aiRouters.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({ models: { opus: "PremiumCombo" } })
|
|
)
|
|
);
|
|
});
|
|
|
|
it("never puts a stored key in the edit form, and omits it when left blank", async () => {
|
|
// The API cannot return a key, so an edit that sends `apiKey: ""` would
|
|
// silently erase the one on disk.
|
|
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
|
render(<AiRouterConfig />);
|
|
const user = userEvent.setup();
|
|
|
|
await user.click(await screen.findByLabelText("aiRouter.edit 9Router"));
|
|
expect(screen.getByLabelText("aiRouter.fieldApiKey")).toHaveValue("");
|
|
|
|
await user.clear(screen.getByLabelText("aiRouter.fieldLabel"));
|
|
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router nhà");
|
|
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
|
|
|
await waitFor(() => expect(api.aiRouters.update).toHaveBeenCalled());
|
|
const call = vi.mocked(api.aiRouters.update).mock.calls[0];
|
|
expect(call?.[1]).not.toHaveProperty("apiKey");
|
|
});
|
|
|
|
it("warns when a rotated key did not reach every lane using the profile", async () => {
|
|
// The whole point of pushing an edit out to the lanes is that a revoked key
|
|
// stops being used. A lane that silently kept the old one must be named,
|
|
// or the user believes a rotation is complete when it is not.
|
|
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
|
vi.mocked(api.aiRouters.update).mockResolvedValue({
|
|
...nineRouter,
|
|
lanes: { updated: 1, failed: ["7 (checkout): EBADJSON"] },
|
|
});
|
|
render(<AiRouterConfig />);
|
|
const user = userEvent.setup();
|
|
|
|
await user.click(await screen.findByLabelText("aiRouter.edit 9Router"));
|
|
await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-rotated");
|
|
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
|
|
|
expect(await screen.findByText("aiRouter.lanesFailed")).toBeInTheDocument();
|
|
});
|
|
|
|
it("surfaces a server error instead of closing the form", async () => {
|
|
vi.mocked(api.aiRouters.create).mockRejectedValue(
|
|
new Error("baseUrl must be http:// or https://")
|
|
);
|
|
render(<AiRouterConfig />);
|
|
const user = userEvent.setup();
|
|
|
|
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
|
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "Bad");
|
|
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "file:///etc/passwd");
|
|
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "x");
|
|
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
|
|
|
expect(await screen.findByText(/must be http/)).toBeInTheDocument();
|
|
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());
|
|
});
|
|
});
|