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
@@ -5,6 +5,11 @@ import {
formatPrice,
normalizeModelIdForPricing,
} from "@/components/usage/ModelsDevPickerDialog";
import {
getCommonModelKeys,
resolveModelsDevSelection,
toModelPricing,
} from "@/lib/modelsDevPricing";
describe("normalizeModelIdForPricing", () => {
it("keeps already-normalized ids unchanged", () => {
@@ -141,4 +146,183 @@ describe("flattenModels", () => {
// 完全没有定价的模型被过滤
expect(entries.some((e) => e.modelId === "free-model")).toBe(false);
});
it("filters deprecated and non-text output models while keeping multimodal input models", () => {
const entries = flattenModels({
acme: {
models: {
"multimodal-chat": {
name: "Multimodal Chat",
modalities: {
input: ["text", "image", "audio", "video"],
output: ["text"],
},
cost: { input: 1, output: 2 },
},
"legacy-chat": {
status: "deprecated",
modalities: { output: ["text"] },
cost: { input: 1, output: 2 },
},
"speech-model": {
modalities: { output: ["audio"] },
cost: { input: 1, output: 2 },
},
"mixed-output-model": {
modalities: { output: ["text", "audio"] },
cost: { input: 1, output: 2 },
},
"movie-generator": {
modalities: { output: ["video"] },
cost: { input: 1, output: 2 },
},
"fallback-video-model": {
cost: { input: 1, output: 2 },
},
},
},
});
expect(entries.map((entry) => entry.modelId)).toEqual(["multimodal-chat"]);
});
it("selects a bounded canonical set of common model families", () => {
const openAiModels = Object.fromEntries(
Array.from({ length: 7 }, (_, index) => {
const version = index + 1;
return [
`gpt-${version}`,
{
name: `GPT ${version}`,
release_date: `2025-0${version}-01`,
cost: { input: version, output: version * 2 },
},
];
}),
);
const entries = flattenModels({
openai: {
name: "OpenAI",
models: {
...openAiModels,
"gpt-image-1": {
release_date: "2026-01-01",
cost: { input: 1, output: 2 },
},
},
},
aggregator: {
name: "Aggregator",
models: {
"gpt-7": {
release_date: "2026-02-01",
cost: { input: 9, output: 18 },
},
},
},
anthropic: {
name: "Anthropic",
models: {
"claude-sonnet-5": {
release_date: "2026-06-01",
cost: { input: 3, output: 15 },
},
},
},
deepseek: {
name: "DeepSeek",
models: {
"deepseek-chat": {
release_date: "2025-12-01",
cost: { input: 0.3, output: 1.2 },
},
},
},
xiaomi: {
name: "Xiaomi",
models: {
"mimo-v2.5": {
release_date: "2026-04-01",
cost: { input: 0.2, output: 1 },
},
"mimo-v2.5-tts": {
release_date: "2026-05-01",
cost: { input: 0.1, output: 0.5 },
},
},
},
longcat: {
name: "LongCat",
models: {
"LongCat-2.0": {
release_date: "2026-03-01",
cost: { input: 0.4, output: 1.6 },
},
},
},
});
const common = getCommonModelKeys(entries);
expect(common.has("openai/gpt-image-1")).toBe(false);
expect(common.has("aggregator/gpt-7")).toBe(false);
expect(common.has("openai/gpt-7")).toBe(true);
expect(common.has("openai/gpt-1")).toBe(false);
expect(common.has("anthropic/claude-sonnet-5")).toBe(true);
expect(common.has("deepseek/deepseek-chat")).toBe(true);
expect(common.has("xiaomi/mimo-v2.5")).toBe(true);
expect(common.has("xiaomi/mimo-v2.5-tts")).toBe(false);
expect(common.has("longcat/LongCat-2.0")).toBe(true);
});
it("combines common and explicit selections and deduplicates normalized ids", () => {
const entries = flattenModels({
openai: {
models: {
"gpt-5": {
name: "GPT-5 Official",
release_date: "2025-08-01",
cost: { input: 1, output: 2 },
},
},
},
relay: {
models: {
"vendor/GPT-5": {
name: "GPT-5 Relay",
release_date: "2025-07-01",
cost: { input: 9, output: 18 },
},
"custom-model": {
name: "Custom",
release_date: "2025-06-01",
cost: { input: 0.5, output: 1 },
},
},
},
});
const selected = resolveModelsDevSelection(entries, {
autoSyncEnabled: true,
includeCommonModels: true,
selectedModelKeys: ["relay/vendor/GPT-5", "relay/custom-model"],
excludedCommonModelKeys: ["openai/gpt-5"],
lastSyncAt: null,
lastSyncError: null,
});
expect(selected.map((entry) => entry.key)).toEqual([
"relay/vendor/GPT-5",
"relay/custom-model",
]);
const pricing = toModelPricing([
entries.find((entry) => entry.key === "openai/gpt-5")!,
entries.find((entry) => entry.key === "relay/vendor/GPT-5")!,
]);
expect(pricing).toHaveLength(1);
expect(pricing[0]).toMatchObject({
modelId: "gpt-5",
displayName: "GPT-5 Official",
inputCostPerMillion: "1",
});
});
});