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:
@@ -76,9 +76,8 @@ function formatTimestamp(iso: string): string {
|
||||
/**
|
||||
* Info popover for the Model Pricing section. Hover or focus the icon to see a
|
||||
* three-section explanation: how prices are applied, how pattern matching
|
||||
* works, and — for Claude — a reminder that prices must be edited manually
|
||||
* when Anthropic publishes new rates; another vendor's rows come from
|
||||
* LiteLLM's price list, so its tab says that instead. All copy is i18n-driven
|
||||
* works, and where the rates come from (LiteLLM's price list, kept up to date
|
||||
* daily for the rows marked Auto). All copy is i18n-driven
|
||||
* (settings.pricing.tooltip.*).
|
||||
*
|
||||
* The popover is fixed-positioned and clamped to the viewport so it never
|
||||
@@ -165,11 +164,9 @@ function PricingInfoTooltip({ vendor }: { vendor: PricingVendor }) {
|
||||
</p>
|
||||
|
||||
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
|
||||
{t(claude ? "pricing.tooltip.manualUpdates" : "pricing.tooltip.synced")}
|
||||
</p>
|
||||
<p className="text-fg-secondary leading-snug mb-2.5">
|
||||
{t(claude ? "pricing.tooltip.manualUpdatesBody" : "pricing.tooltip.syncedBody")}
|
||||
{t("pricing.tooltip.synced")}
|
||||
</p>
|
||||
<p className="text-fg-secondary leading-snug mb-2.5">{t("pricing.tooltip.syncedBody")}</p>
|
||||
|
||||
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
|
||||
{t("pricing.tooltip.apiPricing")}
|
||||
@@ -673,6 +670,14 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing" }:
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary">
|
||||
{rule.display_name}
|
||||
{rule.source === "sync" && (
|
||||
<span
|
||||
title={t("pricing.autoBadgeTitle")}
|
||||
className="ml-2 rounded bg-accent/10 px-1.5 py-0.5 text-[10px] font-medium text-accent"
|
||||
>
|
||||
{t("pricing.autoBadge")}
|
||||
</span>
|
||||
)}
|
||||
{rule.intro_until && (
|
||||
<div className="text-[11px] font-normal text-violet-700/80 dark:text-violet-400/80 mt-0.5">
|
||||
{t("pricing.introNote", {
|
||||
|
||||
@@ -54,6 +54,10 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
|
||||
routerCapable: false,
|
||||
install: null,
|
||||
update: null,
|
||||
modelFlag: false,
|
||||
defaultModel: null,
|
||||
modelVendor: null,
|
||||
configFiles: 0,
|
||||
...over,
|
||||
});
|
||||
const VIEW: AiToolsView = {
|
||||
@@ -257,6 +261,28 @@ describe("SettingsHub — AI tool tabs", () => {
|
||||
expect(screen.queryByRole("tab", { name: /^Codex/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("gives every installed, enabled catalog tool its own tab, and none to the rest", async () => {
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue({
|
||||
default: "claude",
|
||||
tools: [
|
||||
tool("claude", "Claude Code", { launcher: "claude" }),
|
||||
tool("gemini", "Gemini"),
|
||||
tool("qwen-code", "Qwen Code", { enabled: false }),
|
||||
tool("aider", "Aider", { installed: false }),
|
||||
],
|
||||
});
|
||||
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
|
||||
const { SettingsHub } = await import("../../../pages/SettingsHub");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsHub />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByRole("tab", { name: /Gemini/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /Qwen Code/ })).toBeNull();
|
||||
expect(screen.queryByRole("tab", { name: /Aider/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("opens AI Tools as a modal from the tab bar, and closes it on Escape", async () => {
|
||||
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
|
||||
const { SettingsHub } = await import("../../../pages/SettingsHub");
|
||||
|
||||
@@ -61,6 +61,18 @@ describe("PricingSettings", () => {
|
||||
expect(screen.getByText("claude-opus-5-5%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks a row the automatic sync keeps up to date", async () => {
|
||||
vi.mocked(api.pricing.list).mockResolvedValue({
|
||||
pricing: [
|
||||
{ ...rule("gpt-6-sol%", "GPT-6 Sol"), source: "sync" },
|
||||
{ ...rule("gpt-5.6-sol%", "GPT-5.6 Sol"), source: "user" },
|
||||
],
|
||||
});
|
||||
render(<PricingSettings vendor="openai" />);
|
||||
await screen.findByText("gpt-6-sol%");
|
||||
expect(screen.getAllByText("Auto")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("says where OpenAI prices come from when there are none", async () => {
|
||||
vi.mocked(api.pricing.list).mockResolvedValue({ pricing: [ROWS[0]!] });
|
||||
render(<PricingSettings vendor="openai" />);
|
||||
|
||||
Reference in New Issue
Block a user