feat: model lists and prices that follow each AI tool, a Settings tab per tool, live lane status (#40)

Pricing: the LiteLLM sync now adds every current Claude / GPT (>= 5) /
Gemini (>= 3) chat model before it is used and keeps its own rows
(model_pricing.source = 'sync', the Auto badge) at the published rate. A
user's row (any PUT) and a deleted pattern (model_pricing_dismissed) are
never touched; a deleted built-in row is not re-seeded at the next start; a
reset re-applies the list at once; retired Claude models are priced only
once used.

Models: GET /api/ai-tools/:id/models reads each tool's own list (Codex's
models_cache.json, Claude's aliases named after the newest version plus
priced versions, a catalog tool's vendor rows plus the models its sessions
used). A catalog `model: {flag, vendor?}` field passes `<flag> <id>`
(validated: never a flag or shell text); a per-tool default model starts
each run, and a resume keeps the session's own model.

Settings: every installed, enabled catalog tool without its own tab gets
one (AiToolConfig.tsx): path, homepage, Update, sessions/cost/lanes,
default model, vendor pricing, and the config files its release catalog
lists, edited over the opened sha, syntax-checked, backed up, written
atomically; credential-looking names refused. Antigravity keeps its hooks
card there.

Lanes: a just-started run no longer reads "dead" (age counts the run's
start while the old session is still bound); a second dashboard on the
same database re-broadcasts the lanes the other changed (PRAGMA
data_version, every 2 s); a lane start with a bad model answers 400.

DB: every migration runs under a cross-process lock (<db>-migrate.lock), so
two dashboards started at once on a fresh database both come up.
This commit is contained in:
2026-10-01 13:39:17 +07:00
parent c5aba094bd
commit f887c16309
51 changed files with 2343 additions and 203 deletions
@@ -10,6 +10,7 @@ import { MemoryRouter } from "react-router-dom";
import { LaneConsolePane } from "../LaneConsolePane";
import { api } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
import { __resetAiTools } from "../../../hooks/useAiTools";
vi.mock("../TerminalView", () => ({
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
@@ -28,6 +29,10 @@ vi.mock("../../../lib/api", () => ({
action: vi.fn(),
list: vi.fn(),
},
aiTools: {
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
},
run: {
list: vi.fn().mockResolvedValue({ items: [] }),
history: vi.fn().mockResolvedValue({ items: [] }),
@@ -36,7 +41,6 @@ vi.mock("../../../lib/api", () => ({
},
},
RUN_MODEL_CHOICES: [],
RUN_MODEL_CHOICES_CODEX: [],
RUN_EFFORT_CHOICES: [],
}));
@@ -136,6 +140,31 @@ describe("LaneConsolePane", () => {
);
});
it("starts a run on the tool's default model from AI Tools settings", async () => {
__resetAiTools();
(api.aiTools.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
default: "claude",
tools: [{ id: "claude", label: "Claude Code", defaultModel: "sonnet", modelFlag: true }],
});
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({ lane: LANE });
render(<LaneConsolePane {...baseProps()} />);
fireEvent.change(screen.getByPlaceholderText(/type to search/i), {
target: { value: "/workspace/a" },
});
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
await waitFor(() => expect(api.aiTools.list).toHaveBeenCalled());
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
await waitFor(() =>
expect(api.lanes.action).toHaveBeenCalledWith(
1,
"start",
expect.objectContaining({ model: "sonnet" })
)
);
__resetAiTools();
});
it("returns to the setup form when the pane exits, without a New run click", async () => {
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
lane: { ...LANE, run_id: "run-1" },
@@ -29,12 +29,37 @@ vi.mock("../../../lib/api", async (importOriginal) => {
list: r({
default: "claude",
tools: [
{ id: "claude", label: "Claude Code", resume: false, bypass: false },
{
id: "claude",
label: "Claude Code",
resume: false,
bypass: false,
modelFlag: true,
defaultModel: "sonnet",
},
{ id: "codex", label: "Codex", resume: false, bypass: true },
{ id: "antigravity", label: "Antigravity", resume: true, bypass: true },
{
id: "antigravity",
label: "Antigravity",
resume: true,
bypass: true,
modelFlag: true,
},
{ id: "gemini", label: "Gemini", resume: false, bypass: false },
].map((x) => ({ ...x, installed: true, enabled: true, command: x.id })),
}),
// Each tool's own model list, as GET /api/ai-tools/:id/models answers it.
models: vi.fn(async (id: string) => {
const lists: Record<string, { id: string; label: string }[]> = {
claude: [
{ id: "sonnet", label: "Sonnet (latest · 5.5)" },
{ id: "claude-sonnet-5-5", label: "Claude Sonnet 5.5" },
],
codex: [{ id: "gpt-6-sol", label: "GPT-6-Sol" }],
antigravity: [{ id: "gemini-3.1-pro", label: "gemini-3.1-pro" }],
};
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
}),
},
run: { files: r({ items: [] }) },
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
@@ -145,11 +170,12 @@ describe("RunSetup — selections report through callbacks", () => {
onlyCalled(spies, "onCwdChange");
});
it("reports the model from the picker, including a custom id", () => {
it("reports the model from the picker, including a custom id", async () => {
const { spies } = renderSetup();
// The picker offers the CLI's "latest" aliases, not pinned ids — that is
// what keeps the list from going stale on every model release.
pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet (latest)");
// The list is the tool's own, fetched from the server — aliases named
// after the newest version, then pinned versions — never a hardcoded copy.
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
fireEvent.click(await screen.findByText("Sonnet (latest · 5.5)"));
expect(spies.onModelChange).toHaveBeenCalledWith("sonnet");
// "Custom…" is a sentinel, not a model id — it must not be reported as one;
// the free-text box it reveals is what reports.
@@ -374,6 +400,27 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
});
});
describe("RunSetup — resume keeps the session's model", () => {
const session = { id: "sess-1", cwd: "/Users/tester" } as never;
it("sends no model when it is still the tool's default", async () => {
const { spies } = renderSetup({ resumeSession: session, model: "sonnet", prompt: "" });
await screen.findByText(i18n.t("run:fields.model"));
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith(
expect.objectContaining({ resumeSessionId: "sess-1", model: undefined })
);
});
it("sends a model the user picked", async () => {
const { spies } = renderSetup({ resumeSession: session, model: "opus", prompt: "" });
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith(expect.objectContaining({ model: "opus" }));
});
});
describe("RunSetup — per-tool fields", () => {
it("shows sandbox and approval instead of permission mode for a codex lane", () => {
renderSetup({ tool: "codex" });
@@ -457,20 +504,38 @@ describe("RunSetup — / and @ suggestions in the prompt box", () => {
});
describe("RunSetup — antigravity lanes", () => {
it("names the lane's CLI in the prompt box and drops the flags `agy` lacks", () => {
const { spies } = renderSetup({ tool: "antigravity", model: "opus", effort: "high" });
it("names the lane's CLI, offers its model flag and drops the flags `agy` lacks", async () => {
const { spies } = renderSetup({ tool: "antigravity", model: "gemini-3.1-pro", effort: "high" });
expect(screen.getByPlaceholderText("Ask Antigravity anything…")).toBeTruthy();
expect(screen.queryByText(i18n.t("run:fields.model"))).toBeNull();
expect(await screen.findByText(i18n.t("run:fields.model"))).toBeTruthy();
expect(screen.queryByText(i18n.t("run:fields.effort"))).toBeNull();
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith(
expect.objectContaining({ model: undefined, effort: undefined, permissionMode: undefined })
expect.objectContaining({
model: "gemini-3.1-pro",
effort: undefined,
permissionMode: undefined,
})
);
});
it("sends no model for a catalog tool whose CLI has no model flag", async () => {
const { spies } = renderSetup({ tool: "gemini", model: "stale", prompt: "go" });
await screen.findByPlaceholderText("Ask Gemini anything…");
expect(screen.queryByText(i18n.t("run:fields.model"))).toBeNull();
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith(expect.objectContaining({ model: undefined }));
});
});
describe("RunSetup — codex lanes", () => {
it("offers the models Codex itself lists", async () => {
renderSetup({ tool: "codex" });
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
expect(await screen.findByText("GPT-6-Sol")).toBeTruthy();
});
it("starts a fresh codex run instead of resuming the last one", () => {
const { spies } = renderSetup({ tool: "codex" });
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));