f887c16309
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.
111 lines
4.5 KiB
TypeScript
111 lines
4.5 KiB
TypeScript
/**
|
|
* @file PricingSettings.test.tsx
|
|
* @description The shared Model Pricing table: each tool's tab shows its own
|
|
* vendor's rows, **Show all models** lists the rest, a row added outside the
|
|
* filter turns that switch on instead of vanishing, and Reset Defaults is
|
|
* offered only where asked for (the Claude tab).
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { render, screen } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import i18n from "i18next";
|
|
import { PricingSettings } from "../PricingSettings";
|
|
import { api } from "../../../lib/api";
|
|
import type { ModelPricing } from "../../../lib/types";
|
|
|
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>();
|
|
return {
|
|
...actual,
|
|
api: {
|
|
pricing: { list: vi.fn(), upsert: vi.fn(), delete: vi.fn() },
|
|
settings: { resetPricing: vi.fn() },
|
|
},
|
|
};
|
|
});
|
|
|
|
const rule = (model_pattern: string, display_name: string) =>
|
|
({
|
|
model_pattern,
|
|
display_name,
|
|
input_per_mtok: 1,
|
|
output_per_mtok: 2,
|
|
cache_read_per_mtok: 0.1,
|
|
cache_write_per_mtok: 0,
|
|
cache_write_1h_per_mtok: 0,
|
|
fast_input_per_mtok: 0,
|
|
fast_output_per_mtok: 0,
|
|
updated_at: "2026-09-29 10:00:00",
|
|
}) as ModelPricing;
|
|
|
|
const ROWS = [rule("claude-opus-5-5%", "Claude Opus 5.5"), rule("gpt-5.6-sol%", "GPT-5.6 Sol")];
|
|
|
|
beforeEach(() => {
|
|
i18n.changeLanguage("en");
|
|
vi.mocked(api.pricing.list).mockReset().mockResolvedValue({ pricing: ROWS });
|
|
vi.mocked(api.pricing.upsert)
|
|
.mockReset()
|
|
.mockResolvedValue({} as never);
|
|
});
|
|
|
|
describe("PricingSettings", () => {
|
|
it("shows the vendor's rows, and the rest behind Show all models", async () => {
|
|
const user = userEvent.setup();
|
|
render(<PricingSettings vendor="openai" />);
|
|
expect(await screen.findByText("gpt-5.6-sol%")).toBeInTheDocument();
|
|
expect(screen.queryByText("claude-opus-5-5%")).toBeNull();
|
|
expect(screen.queryByRole("button", { name: /Reset Defaults/i })).toBeNull();
|
|
await user.click(screen.getByRole("checkbox", { name: /Show all models \(1 more\)/ }));
|
|
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" />);
|
|
expect(await screen.findByText(/added automatically from LiteLLM/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("turns Show all on when a row outside the filter is added", async () => {
|
|
const user = userEvent.setup();
|
|
render(<PricingSettings vendor="openai" />);
|
|
await screen.findByText("gpt-5.6-sol%");
|
|
await user.click(screen.getByRole("button", { name: /Add Model/i }));
|
|
const [pattern, name] = screen.getAllByRole("textbox");
|
|
await user.type(pattern!, "deepseek-v4%");
|
|
await user.type(name!, "DeepSeek V4");
|
|
vi.mocked(api.pricing.list).mockResolvedValue({
|
|
pricing: [...ROWS, rule("deepseek-v4%", "DeepSeek V4")],
|
|
});
|
|
await user.click(screen.getByTitle(/save/i));
|
|
expect(await screen.findByText("deepseek-v4%")).toBeInTheDocument();
|
|
expect(screen.getByRole("checkbox", { name: /Show all models/ })).toBeChecked();
|
|
});
|
|
|
|
it("offers Reset Defaults on the Claude tab, confirmed by a second click", async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(api.settings.resetPricing).mockReset().mockResolvedValue({ ok: true, pricing: ROWS });
|
|
render(<PricingSettings vendor="claude" allowReset />);
|
|
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument();
|
|
expect(screen.queryByText("gpt-5.6-sol%")).toBeNull();
|
|
await user.click(screen.getByRole("button", { name: /Reset Defaults/i }));
|
|
expect(api.settings.resetPricing).not.toHaveBeenCalled();
|
|
await user.click(screen.getByRole("button", { name: /confirm/i }));
|
|
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
|
|
expect(api.settings.resetPricing).toHaveBeenCalledWith("claude");
|
|
});
|
|
});
|