Files
CC-Switch/src/components/usage/ModelsDevPickerDialog.tsx
T
Xinyu Kuo 0bb3b7515a feat(usage): support importing model pricing from models.dev (#4079)
* feat(usage): support importing model pricing from models.dev

Add an "Import from models.dev" button to the Add Pricing panel that
fetches https://models.dev/api.json, lists priced models sorted by
release date (newest 50 by default, full-text search across ~4800),
and bulk-imports the selected entries through the same
update_model_pricing command as manual entry.

- Normalize imported model IDs to match the backend's
  clean_model_id_for_pricing rules (strip vendor prefix, lowercase,
  truncate ':' suffix, map '@' to '-', drop the [1m] marker) so the
  stored rows actually match cost-attribution lookups
- Dedupe selections that collapse to the same model_id and report
  skipped duplicates in the success toast
- Invalidate usage queries on settled (not just success) so partial
  import failures still refresh the pricing list
- Keep ESC inside the picker's search input from closing the dialog
  and discarding the selection
- Add i18n keys for zh/en/zh-TW/ja and unit tests for the
  normalization, price formatting and flattening logic

Fixes #4017

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(usage): match scoped cost backfill against raw model aliases

The scoped backfill selected zero-cost rows via exact SQL string match,
but log columns store raw model strings (route prefixes, :free variants,
date suffixes), so alias rows were skipped until the next full backfill
on startup. Filter rows in Rust with the same model_pricing_candidates
normalization used by the pricing lookup; pricing decision logic is
untouched. Pre-existing gap from schema v11, surfaced by bulk import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(usage): restrict models.dev pricing import to a single model

Each update_model_pricing call triggers a backfill pass that loads
every zero-cost usage row before filtering by model, so bulk-importing
N entries scaled as selectedModels x allZeroCostLogs full scans.
Re-importing pricing is rare, so drop the batch path instead of
optimizing it: the picker is now single-select, one import runs exactly
one update_model_pricing call and one backfill pass. This also removes
the normalized-ID dedup logic and the useImportModelPricing hook in
favor of the existing useUpdateModelPricing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 22:01:56 +08:00

453 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Check, Loader2, Search } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useUpdateModelPricing } from "@/lib/query/usage";
import { isTextEditableTarget } from "@/utils/domUtils";
const MODELS_DEV_API_URL = "https://models.dev/api.json";
// 全量约 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;
/** 导入成功后调用(此时定价列表已刷新) */
onImported: () => void;
}
export function ModelsDevPickerDialog({
open,
onClose,
onImported,
}: ModelsDevPickerDialogProps) {
const { t } = useTranslation();
const updatePricing = useUpdateModelPricing();
const [search, setSearch] = useState("");
const [providerFilter, setProviderFilter] = useState("all");
const [selected, setSelected] = useState<ModelsDevEntry | null>(null);
// 每次打开时重置选择与过滤条件
useEffect(() => {
if (open) {
setSearch("");
setProviderFilter("all");
setSelected(null);
}
}, [open]);
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();
},
enabled: open,
staleTime: 60 * 60 * 1000,
retry: 1,
});
const entries = useMemo(() => (data ? flattenModels(data) : []), [data]);
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, search, providerFilter]);
// 默认只展示最新发布的一批,搜索/筛选时展示全量匹配(设上限防卡顿)
const visible = useMemo(
() =>
filtered.slice(0, isFiltering ? MAX_VISIBLE_ROWS : DEFAULT_VISIBLE_ROWS),
[filtered, isFiltering],
);
// 单选:点击未选中的行替换选择,点击已选中的行取消选择。
// 限制单选是为了避免批量导入时每条都触发一次全量零成本回填扫描(见 update_model_pricing)。
const toggleEntry = (entry: ModelsDevEntry) => {
setSelected((prev) => (prev?.key === entry.key ? null : entry));
};
const handleImport = async () => {
if (!selected) return;
try {
await updatePricing.mutateAsync({
modelId: selected.normalizedId,
displayName: selected.modelName,
inputCost: formatPrice(selected.input),
outputCost: formatPrice(selected.output),
cacheReadCost: formatPrice(selected.cacheRead),
cacheCreationCost: formatPrice(selected.cacheWrite),
});
toast.success(
t("usage.modelsDevImported", {
name: selected.modelName,
defaultValue: "已导入 {{name}} 的定价",
}),
{ closeButton: true },
);
onImported();
} catch (error) {
toast.error(String(error));
}
};
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={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && !updatePricing.isPending) {
onClose();
}
}}
>
<DialogContent
zIndex="top"
className="max-w-3xl h-[80vh]"
onEscapeKeyDown={(e) => {
// 在搜索框里按 ESC 不应关闭弹窗丢掉已选模型(与 FullScreenPanel 的约定一致)
if (isTextEditableTarget(e.target)) {
e.preventDefault();
}
}}
>
<DialogHeader>
<DialogTitle>
{t("usage.modelsDevPickerTitle", "从 models.dev 导入定价")}
</DialogTitle>
<DialogDescription>
{t(
"usage.modelsDevPickerDesc",
"选择要导入的模型(价格单位:USD / 百万 tokens),每次导入一个",
)}
</DialogDescription>
</DialogHeader>
<div className="flex flex-1 min-h-0 flex-col gap-3 px-6 py-4">
{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", "加载 models.dev 数据失败")}:{" "}
{error instanceof Error ? error.message : String(error)}
</span>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
className="shrink-0"
>
{t("usage.modelsDevRetry", "重试")}
</Button>
</AlertDescription>
</Alert>
) : (
<>
<div className="flex items-center gap-2">
<Select
value={providerFilter}
onValueChange={setProviderFilter}
>
<SelectTrigger className="w-44 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={(e) => setSearch(e.target.value)}
placeholder={t(
"usage.modelsDevSearchPlaceholder",
"搜索模型或供应商(全量搜索)...",
)}
className="pl-8"
/>
</div>
</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) => (
<div
key={entry.key}
role="button"
aria-pressed={selected?.key === entry.key}
onClick={() => toggleEntry(entry)}
className={`flex cursor-pointer items-center gap-3 px-3 py-2 ${
selected?.key === entry.key
? "bg-accent/50"
: "hover:bg-muted/40"
}`}
>
<Check
className={`h-4 w-4 shrink-0 text-primary ${
selected?.key === entry.key
? "visible"
: "invisible"
}`}
/>
<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>
{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>
</div>
))}
{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,
defaultValue:
"仅显示前 {{shown}} 条,共 {{total}} 条结果,请缩小搜索范围",
})
: t("usage.modelsDevDefaultHint", {
shown: visible.length,
total: filtered.length,
defaultValue:
"默认展示最新发布的 {{shown}} 个模型(共 {{total}} 个),输入关键字可全量搜索",
})}
</div>
)}
</div>
)}
</div>
</>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={onClose}
disabled={updatePricing.isPending}
>
{t("common.cancel", "取消")}
</Button>
<Button
onClick={handleImport}
disabled={!selected || updatePricing.isPending}
>
{updatePricing.isPending ? (
<>
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
{t("usage.modelsDevImporting", "导入中...")}
</>
) : (
t("usage.modelsDevImportButton", "导入")
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}