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,668 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Check,
FolderOpen,
Loader2,
RefreshCw,
Search,
Settings2,
} from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { settingsApi } from "@/lib/api/settings";
import { usageApi } from "@/lib/api/usage";
import {
MODELS_DEV_SYNC_CONFIG_QUERY_KEY,
syncModelsDevPricing,
} from "@/lib/modelsDevAutoSync";
import {
fetchModelsDevPricing,
flattenModels,
formatPrice,
getCommonModelKeys,
type ModelsDevEntry,
} from "@/lib/modelsDevPricing";
import { usageKeys } from "@/lib/query/usage";
import type { ModelsDevSyncConfig, ModelsDevSyncState } from "@/types/usage";
import { isTextEditableTarget } from "@/utils/domUtils";
const MODELS_DEV_QUERY_KEY = ["models-dev-pricing"] as const;
const DEFAULT_VISIBLE_ROWS = 80;
const MAX_VISIBLE_ROWS = 300;
interface AutoSyncDialogProps {
state: ModelsDevSyncState;
onClose: () => void;
onSaved: (state: ModelsDevSyncState) => void;
}
function AutoSyncDialog({ state, onClose, onSaved }: AutoSyncDialogProps) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [providerFilter, setProviderFilter] = useState("all");
const [includeCommonModels, setIncludeCommonModels] = useState(
state.config.includeCommonModels,
);
const [selectedModelKeys, setSelectedModelKeys] = useState(
() => new Set(state.config.selectedModelKeys),
);
const [excludedCommonModelKeys, setExcludedCommonModelKeys] = useState(
() => new Set(state.config.excludedCommonModelKeys),
);
const [isSaving, setIsSaving] = useState(false);
const { data, isLoading, error, refetch } = useQuery({
queryKey: MODELS_DEV_QUERY_KEY,
queryFn: fetchModelsDevPricing,
staleTime: 60 * 60 * 1000,
retry: 1,
});
const entries = useMemo(() => (data ? flattenModels(data) : []), [data]);
const commonModelKeys = useMemo(() => getCommonModelKeys(entries), [entries]);
const effectiveSelectedKeys = useMemo(() => {
const selected = new Set(selectedModelKeys);
if (includeCommonModels) {
for (const key of commonModelKeys) {
if (!excludedCommonModelKeys.has(key)) selected.add(key);
}
}
return selected;
}, [
commonModelKeys,
excludedCommonModelKeys,
includeCommonModels,
selectedModelKeys,
]);
const providers = useMemo(() => {
const map = new Map<string, string>();
for (const entry of entries) {
if (!map.has(entry.providerId)) {
map.set(entry.providerId, entry.providerName);
}
}
return Array.from(map, ([id, name]) => ({ id, name })).sort((a, b) =>
a.name.localeCompare(b.name),
);
}, [entries]);
const isFiltering = search.trim() !== "" || providerFilter !== "all";
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
return entries.filter(
(entry) =>
(providerFilter === "all" || entry.providerId === providerFilter) &&
(!query ||
entry.modelId.toLowerCase().includes(query) ||
entry.normalizedId.includes(query) ||
entry.modelName.toLowerCase().includes(query) ||
entry.providerName.toLowerCase().includes(query)),
);
}, [entries, providerFilter, search]);
const visible = useMemo(
() =>
filtered.slice(0, isFiltering ? MAX_VISIBLE_ROWS : DEFAULT_VISIBLE_ROWS),
[filtered, isFiltering],
);
const toggleEntry = (entry: ModelsDevEntry) => {
const isSelected = effectiveSelectedKeys.has(entry.key);
setSelectedModelKeys((previous) => {
const next = new Set(previous);
if (isSelected) next.delete(entry.key);
else next.add(entry.key);
return next;
});
setExcludedCommonModelKeys((previous) => {
const next = new Set(previous);
if (isSelected && includeCommonModels && commonModelKeys.has(entry.key)) {
next.add(entry.key);
} else {
next.delete(entry.key);
}
return next;
});
};
const selectFiltered = () => {
setSelectedModelKeys((previous) => {
const next = new Set(previous);
for (const entry of filtered) next.add(entry.key);
return next;
});
setExcludedCommonModelKeys((previous) => {
const next = new Set(previous);
for (const entry of filtered) next.delete(entry.key);
return next;
});
};
const clearFiltered = () => {
setSelectedModelKeys((previous) => {
const next = new Set(previous);
for (const entry of filtered) next.delete(entry.key);
return next;
});
if (includeCommonModels) {
setExcludedCommonModelKeys((previous) => {
const next = new Set(previous);
for (const entry of filtered) {
if (commonModelKeys.has(entry.key)) next.add(entry.key);
}
return next;
});
}
};
const save = async () => {
setIsSaving(true);
try {
const config: ModelsDevSyncConfig = {
...state.config,
includeCommonModels,
selectedModelKeys: Array.from(selectedModelKeys).sort(),
excludedCommonModelKeys: Array.from(excludedCommonModelKeys).sort(),
};
await usageApi.saveModelsDevSyncConfig(config);
onSaved({ ...state, config });
toast.success(t("usage.modelsDevAutoSync.selectionSaved"));
onClose();
} catch (saveError) {
toast.error(String(saveError));
} finally {
setIsSaving(false);
}
};
const priceColumns = (entry: ModelsDevEntry) =>
[
{ label: t("usage.inputCost"), value: entry.input },
{ label: t("usage.outputCost"), value: entry.output },
{ label: t("usage.cacheReadCost"), value: entry.cacheRead },
{ label: t("usage.cacheWriteCost"), value: entry.cacheWrite },
] as const;
return (
<Dialog open onOpenChange={(open) => !open && !isSaving && onClose()}>
<DialogContent
zIndex="top"
className="max-w-4xl h-[84vh]"
onEscapeKeyDown={(event) => {
if (isTextEditableTarget(event.target)) event.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>
{t("usage.modelsDevAutoSync.configureTitle")}
</DialogTitle>
<DialogDescription>
{t("usage.modelsDevAutoSync.configureDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-1 min-h-0 flex-col gap-3 px-6 py-4">
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/50 bg-muted/20 px-3 py-2.5">
<div>
<div className="text-sm font-medium">
{t("usage.modelsDevAutoSync.commonModels")}
</div>
<div className="text-xs text-muted-foreground">
{t("usage.modelsDevAutoSync.commonModelsDescription", {
count: commonModelKeys.size,
})}
</div>
</div>
<Switch
checked={includeCommonModels}
onCheckedChange={setIncludeCommonModels}
aria-label={t("usage.modelsDevAutoSync.commonModels")}
/>
</div>
{isLoading ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<Alert variant="destructive">
<AlertDescription className="flex items-center justify-between gap-3">
<span>
{t("usage.modelsDevLoadError")}: {String(error)}
</span>
<Button variant="outline" size="sm" onClick={() => refetch()}>
{t("usage.modelsDevRetry")}
</Button>
</AlertDescription>
</Alert>
) : (
<>
<div className="flex items-center gap-2">
<Select
value={providerFilter}
onValueChange={setProviderFilter}
>
<SelectTrigger className="w-48 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[120] max-h-[min(24rem,var(--radix-select-content-available-height))]">
<SelectItem value="all">
{t("usage.modelsDevAllProviders")}
</SelectItem>
{providers.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("usage.modelsDevSearchPlaceholder")}
className="pl-8"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={selectFiltered}
disabled={filtered.length === 0}
>
{t("usage.modelsDevAutoSync.selectFiltered", {
count: filtered.length,
})}
</Button>
<Button
variant="outline"
size="sm"
onClick={clearFiltered}
disabled={filtered.length === 0}
>
{t("usage.modelsDevAutoSync.clearFiltered")}
</Button>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{t("usage.modelsDevAutoSync.selectedCount", {
count: effectiveSelectedKeys.size,
})}
</span>
<span>{t("usage.modelsDevAutoSync.selectionHint")}</span>
</div>
<div className="flex-1 min-h-0 overflow-y-auto rounded-md border border-border/50">
{filtered.length === 0 ? (
<div className="flex h-full items-center justify-center py-8 text-sm text-muted-foreground">
{t("usage.modelsDevNoResults")}
</div>
) : (
<div className="divide-y divide-border/30">
{visible.map((entry) => {
const selected = effectiveSelectedKeys.has(entry.key);
const common = commonModelKeys.has(entry.key);
return (
<button
key={entry.key}
type="button"
aria-pressed={selected}
onClick={() => toggleEntry(entry)}
className={`flex w-full items-center gap-3 px-3 py-2 text-left ${
selected ? "bg-accent/50" : "hover:bg-muted/40"
}`}
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
selected
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/50"
}`}
>
{selected && <Check className="h-3 w-3" />}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">
{entry.modelName}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{entry.providerName}
</span>
{common && (
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">
{t("usage.modelsDevAutoSync.commonBadge")}
</span>
)}
{entry.releaseDate && (
<span className="shrink-0 text-[10px] text-muted-foreground/70">
{entry.releaseDate}
</span>
)}
</div>
<div
className="truncate font-mono text-xs text-muted-foreground"
title={entry.modelId}
>
{entry.normalizedId}
</div>
</div>
<div className="flex shrink-0 gap-3 text-right">
{priceColumns(entry).map((column) => (
<div key={column.label} className="w-16">
<div className="text-[10px] text-muted-foreground">
{column.label}
</div>
<div className="font-mono text-xs">
${formatPrice(column.value)}
</div>
</div>
))}
</div>
</button>
);
})}
{filtered.length > visible.length && (
<div className="px-3 py-2 text-center text-xs text-muted-foreground">
{isFiltering
? t("usage.modelsDevTruncated", {
shown: visible.length,
total: filtered.length,
})
: t("usage.modelsDevDefaultHint", {
shown: visible.length,
total: filtered.length,
})}
</div>
)}
</div>
)}
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isSaving}>
{t("common.cancel")}
</Button>
<Button onClick={save} disabled={isSaving || isLoading || !!error}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function ModelsDevAutoSyncPanel() {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [isReloading, setIsReloading] = useState(false);
const [showEnableConfirm, setShowEnableConfirm] = useState(false);
const { data, isLoading, error, refetch } = useQuery({
queryKey: MODELS_DEV_SYNC_CONFIG_QUERY_KEY,
queryFn: usageApi.getModelsDevSyncConfig,
staleTime: Number.POSITIVE_INFINITY,
});
const updateCachedState = (state: ModelsDevSyncState) => {
queryClient.setQueryData(MODELS_DEV_SYNC_CONFIG_QUERY_KEY, state);
};
const saveConfig = async (config: ModelsDevSyncConfig) => {
if (!data) return;
setIsSaving(true);
try {
await usageApi.saveModelsDevSyncConfig(config);
updateCachedState({ ...data, config });
} catch (saveError) {
toast.error(String(saveError));
} finally {
setIsSaving(false);
}
};
const syncNow = async () => {
if (!data) return;
setIsSyncing(true);
try {
const result = await syncModelsDevPricing(data, true);
await Promise.all([
refetch(),
queryClient.invalidateQueries({ queryKey: usageKeys.all }),
]);
toast.success(
t("usage.modelsDevAutoSync.syncSuccess", {
imported: result.imported,
changed: result.changed,
}),
);
} catch (syncError) {
await refetch();
toast.error(
t("usage.modelsDevAutoSync.syncFailed", { error: String(syncError) }),
);
} finally {
setIsSyncing(false);
}
};
const reloadLocalFile = async () => {
setIsReloading(true);
try {
await usageApi.getModelPricing();
await Promise.all([
refetch(),
queryClient.invalidateQueries({ queryKey: usageKeys.all }),
]);
toast.success(t("usage.modelsDevAutoSync.localFileReloaded"));
} catch (reloadError) {
toast.error(
t("usage.modelsDevAutoSync.localFileReloadFailed", {
error: String(reloadError),
}),
);
} finally {
setIsReloading(false);
}
};
const openLocalFileFolder = async () => {
try {
await settingsApi.openAppConfigFolder();
} catch (openError) {
toast.error(
t("usage.modelsDevAutoSync.openFolderFailed", {
error: String(openError),
}),
);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center rounded-lg border border-border/50 py-6">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (error || !data) {
return (
<Alert variant="destructive">
<AlertDescription className="flex items-center justify-between gap-3">
<span>
{t("usage.modelsDevAutoSync.configLoadFailed", {
error: String(error),
})}
</span>
<Button variant="outline" size="sm" onClick={() => refetch()}>
{t("usage.modelsDevRetry")}
</Button>
</AlertDescription>
</Alert>
);
}
const lastSync = data.config.lastSyncAt
? new Date(data.config.lastSyncAt).toLocaleString(i18n.resolvedLanguage)
: t("usage.modelsDevAutoSync.neverSynced");
const handleAutoSyncChange = (autoSyncEnabled: boolean) => {
if (autoSyncEnabled) {
setShowEnableConfirm(true);
return;
}
void saveConfig({ ...data.config, autoSyncEnabled: false });
};
return (
<>
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/15 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<h5 className="text-sm font-medium">
{t("usage.modelsDevAutoSync.title")}
</h5>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("usage.modelsDevAutoSync.description")}
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{data.config.autoSyncEnabled
? t("usage.modelsDevAutoSync.enabled")
: t("usage.modelsDevAutoSync.disabled")}
</span>
<Switch
checked={data.config.autoSyncEnabled}
disabled={isSaving}
onCheckedChange={handleAutoSyncChange}
aria-label={t("usage.modelsDevAutoSync.title")}
/>
</div>
</div>
<div className="grid gap-2 text-xs text-muted-foreground md:grid-cols-2">
<div>
{t("usage.modelsDevAutoSync.lastSync")}: {lastSync}
</div>
<div>
{t("usage.modelsDevAutoSync.commonStatus")}:{" "}
{data.config.includeCommonModels
? t("usage.modelsDevAutoSync.enabled")
: t("usage.modelsDevAutoSync.disabled")}
</div>
</div>
{data.config.lastSyncError && (
<Alert variant="destructive">
<AlertDescription>
{t("usage.modelsDevAutoSync.lastError", {
error: data.config.lastSyncError,
})}
</AlertDescription>
</Alert>
)}
<div className="rounded-md bg-background/60 px-3 py-2">
<div className="text-[11px] text-muted-foreground">
{t("usage.modelsDevAutoSync.localFile")}
</div>
<div className="truncate font-mono text-xs" title={data.configPath}>
{data.configPath}
</div>
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void openLocalFileFolder()}
>
<FolderOpen className="mr-1.5 h-3.5 w-3.5" />
{t("usage.modelsDevAutoSync.openFolder")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void reloadLocalFile()}
disabled={isReloading}
>
{isReloading ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
)}
{t("usage.modelsDevAutoSync.reloadLocalFile")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setIsDialogOpen(true)}
>
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
{t("usage.modelsDevAutoSync.configure")}
</Button>
<Button size="sm" onClick={() => void syncNow()} disabled={isSyncing}>
{isSyncing ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
)}
{t("usage.modelsDevAutoSync.syncNow")}
</Button>
</div>
</div>
{isDialogOpen && (
<AutoSyncDialog
state={data}
onClose={() => setIsDialogOpen(false)}
onSaved={updateCachedState}
/>
)}
<ConfirmDialog
isOpen={showEnableConfirm}
title={t("usage.modelsDevAutoSync.enableConfirmTitle")}
message={t("usage.modelsDevAutoSync.enableConfirmMessage")}
confirmText={t("usage.modelsDevAutoSync.enableConfirmAction")}
variant="destructive"
onConfirm={() => {
setShowEnableConfirm(false);
void saveConfig({ ...data.config, autoSyncEnabled: true });
}}
onCancel={() => setShowEnableConfirm(false)}
/>
</>
);
}
+13 -109
View File
@@ -22,114 +22,24 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useUpdateModelPricing } from "@/lib/query/usage";
import {
fetchModelsDevPricing,
flattenModels,
formatPrice,
type ModelsDevEntry,
} from "@/lib/modelsDevPricing";
import { isTextEditableTarget } from "@/utils/domUtils";
const MODELS_DEV_API_URL = "https://models.dev/api.json";
export {
flattenModels,
formatPrice,
normalizeModelIdForPricing,
} from "@/lib/modelsDevPricing";
// 全量约 5000 条:默认只展示最新发布的一批,搜索时才做全量匹配
const DEFAULT_VISIBLE_ROWS = 50;
const MAX_VISIBLE_ROWS = 200;
interface ModelsDevCost {
input?: number;
output?: number;
cache_read?: number;
cache_write?: number;
}
interface ModelsDevModel {
id?: string;
name?: string;
release_date?: string;
cost?: ModelsDevCost;
}
interface ModelsDevProvider {
id?: string;
name?: string;
models?: Record<string, ModelsDevModel>;
}
type ModelsDevResponse = Record<string, ModelsDevProvider>;
interface ModelsDevEntry {
/** providerId/modelId,同一模型可能出现在多个供应商下 */
key: string;
providerId: string;
providerName: string;
modelId: string;
/** 实际入库的 ID,与后端 clean_model_id_for_pricing 的归一化规则一致 */
normalizedId: string;
modelName: string;
/** YYYY-MM-DD 或 YYYY-MM,缺失时为空串 */
releaseDate: string;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
}
/**
* 与后端 clean_model_id_for_pricingusage_stats.rs)保持一致:
* 取最后一个 '/' 之后的段、去掉 ':' 后缀、'@' 换成 '-'、转小写、去掉 [1m] 标记。
* 成本归因查询用的就是这种归一化形式,原样入库的 ID 永远匹配不上。
*/
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;
}
/** 转成后端可解析的非负十进制字符串(不能用 String(),小数可能变成科学计数法) */
export function formatPrice(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "0";
// toFixed 对 >=1e21 会退化成科学计数法;这种量级的"价格"只可能是脏数据,按 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 ?? {})) {
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;
}
interface ModelsDevPickerDialogProps {
open: boolean;
onClose: () => void;
@@ -160,13 +70,7 @@ export function ModelsDevPickerDialog({
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["models-dev-pricing"],
queryFn: async (): Promise<ModelsDevResponse> => {
const res = await fetch(MODELS_DEV_API_URL);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
},
queryFn: fetchModelsDevPricing,
enabled: open,
staleTime: 60 * 60 * 1000,
retry: 1,
@@ -32,6 +32,7 @@ import { isNonNegativeDecimalString, type ModelPricing } from "@/types/usage";
import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { proxyApi } from "@/lib/api/proxy";
import { ModelsDevAutoSyncPanel } from "./ModelsDevAutoSyncPanel";
const PRICING_APPS = ["claude", "codex", "gemini", "grokbuild"] as const;
type PricingApp = (typeof PRICING_APPS)[number];
@@ -341,6 +342,8 @@ export function PricingConfigPanel() {
{/* 模型定价配置 */}
<div className="space-y-4">
<ModelsDevAutoSyncPanel />
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-muted-foreground">
{t("usage.modelPricingDesc")} {t("usage.perMillion")}
+34
View File
@@ -1633,6 +1633,40 @@
"modelsDevNoResults": "No matching models",
"modelsDevTruncated": "Showing first {{shown}} of {{total}} results — refine your search",
"modelsDevDefaultHint": "Showing the {{shown}} most recently released models (of {{total}}) — type to search all",
"modelsDevAutoSync": {
"title": "Automatic models.dev pricing sync",
"description": "When enabled, periodically fetch selected prices when CC Switch launches and keep them in your local pricing file.",
"enabled": "Enabled",
"disabled": "Disabled",
"enableConfirmTitle": "Enable automatic pricing sync?",
"enableConfirmMessage": "After enabling, CC Switch will periodically update prices for the selected models from models.dev when it launches (at most once every 6 hours). Built-in and manually configured prices with the same model ID will be overwritten.",
"enableConfirmAction": "Enable automatic sync",
"configure": "Choose models",
"configureTitle": "Choose models for automatic pricing sync",
"configureDescription": "Search and filter models to update automatically. Your choices are stored locally.",
"commonModels": "Automatically include common models",
"commonModelsDescription": "Selects {{count}} recent Claude, GPT, Gemini, Grok, DeepSeek, Qwen, MiMo, LongCat, Kimi, MiniMax, and GLM models.",
"selectFiltered": "Select filtered ({{count}})",
"clearFiltered": "Clear filtered",
"selectedCount": "{{count}} models selected",
"selectionHint": "Selections with the same normalized model ID are imported once.",
"commonBadge": "Common",
"selectionSaved": "Automatic pricing selection saved",
"syncSuccess": "Pricing sync complete: {{imported}} models checked, {{changed}} updated",
"syncFailed": "Pricing sync failed: {{error}}",
"configLoadFailed": "Failed to load local pricing configuration: {{error}}",
"neverSynced": "Never",
"lastSync": "Last sync",
"commonStatus": "Common models",
"lastError": "Last automatic sync failed: {{error}}",
"localFile": "Local pricing file",
"openFolder": "Open folder",
"openFolderFailed": "Failed to open the local pricing folder: {{error}}",
"reloadLocalFile": "Reload file",
"localFileReloaded": "Local pricing file reloaded",
"localFileReloadFailed": "Failed to reload local pricing file: {{error}}",
"syncNow": "Sync now"
},
"cacheReadCostPerMillion": "Cache Read Cost (per million tokens, USD)",
"cacheCreationCostPerMillion": "Cache Write Cost (per million tokens, USD)"
},
+34
View File
@@ -1633,6 +1633,40 @@
"modelsDevNoResults": "一致するモデルがありません",
"modelsDevTruncated": "{{total}} 件中、先頭 {{shown}} 件のみ表示しています。検索条件を絞り込んでください",
"modelsDevDefaultHint": "最新リリース順に {{shown}} 件を表示しています(全 {{total}} 件)。キーワード入力で全件検索できます",
"modelsDevAutoSync": {
"title": "models.dev 料金の自動同期",
"description": "有効にすると、CC Switch の起動時に選択したモデルの最新料金を定期的に取得し、ローカル料金ファイルに保存します。",
"enabled": "有効",
"disabled": "無効",
"enableConfirmTitle": "料金の自動同期を有効にしますか?",
"enableConfirmMessage": "有効にすると、CC Switch の起動時に models.dev から選択したモデルの料金を定期的に更新します(最大 6 時間に 1 回)。同じモデル ID の内蔵料金と手動設定した料金は上書きされます。",
"enableConfirmAction": "自動同期を有効にする",
"configure": "モデルを選択",
"configureTitle": "料金を自動同期するモデルを選択",
"configureDescription": "検索とプロバイダーの絞り込みで自動更新するモデルを選択します。選択内容はローカルに保存されます。",
"commonModels": "よく使うモデルを自動的に含める",
"commonModelsDescription": "最近の Claude、GPT、Gemini、Grok、DeepSeek、Qwen、MiMo、LongCat、Kimi、MiniMax、GLM から {{count}} モデルを選択します。",
"selectFiltered": "絞り込み結果を選択({{count}}",
"clearFiltered": "絞り込み結果を解除",
"selectedCount": "{{count}} モデルを選択中",
"selectionHint": "正規化後のモデル ID が同じ項目は一度だけインポートされます。",
"commonBadge": "よく使う",
"selectionSaved": "自動料金同期の選択を保存しました",
"syncSuccess": "料金同期が完了しました:{{imported}} モデルを確認、{{changed}} モデルを更新",
"syncFailed": "料金同期に失敗しました:{{error}}",
"configLoadFailed": "ローカル料金設定の読み込みに失敗しました:{{error}}",
"neverSynced": "未同期",
"lastSync": "最終同期",
"commonStatus": "よく使うモデル",
"lastError": "前回の自動同期に失敗しました:{{error}}",
"localFile": "ローカル料金ファイル",
"openFolder": "フォルダーを開く",
"openFolderFailed": "ローカル料金フォルダーを開けませんでした: {{error}}",
"reloadLocalFile": "ファイルを再読み込み",
"localFileReloaded": "ローカル料金ファイルを再読み込みしました",
"localFileReloadFailed": "ローカル料金ファイルの再読み込みに失敗しました:{{error}}",
"syncNow": "今すぐ同期"
},
"cacheReadCostPerMillion": "キャッシュ読み取りコスト(100万トークンあたり、USD)",
"cacheCreationCostPerMillion": "キャッシュ書き込みコスト(100万トークンあたり、USD)"
},
+34
View File
@@ -1604,6 +1604,40 @@
"modelsDevNoResults": "沒有符合的模型",
"modelsDevTruncated": "僅顯示前 {{shown}} 條,共 {{total}} 條結果,請縮小搜尋範圍",
"modelsDevDefaultHint": "預設展示最新發布的 {{shown}} 個模型(共 {{total}} 個),輸入關鍵字可全量搜尋",
"modelsDevAutoSync": {
"title": "自動同步 models.dev 定價",
"description": "開啟後,CC Switch 會在啟動時定期擷取所選模型的最新價格,並儲存到本機定價檔案。",
"enabled": "已開啟",
"disabled": "已關閉",
"enableConfirmTitle": "開啟自動同步定價?",
"enableConfirmMessage": "開啟後,CC Switch 會在啟動時定期從 models.dev 更新所選模型的價格(最多每 6 小時一次)。相同模型 ID 的軟體內建價格和手動設定價格都會被覆寫。",
"enableConfirmAction": "開啟自動同步",
"configure": "選擇模型",
"configureTitle": "選擇自動同步定價的模型",
"configureDescription": "透過搜尋和供應商篩選選擇需要自動更新的模型,選擇結果儲存在本機。",
"commonModels": "自動包含常用模型",
"commonModelsDescription": "自動選擇 {{count}} 個近期 Claude、GPT、Gemini、Grok、DeepSeek、Qwen、MiMo、LongCat、Kimi、MiniMax 和 GLM 模型。",
"selectFiltered": "全選篩選結果({{count}}",
"clearFiltered": "清除篩選結果",
"selectedCount": "已選擇 {{count}} 個模型",
"selectionHint": "標準化模型 ID 相同的選項只會匯入一次。",
"commonBadge": "常用",
"selectionSaved": "自動定價同步選擇已儲存",
"syncSuccess": "定價同步完成:檢查 {{imported}} 個模型,更新 {{changed}} 個",
"syncFailed": "定價同步失敗:{{error}}",
"configLoadFailed": "載入本機定價設定失敗:{{error}}",
"neverSynced": "從未同步",
"lastSync": "上次同步",
"commonStatus": "常用模型",
"lastError": "上次自動同步失敗:{{error}}",
"localFile": "本機定價檔案",
"openFolder": "開啟資料夾",
"openFolderFailed": "開啟本機定價檔案資料夾失敗:{{error}}",
"reloadLocalFile": "重新載入檔案",
"localFileReloaded": "本機定價檔案已重新載入",
"localFileReloadFailed": "重新載入本機定價檔案失敗:{{error}}",
"syncNow": "立即同步"
},
"cacheReadCostPerMillion": "快取讀取成本 (每百萬 tokens, USD)",
"cacheCreationCostPerMillion": "快取寫入成本 (每百萬 tokens, USD)"
},
+34
View File
@@ -1633,6 +1633,40 @@
"modelsDevNoResults": "没有匹配的模型",
"modelsDevTruncated": "仅显示前 {{shown}} 条,共 {{total}} 条结果,请缩小搜索范围",
"modelsDevDefaultHint": "默认展示最新发布的 {{shown}} 个模型(共 {{total}} 个),输入关键字可全量搜索",
"modelsDevAutoSync": {
"title": "自动同步 models.dev 定价",
"description": "开启后,CC Switch 会在启动时定期拉取所选模型的最新价格,并保存到本地定价文件。",
"enabled": "已开启",
"disabled": "已关闭",
"enableConfirmTitle": "开启自动同步定价?",
"enableConfirmMessage": "开启后,CC Switch 会在启动时定期从 models.dev 更新所选模型的价格(最多每 6 小时一次)。同一模型 ID 的软件内置价格和手动设置价格都会被覆盖。",
"enableConfirmAction": "开启自动同步",
"configure": "选择模型",
"configureTitle": "选择自动同步定价的模型",
"configureDescription": "通过搜索和供应商筛选选择需要自动更新的模型,选择结果保存在本地。",
"commonModels": "自动包含常用模型",
"commonModelsDescription": "自动选择 {{count}} 个近期 Claude、GPT、Gemini、Grok、DeepSeek、Qwen、MiMo、LongCat、Kimi、MiniMax 和 GLM 模型。",
"selectFiltered": "全选筛选结果({{count}}",
"clearFiltered": "清空筛选结果",
"selectedCount": "已选择 {{count}} 个模型",
"selectionHint": "归一化模型 ID 相同的选项只会导入一次。",
"commonBadge": "常用",
"selectionSaved": "自动定价同步选择已保存",
"syncSuccess": "定价同步完成:检查 {{imported}} 个模型,更新 {{changed}} 个",
"syncFailed": "定价同步失败:{{error}}",
"configLoadFailed": "加载本地定价配置失败:{{error}}",
"neverSynced": "从未同步",
"lastSync": "上次同步",
"commonStatus": "常用模型",
"lastError": "上次自动同步失败:{{error}}",
"localFile": "本地定价文件",
"openFolder": "打开目录",
"openFolderFailed": "打开本地定价文件目录失败:{{error}}",
"reloadLocalFile": "重新加载文件",
"localFileReloaded": "本地定价文件已重新加载",
"localFileReloadFailed": "重新加载本地定价文件失败:{{error}}",
"syncNow": "立即同步"
},
"cacheReadCostPerMillion": "缓存读取成本 (每百万 tokens, USD)",
"cacheCreationCostPerMillion": "缓存写入成本 (每百万 tokens, USD)"
},
+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());
}
+23
View File
@@ -19,6 +19,10 @@ import {
installGlobalErrorHandlers,
reportFrontendError,
} from "./lib/frontendLogger";
import {
MODELS_DEV_SYNC_CONFIG_QUERY_KEY,
syncModelsDevPricingOnStartup,
} from "./lib/modelsDevAutoSync";
installGlobalErrorHandlers();
@@ -124,6 +128,25 @@ async function bootstrap() {
</FrontendErrorBoundary>
</React.StrictMode>,
);
void syncModelsDevPricingOnStartup()
.then((result) => {
if (!result.skipped) {
return Promise.all([
queryClient.invalidateQueries({ queryKey: ["usage"] }),
queryClient.invalidateQueries({
queryKey: MODELS_DEV_SYNC_CONFIG_QUERY_KEY,
}),
]);
}
})
.catch((error) => {
// 离线或 models.dev 暂时不可用不应阻塞应用启动。
reportFrontendError("models_dev_startup_sync", error);
void queryClient.invalidateQueries({
queryKey: MODELS_DEV_SYNC_CONFIG_QUERY_KEY,
});
});
}
void bootstrap();
+14
View File
@@ -67,6 +67,20 @@ export interface ModelPricing {
cacheCreationCostPerMillion: string;
}
export interface ModelsDevSyncConfig {
autoSyncEnabled: boolean;
includeCommonModels: boolean;
selectedModelKeys: string[];
excludedCommonModelKeys: string[];
lastSyncAt: number | null;
lastSyncError: string | null;
}
export interface ModelsDevSyncState {
config: ModelsDevSyncConfig;
configPath: string;
}
export interface UsageSummary {
totalRequests: number;
totalCost: string;