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
@@ -0,0 +1,141 @@
/**
* @file AiToolConfig.test.tsx
* @description The generic per-tool Settings tab: the tool's path, homepage
* and an Update link into the AI Tools modal; its session count, cost and
* lanes; a default-model picker saved through PUT /api/ai-tools/:id; and its
* catalog-declared config files, edited over the sha they were opened at with
* the server's refusal shown.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { api, type AiTool } from "../../lib/api";
import { __resetAiTools } from "../../hooks/useAiTools";
import { AiToolConfig } from "../AiToolConfig";
vi.mock("../../lib/api", () => ({
api: {
aiTools: {
list: vi.fn(),
update: vi.fn(),
models: vi.fn(),
usage: vi.fn(),
config: vi.fn(),
readConfig: vi.fn(),
saveConfig: vi.fn(),
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
},
lanes: { list: vi.fn() },
},
RUN_MODEL_CHOICES: [],
}));
vi.mock("../../components/settings/PricingSettings", () => ({
PricingSettings: ({ vendor }: { vendor: string }) => <div>pricing:{vendor}</div>,
}));
const GEMINI: AiTool = {
id: "gemini",
label: "Gemini",
command: "gemini",
homepageUrl: "https://github.com/google-gemini/gemini-cli",
installed: true,
path: "/usr/bin/gemini",
enabled: true,
source: "builtin",
launcher: null,
prompt: { mode: "flag" },
resume: false,
bypass: true,
routerCapable: false,
install: { kind: "npm", package: "@google/gemini-cli" },
update: { kind: "npm" },
modelFlag: true,
defaultModel: null,
modelVendor: "google",
configFiles: 2,
};
beforeEach(() => {
__resetAiTools();
const view = { default: "claude", tools: [GEMINI] };
vi.mocked(api.aiTools.list)
.mockReset()
.mockResolvedValue(view as never);
vi.mocked(api.aiTools.update)
.mockReset()
.mockResolvedValue(view as never);
vi.mocked(api.aiTools.models)
.mockReset()
.mockResolvedValue({
flag: true,
models: [{ id: "gemini-3.1-pro", label: "gemini-3.1-pro" }],
defaultModel: null,
});
vi.mocked(api.aiTools.usage).mockReset().mockResolvedValue({ sessions: 3, cost: 1.5 });
vi.mocked(api.aiTools.config)
.mockReset()
.mockResolvedValue({
files: [
{ index: 0, path: "/h/.gemini/settings.json", format: "json", exists: true, size: 2 },
{ index: 1, path: "/h/.gemini/GEMINI.md", format: "text", exists: false, size: null },
],
});
vi.mocked(api.aiTools.readConfig)
.mockReset()
.mockResolvedValue({ path: "/h/.gemini/settings.json", format: "json", text: "{}", sha: "s1" });
vi.mocked(api.aiTools.saveConfig).mockReset();
vi.mocked(api.lanes.list)
.mockReset()
.mockResolvedValue({
lanes: [
{ id: 7, tool: "gemini", title: "web", cwd: "/w" },
{ id: 8, tool: "claude", title: "other", cwd: "/o" },
],
} as never);
});
const renderTab = () =>
render(
<MemoryRouter>
<AiToolConfig toolId="gemini" />
</MemoryRouter>
);
describe("AiToolConfig", () => {
it("shows the tool, its usage, its lanes and its vendor's pricing", async () => {
renderTab();
expect(await screen.findByText("/usr/bin/gemini")).toBeInTheDocument();
expect(await screen.findByText(/3 sessions/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: "web" })).toHaveAttribute("href", "/workspace?lane=7");
expect(screen.queryByRole("link", { name: "other" })).toBeNull();
expect(screen.getByRole("link", { name: /update/i })).toHaveAttribute(
"href",
"/settings?tool=gemini#ai-tools"
);
expect(screen.getByText("pricing:google")).toBeInTheDocument();
});
it("saves the default model", async () => {
renderTab();
fireEvent.click(await screen.findByText("Inherit from settings"));
fireEvent.click(await screen.findByText("gemini-3.1-pro"));
fireEvent.click(screen.getByRole("button", { name: /save default model/i }));
await waitFor(() =>
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { defaultModel: "gemini-3.1-pro" })
);
});
it("edits a config file over the sha it opened, and shows a refusal", async () => {
vi.mocked(api.aiTools.saveConfig).mockRejectedValueOnce(new Error("not valid JSON"));
renderTab();
fireEvent.click(await screen.findByRole("button", { name: "/h/.gemini/settings.json" }));
expect(screen.getByRole("button", { name: "/h/.gemini/GEMINI.md" })).toBeDisabled();
const box = await screen.findByRole("textbox", { name: "/h/.gemini/settings.json" });
fireEvent.change(box, { target: { value: "{nope" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
expect(await screen.findByText(/not valid JSON/)).toBeInTheDocument();
expect(api.aiTools.saveConfig).toHaveBeenCalledWith("gemini", 0, "{nope", "s1");
});
});