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
+23
View File
@@ -8,6 +8,8 @@ import type {
RequestLog,
LogFilters,
ModelPricing,
ModelsDevSyncConfig,
ModelsDevSyncState,
ProviderLimitStatus,
PaginatedLogs,
SessionSyncResult,
@@ -164,6 +166,27 @@ export const usageApi = {
});
},
updateModelPricingBatch: async (entries: ModelPricing[]): Promise<number> => {
return invoke("update_model_pricing_batch", { entries });
},
getModelsDevSyncConfig: async (): Promise<ModelsDevSyncState> => {
return invoke("get_models_dev_sync_config");
},
saveModelsDevSyncConfig: async (
config: ModelsDevSyncConfig,
): Promise<void> => {
return invoke("save_models_dev_sync_config", { config });
},
recordModelsDevSyncResult: async (
syncedAt: number | null,
error: string | null,
): Promise<void> => {
return invoke("record_models_dev_sync_result", { syncedAt, error });
},
deleteModelPricing: async (modelId: string): Promise<void> => {
return invoke("delete_model_pricing", { modelId });
},
+93
View File
@@ -0,0 +1,93 @@
import { usageApi } from "@/lib/api/usage";
import {
fetchModelsDevPricing,
flattenModels,
resolveModelsDevSelection,
toModelPricing,
} from "@/lib/modelsDevPricing";
import type { ModelsDevSyncState } from "@/types/usage";
export interface ModelsDevSyncResult {
skipped: boolean;
selected: number;
imported: number;
changed: number;
syncedAt: number | null;
}
export const MODELS_DEV_SYNC_CONFIG_QUERY_KEY = [
"models-dev-sync-config",
] as const;
export const MODELS_DEV_STARTUP_SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;
const errorMessage = (error: unknown) =>
error instanceof Error ? error.message : String(error);
export async function syncModelsDevPricing(
state?: ModelsDevSyncState,
force = false,
): Promise<ModelsDevSyncResult> {
const initialState = state ?? (await usageApi.getModelsDevSyncConfig());
const recentlySynced =
initialState.config.lastSyncAt !== null &&
Date.now() - initialState.config.lastSyncAt <
MODELS_DEV_STARTUP_SYNC_INTERVAL_MS;
if (!force && (!initialState.config.autoSyncEnabled || recentlySynced)) {
return {
skipped: true,
selected: 0,
imported: 0,
changed: 0,
syncedAt: initialState.config.lastSyncAt,
};
}
try {
const data = await fetchModelsDevPricing();
const latestState = await usageApi.getModelsDevSyncConfig();
if (!force && !latestState.config.autoSyncEnabled) {
return {
skipped: true,
selected: 0,
imported: 0,
changed: 0,
syncedAt: latestState.config.lastSyncAt,
};
}
const selectedEntries = resolveModelsDevSelection(
flattenModels(data),
latestState.config,
);
const pricing = toModelPricing(selectedEntries);
const changed = pricing.length
? await usageApi.updateModelPricingBatch(pricing)
: 0;
const syncedAt = Date.now();
await usageApi.recordModelsDevSyncResult(syncedAt, null);
return {
skipped: false,
selected: selectedEntries.length,
imported: pricing.length,
changed,
syncedAt,
};
} catch (error) {
try {
await usageApi.recordModelsDevSyncResult(null, errorMessage(error));
} catch (saveError) {
console.warn(
"[models.dev] Failed to persist automatic sync error",
saveError,
);
}
throw error;
}
}
let startupSync: Promise<ModelsDevSyncResult> | null = null;
/** Run once per renderer and at most once per interval across WebView rebuilds. */
export function syncModelsDevPricingOnStartup(): Promise<ModelsDevSyncResult> {
startupSync ??= syncModelsDevPricing();
return startupSync;
}
+277
View File
@@ -0,0 +1,277 @@
import type { ModelPricing, ModelsDevSyncConfig } from "@/types/usage";
export const MODELS_DEV_API_URL = "https://models.dev/api.json";
const MODELS_DEV_FETCH_TIMEOUT_MS = 15_000;
export interface ModelsDevCost {
input?: number;
output?: number;
cache_read?: number;
cache_write?: number;
}
export interface ModelsDevModalities {
input?: string[];
output?: string[];
}
export interface ModelsDevModel {
id?: string;
name?: string;
release_date?: string;
cost?: ModelsDevCost;
modalities?: ModelsDevModalities;
status?: string;
}
export interface ModelsDevProvider {
id?: string;
name?: string;
models?: Record<string, ModelsDevModel>;
}
export type ModelsDevResponse = Record<string, ModelsDevProvider>;
export interface ModelsDevEntry {
key: string;
providerId: string;
providerName: string;
modelId: string;
normalizedId: string;
modelName: string;
releaseDate: string;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
}
const NON_TEXT_MODEL_MARKERS = [
"audio",
"deprecated",
"embedding",
"image",
"moderation",
"realtime",
"transcribe",
"tts",
"video",
];
const NON_TEXT_OUTPUT_MODALITIES = new Set(["audio", "image", "video"]);
const isTextPricingModel = (modelId: string, model?: ModelsDevModel) => {
if (model?.status?.toLowerCase() === "deprecated") return false;
const outputModalities = model?.modalities?.output
?.filter((modality): modality is string => typeof modality === "string")
.map((modality) => modality.toLowerCase());
if (
outputModalities?.length &&
(!outputModalities.includes("text") ||
outputModalities.some((modality) =>
NON_TEXT_OUTPUT_MODALITIES.has(modality),
))
) {
return false;
}
const searchableName = `${modelId} ${model?.name ?? ""}`.toLowerCase();
return !NON_TEXT_MODEL_MARKERS.some((marker) =>
searchableName.includes(marker),
);
};
export function normalizeModelIdForPricing(modelId: string): string {
const afterSlash = modelId.slice(modelId.lastIndexOf("/") + 1);
const beforeColon = afterSlash.split(":")[0] ?? "";
let normalized = beforeColon.trim().replace(/@/g, "-").toLowerCase();
if (normalized.endsWith("[1m]")) {
normalized = normalized.slice(0, -"[1m]".length).trim();
}
return normalized;
}
export function formatPrice(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "0";
if (value >= 1e12) return "0";
const trimmed = value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
return trimmed || "0";
}
export function flattenModels(data: ModelsDevResponse): ModelsDevEntry[] {
const entries: ModelsDevEntry[] = [];
for (const [providerId, provider] of Object.entries(data)) {
if (!provider || typeof provider !== "object") continue;
const providerName = provider.name || providerId;
for (const [modelId, model] of Object.entries(provider.models ?? {})) {
if (!isTextPricingModel(modelId, model)) continue;
const cost = model?.cost;
const input = typeof cost?.input === "number" ? cost.input : null;
const output = typeof cost?.output === "number" ? cost.output : null;
if (input === null && output === null) continue;
const normalizedId = normalizeModelIdForPricing(modelId);
if (!normalizedId) continue;
entries.push({
key: `${providerId}/${modelId}`,
providerId,
providerName,
modelId,
normalizedId,
modelName: model?.name || modelId,
releaseDate:
typeof model?.release_date === "string" ? model.release_date : "",
input: input ?? 0,
output: output ?? 0,
cacheRead: typeof cost?.cache_read === "number" ? cost.cache_read : 0,
cacheWrite:
typeof cost?.cache_write === "number" ? cost.cache_write : 0,
});
}
}
entries.sort(
(a, b) =>
b.releaseDate.localeCompare(a.releaseDate) ||
a.modelName.localeCompare(b.modelName),
);
return entries;
}
export async function fetchModelsDevPricing(): Promise<ModelsDevResponse> {
const controller = new AbortController();
const timeout = window.setTimeout(
() => controller.abort(),
MODELS_DEV_FETCH_TIMEOUT_MS,
);
try {
const response = await fetch(MODELS_DEV_API_URL, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return (await response.json()) as ModelsDevResponse;
} finally {
window.clearTimeout(timeout);
}
}
const COMMON_MODEL_LIMIT_PER_FAMILY = 6;
interface CommonFamilyRule {
id: string;
providers: ReadonlySet<string>;
matches: (modelId: string) => boolean;
}
const COMMON_FAMILY_RULES: CommonFamilyRule[] = [
{
id: "claude",
providers: new Set(["anthropic"]),
matches: (modelId) => modelId.startsWith("claude-"),
},
{
id: "gpt",
providers: new Set(["openai"]),
matches: (modelId) =>
modelId.startsWith("gpt-") ||
modelId.startsWith("o1-") ||
modelId.startsWith("o3-") ||
modelId.startsWith("o4-"),
},
{
id: "gemini",
providers: new Set(["google"]),
matches: (modelId) => modelId.startsWith("gemini-"),
},
{
id: "grok",
providers: new Set(["xai"]),
matches: (modelId) => modelId.startsWith("grok-"),
},
{
id: "deepseek",
providers: new Set(["deepseek"]),
matches: (modelId) => modelId.startsWith("deepseek-"),
},
{
id: "qwen",
providers: new Set(["alibaba"]),
matches: (modelId) => modelId.startsWith("qwen"),
},
{
id: "mimo",
providers: new Set(["xiaomi"]),
matches: (modelId) => modelId.startsWith("mimo-"),
},
{
id: "longcat",
providers: new Set(["longcat"]),
matches: (modelId) => modelId.startsWith("longcat-"),
},
{
id: "kimi",
providers: new Set(["moonshotai"]),
matches: (modelId) => modelId.startsWith("kimi-"),
},
{
id: "minimax",
providers: new Set(["minimax-cn"]),
matches: (modelId) => modelId.startsWith("minimax-m"),
},
{
id: "glm",
providers: new Set(["zai"]),
matches: (modelId) => modelId.startsWith("glm-"),
},
];
/** Pick a bounded, canonical set of recent chat/coding models per family. */
export function getCommonModelKeys(entries: ModelsDevEntry[]): Set<string> {
const keys = new Set<string>();
for (const rule of COMMON_FAMILY_RULES) {
let count = 0;
for (const entry of entries) {
if (
rule.providers.has(entry.providerId) &&
rule.matches(entry.modelId.toLowerCase())
) {
keys.add(entry.key);
count += 1;
if (count >= COMMON_MODEL_LIMIT_PER_FAMILY) break;
}
}
}
return keys;
}
export function resolveModelsDevSelection(
entries: ModelsDevEntry[],
config: ModelsDevSyncConfig,
): ModelsDevEntry[] {
const explicit = new Set(config.selectedModelKeys);
const excluded = new Set(config.excludedCommonModelKeys);
const common = config.includeCommonModels
? getCommonModelKeys(entries)
: new Set<string>();
return entries.filter(
(entry) =>
explicit.has(entry.key) ||
(common.has(entry.key) && !excluded.has(entry.key)),
);
}
export function toModelPricing(entries: ModelsDevEntry[]): ModelPricing[] {
const byModelId = new Map<string, ModelPricing>();
for (const entry of entries) {
if (byModelId.has(entry.normalizedId)) continue;
byModelId.set(entry.normalizedId, {
modelId: entry.normalizedId,
displayName: entry.modelName,
inputCostPerMillion: formatPrice(entry.input),
outputCostPerMillion: formatPrice(entry.output),
cacheReadCostPerMillion: formatPrice(entry.cacheRead),
cacheCreationCostPerMillion: formatPrice(entry.cacheWrite),
});
}
return Array.from(byModelId.values());
}