mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
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:
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user