mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 01:25:33 +08:00
12b972a66e
* 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
197 lines
5.6 KiB
TypeScript
197 lines
5.6 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const {
|
|
getModelsDevSyncConfig,
|
|
updateModelPricingBatch,
|
|
recordModelsDevSyncResult,
|
|
} = vi.hoisted(() => ({
|
|
getModelsDevSyncConfig: vi.fn(),
|
|
updateModelPricingBatch: vi.fn(),
|
|
recordModelsDevSyncResult: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/api/usage", () => ({
|
|
usageApi: {
|
|
getModelsDevSyncConfig,
|
|
updateModelPricingBatch,
|
|
recordModelsDevSyncResult,
|
|
},
|
|
}));
|
|
|
|
import {
|
|
MODELS_DEV_STARTUP_SYNC_INTERVAL_MS,
|
|
syncModelsDevPricing,
|
|
} from "@/lib/modelsDevAutoSync";
|
|
|
|
const state = {
|
|
configPath: "C:/Users/test/.cc-switch/model-pricing.json",
|
|
config: {
|
|
autoSyncEnabled: true,
|
|
includeCommonModels: true,
|
|
selectedModelKeys: ["relay/custom-model"],
|
|
excludedCommonModelKeys: [],
|
|
lastSyncAt: null,
|
|
lastSyncError: null,
|
|
},
|
|
};
|
|
|
|
describe("syncModelsDevPricing", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
updateModelPricingBatch.mockResolvedValue(2);
|
|
recordModelsDevSyncResult.mockResolvedValue(undefined);
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
openai: {
|
|
models: {
|
|
"gpt-5": {
|
|
name: "GPT-5",
|
|
release_date: "2025-08-01",
|
|
cost: { input: 1, output: 2 },
|
|
},
|
|
},
|
|
},
|
|
relay: {
|
|
models: {
|
|
"custom-model": {
|
|
name: "Custom Model",
|
|
release_date: "2025-07-01",
|
|
cost: { input: 0.5, output: 1 },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("skips network access when automatic sync is disabled", async () => {
|
|
getModelsDevSyncConfig.mockResolvedValue({
|
|
...state,
|
|
config: { ...state.config, autoSyncEnabled: false },
|
|
});
|
|
|
|
const result = await syncModelsDevPricing();
|
|
|
|
expect(result.skipped).toBe(true);
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
expect(updateModelPricingBatch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips startup network access when pricing synced within the interval", async () => {
|
|
const lastSyncAt = Date.now() - MODELS_DEV_STARTUP_SYNC_INTERVAL_MS + 1;
|
|
getModelsDevSyncConfig.mockResolvedValue({
|
|
...state,
|
|
config: { ...state.config, lastSyncAt },
|
|
});
|
|
|
|
const result = await syncModelsDevPricing();
|
|
|
|
expect(result).toEqual({
|
|
skipped: true,
|
|
selected: 0,
|
|
imported: 0,
|
|
changed: 0,
|
|
syncedAt: lastSyncAt,
|
|
});
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
expect(updateModelPricingBatch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("imports common and explicitly selected models in one batch", async () => {
|
|
getModelsDevSyncConfig.mockResolvedValue(state);
|
|
|
|
const result = await syncModelsDevPricing();
|
|
|
|
expect(updateModelPricingBatch).toHaveBeenCalledTimes(1);
|
|
expect(updateModelPricingBatch).toHaveBeenCalledWith([
|
|
expect.objectContaining({ modelId: "gpt-5", inputCostPerMillion: "1" }),
|
|
expect.objectContaining({
|
|
modelId: "custom-model",
|
|
inputCostPerMillion: "0.5",
|
|
}),
|
|
]);
|
|
expect(result).toMatchObject({
|
|
skipped: false,
|
|
selected: 2,
|
|
imported: 2,
|
|
changed: 2,
|
|
});
|
|
expect(recordModelsDevSyncResult).toHaveBeenCalledWith(
|
|
expect.any(Number),
|
|
null,
|
|
);
|
|
const fetchOptions = vi.mocked(fetch).mock.calls[0]?.[1];
|
|
expect(fetchOptions).toEqual({ signal: expect.any(AbortSignal) });
|
|
expect(fetchOptions).not.toHaveProperty("cache");
|
|
});
|
|
|
|
it("stops a startup sync when automatic sync is disabled during download", async () => {
|
|
getModelsDevSyncConfig.mockResolvedValueOnce(state).mockResolvedValueOnce({
|
|
...state,
|
|
config: { ...state.config, autoSyncEnabled: false, lastSyncAt: 123 },
|
|
});
|
|
|
|
const result = await syncModelsDevPricing();
|
|
|
|
expect(result).toEqual({
|
|
skipped: true,
|
|
selected: 0,
|
|
imported: 0,
|
|
changed: 0,
|
|
syncedAt: 123,
|
|
});
|
|
expect(updateModelPricingBatch).not.toHaveBeenCalled();
|
|
expect(recordModelsDevSyncResult).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses the latest model selection after the download completes", async () => {
|
|
getModelsDevSyncConfig.mockResolvedValueOnce(state).mockResolvedValueOnce({
|
|
...state,
|
|
config: {
|
|
...state.config,
|
|
includeCommonModels: false,
|
|
selectedModelKeys: ["relay/custom-model"],
|
|
},
|
|
});
|
|
|
|
await syncModelsDevPricing();
|
|
|
|
expect(updateModelPricingBatch).toHaveBeenCalledWith([
|
|
expect.objectContaining({ modelId: "custom-model" }),
|
|
]);
|
|
});
|
|
|
|
it("uses the latest selection for a forced sync even when automatic sync is disabled", async () => {
|
|
getModelsDevSyncConfig.mockResolvedValueOnce({
|
|
...state,
|
|
config: {
|
|
...state.config,
|
|
autoSyncEnabled: false,
|
|
includeCommonModels: false,
|
|
selectedModelKeys: ["relay/custom-model"],
|
|
lastSyncAt: Date.now(),
|
|
},
|
|
});
|
|
|
|
const result = await syncModelsDevPricing(state, true);
|
|
|
|
expect(result.skipped).toBe(false);
|
|
expect(updateModelPricingBatch).toHaveBeenCalledWith([
|
|
expect.objectContaining({ modelId: "custom-model" }),
|
|
]);
|
|
});
|
|
|
|
it("persists the last error without replacing the previous success time", async () => {
|
|
const previous = { ...state, config: { ...state.config, lastSyncAt: 123 } };
|
|
getModelsDevSyncConfig.mockResolvedValue(previous);
|
|
vi.mocked(fetch).mockRejectedValueOnce(new Error("offline"));
|
|
|
|
await expect(syncModelsDevPricing()).rejects.toThrow("offline");
|
|
expect(recordModelsDevSyncResult).toHaveBeenCalledWith(null, "offline");
|
|
});
|
|
});
|