feat(usage): add automatic models.dev pricing sync (#5734)

* feat(usage): persist model pricing in local config

* feat(usage): sync selected models.dev pricing on startup

* fix(usage): address models.dev sync review feedback

* fix(usage): harden local pricing synchronization
This commit is contained in:
Thefool
2026-07-28 23:37:55 +08:00
committed by GitHub
parent ff3bc242cc
commit 12b972a66e
20 changed files with 2672 additions and 195 deletions
@@ -0,0 +1,226 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
getModelsDevSyncConfig,
saveModelsDevSyncConfig,
getModelPricing,
openAppConfigFolder,
syncModelsDevPricing,
} = vi.hoisted(() => ({
getModelsDevSyncConfig: vi.fn(),
saveModelsDevSyncConfig: vi.fn(),
getModelPricing: vi.fn(),
openAppConfigFolder: vi.fn(),
syncModelsDevPricing: vi.fn(),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, options?: { count?: number }) =>
options?.count == null ? key : `${key}:${options.count}`,
i18n: { resolvedLanguage: "en" },
}),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
vi.mock("@/lib/api/usage", () => ({
usageApi: {
getModelsDevSyncConfig,
saveModelsDevSyncConfig,
getModelPricing,
},
}));
vi.mock("@/lib/api/settings", () => ({
settingsApi: { openAppConfigFolder },
}));
vi.mock("@/lib/modelsDevAutoSync", () => ({
MODELS_DEV_SYNC_CONFIG_QUERY_KEY: ["models-dev-sync-config"],
syncModelsDevPricing,
}));
import { ModelsDevAutoSyncPanel } from "@/components/usage/ModelsDevAutoSyncPanel";
const state = {
configPath: "C:/Users/test/.cc-switch/model-pricing.json",
config: {
autoSyncEnabled: false,
includeCommonModels: true,
selectedModelKeys: [],
excludedCommonModelKeys: [],
lastSyncAt: null,
lastSyncError: null,
},
};
function renderPanel() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<ModelsDevAutoSyncPanel />
</QueryClientProvider>,
);
}
describe("ModelsDevAutoSyncPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
getModelsDevSyncConfig.mockResolvedValue(state);
saveModelsDevSyncConfig.mockResolvedValue(undefined);
getModelPricing.mockResolvedValue([]);
openAppConfigFolder.mockResolvedValue(undefined);
syncModelsDevPricing.mockResolvedValue({
skipped: false,
selected: 2,
imported: 2,
changed: 1,
syncedAt: Date.now(),
});
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
openai: {
name: "OpenAI",
models: {
"gpt-5": {
name: "GPT-5",
release_date: "2025-08-01",
cost: { input: 1, output: 2 },
},
},
},
deepseek: {
name: "DeepSeek",
models: {
"deepseek-chat": {
name: "DeepSeek Chat",
release_date: "2025-12-01",
cost: { input: 0.3, output: 1.2 },
},
},
},
}),
}),
);
});
it("loads automatic sync as disabled by default", async () => {
renderPanel();
expect(
await screen.findByText("usage.modelsDevAutoSync.title"),
).toBeInTheDocument();
expect(screen.getByText(state.configPath)).toBeInTheDocument();
expect(screen.getByRole("switch")).not.toBeChecked();
expect(saveModelsDevSyncConfig).not.toHaveBeenCalled();
});
it("persists disabling without showing the overwrite warning", async () => {
const enabledState = {
...state,
config: { ...state.config, autoSyncEnabled: true },
};
getModelsDevSyncConfig.mockResolvedValue(enabledState);
renderPanel();
fireEvent.click(await screen.findByRole("switch"));
await waitFor(() =>
expect(saveModelsDevSyncConfig).toHaveBeenCalledWith({
...enabledState.config,
autoSyncEnabled: false,
}),
);
expect(
screen.queryByText("usage.modelsDevAutoSync.enableConfirmTitle"),
).not.toBeInTheDocument();
});
it("warns about price overwrites before enabling automatic sync", async () => {
renderPanel();
fireEvent.click(await screen.findByRole("switch"));
expect(saveModelsDevSyncConfig).not.toHaveBeenCalled();
expect(
await screen.findByText("usage.modelsDevAutoSync.enableConfirmTitle"),
).toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", {
name: "usage.modelsDevAutoSync.enableConfirmAction",
}),
);
await waitFor(() =>
expect(saveModelsDevSyncConfig).toHaveBeenCalledWith({
...state.config,
autoSyncEnabled: true,
}),
);
});
it("keeps automatic sync disabled when the overwrite warning is cancelled", async () => {
renderPanel();
fireEvent.click(await screen.findByRole("switch"));
fireEvent.click(
await screen.findByRole("button", { name: "common.cancel" }),
);
expect(saveModelsDevSyncConfig).not.toHaveBeenCalled();
expect(screen.getByRole("switch")).not.toBeChecked();
});
it("reloads the automatic sync config after reading the local pricing file", async () => {
const initialState = {
...state,
config: { ...state.config, autoSyncEnabled: true },
};
getModelsDevSyncConfig
.mockResolvedValueOnce(initialState)
.mockResolvedValue(state);
renderPanel();
fireEvent.click(
await screen.findByRole("button", {
name: "usage.modelsDevAutoSync.reloadLocalFile",
}),
);
await waitFor(() =>
expect(getModelsDevSyncConfig).toHaveBeenCalledTimes(2),
);
expect(getModelPricing).toHaveBeenCalledTimes(1);
await waitFor(() => expect(screen.getByRole("switch")).not.toBeChecked());
});
it("opens the searchable multi-select dialog with common models selected", async () => {
renderPanel();
fireEvent.click(
await screen.findByRole("button", {
name: "usage.modelsDevAutoSync.configure",
}),
);
expect(
await screen.findByText("usage.modelsDevAutoSync.configureTitle"),
).toBeInTheDocument();
expect(await screen.findByText("GPT-5")).toBeInTheDocument();
expect(screen.getByText("DeepSeek Chat")).toBeInTheDocument();
expect(
screen.getByText("usage.modelsDevAutoSync.selectedCount:2"),
).toBeInTheDocument();
expect(
screen.getAllByText("usage.modelsDevAutoSync.commonBadge"),
).toHaveLength(2);
});
});