From 78ca0380aaa7afb09a6d249de46b93cd5becc460 Mon Sep 17 00:00:00 2001 From: kingcanfish Date: Thu, 11 Jun 2026 20:55:16 +0800 Subject: [PATCH 1/3] 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 --- .../usage/ModelsDevPickerDialog.tsx | 512 ++++++++++++++++++ src/components/usage/PricingEditModal.tsx | 36 +- src/i18n/locales/en.json | 17 + src/i18n/locales/ja.json | 17 + src/i18n/locales/zh-TW.json | 17 + src/i18n/locales/zh.json | 17 + src/lib/query/usage.ts | 31 ++ .../components/ModelsDevPickerDialog.test.ts | 144 +++++ 8 files changed, 790 insertions(+), 1 deletion(-) create mode 100644 src/components/usage/ModelsDevPickerDialog.tsx create mode 100644 tests/components/ModelsDevPickerDialog.test.ts diff --git a/src/components/usage/ModelsDevPickerDialog.tsx b/src/components/usage/ModelsDevPickerDialog.tsx new file mode 100644 index 0000000000..e1e255a782 --- /dev/null +++ b/src/components/usage/ModelsDevPickerDialog.tsx @@ -0,0 +1,512 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { 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 { Checkbox } from "@/components/ui/checkbox"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useImportModelPricing } 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; +} + +type ModelsDevResponse = Record; + +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_pricing(usage_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 importPricing = useImportModelPricing(); + + const [search, setSearch] = useState(""); + const [providerFilter, setProviderFilter] = useState("all"); + const [selected, setSelected] = useState>( + new Map(), + ); + + // 每次打开时重置选择与过滤条件 + useEffect(() => { + if (open) { + setSearch(""); + setProviderFilter("all"); + setSelected(new Map()); + } + }, [open]); + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ["models-dev-pricing"], + queryFn: async (): Promise => { + 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(); + 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], + ); + + const allVisibleSelected = + visible.length > 0 && visible.every((entry) => selected.has(entry.key)); + + const toggleEntry = (entry: ModelsDevEntry) => { + setSelected((prev) => { + const next = new Map(prev); + if (next.has(entry.key)) { + next.delete(entry.key); + } else { + next.set(entry.key, entry); + } + return next; + }); + }; + + const toggleAllVisible = () => { + setSelected((prev) => { + const next = new Map(prev); + if (allVisibleSelected) { + for (const entry of visible) { + next.delete(entry.key); + } + } else { + for (const entry of visible) { + next.set(entry.key, entry); + } + } + return next; + }); + }; + + const handleImport = async () => { + // 同一归一化 ID 可能被多个供应商条目选中,数据库以 model_id 为主键, + // 静默覆盖会导致最终价格取决于遍历顺序——按选择顺序保留第一个并提示跳过数量 + const deduped = new Map(); + for (const item of selected.values()) { + if (!deduped.has(item.normalizedId)) { + deduped.set(item.normalizedId, item); + } + } + const items = Array.from(deduped.values()); + if (items.length === 0) return; + const skipped = selected.size - items.length; + + try { + await importPricing.mutateAsync( + items.map((item) => ({ + modelId: item.normalizedId, + displayName: item.modelName, + inputCost: formatPrice(item.input), + outputCost: formatPrice(item.output), + cacheReadCost: formatPrice(item.cacheRead), + cacheCreationCost: formatPrice(item.cacheWrite), + })), + ); + + toast.success( + skipped > 0 + ? t("usage.modelsDevImportedWithSkipped", { + count: items.length, + skipped, + defaultValue: + "已导入 {{count}} 个模型定价,跳过 {{skipped}} 个重复模型", + }) + : t("usage.modelsDevImported", { + count: items.length, + defaultValue: "已导入 {{count}} 个模型定价", + }), + { 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 ( + { + if (!nextOpen && !importPricing.isPending) { + onClose(); + } + }} + > + { + // 在搜索框里按 ESC 不应关闭弹窗丢掉已勾选的模型(与 FullScreenPanel 的约定一致) + if (isTextEditableTarget(e.target)) { + e.preventDefault(); + } + }} + > + + + {t("usage.modelsDevPickerTitle", "从 models.dev 导入定价")} + + + {t( + "usage.modelsDevPickerDesc", + "选择要导入的模型(价格单位:USD / 百万 tokens),支持多选", + )} + + + +
+ {isLoading ? ( +
+ +
+ ) : error ? ( + + + + {t("usage.modelsDevLoadError", "加载 models.dev 数据失败")}:{" "} + {error instanceof Error ? error.message : String(error)} + + + + + ) : ( + <> +
+ +
+ + setSearch(e.target.value)} + placeholder={t( + "usage.modelsDevSearchPlaceholder", + "搜索模型或供应商(全量搜索)...", + )} + className="pl-8" + /> +
+
+ +
+ + + {t("usage.modelsDevSelectedCount", { + count: selected.size, + defaultValue: "已选 {{count}} 个", + })} + +
+ +
+ {filtered.length === 0 ? ( +
+ {t("usage.modelsDevNoResults", "没有匹配的模型")} +
+ ) : ( +
+ {visible.map((entry) => ( +
toggleEntry(entry)} + className="flex cursor-pointer items-center gap-3 px-3 py-2 hover:bg-muted/40" + > + toggleEntry(entry)} + onClick={(e) => e.stopPropagation()} + /> +
+
+ + {entry.modelName} + + + {entry.providerName} + + {entry.releaseDate && ( + + {entry.releaseDate} + + )} +
+
+ {entry.normalizedId} +
+
+
+ {priceColumns(entry).map((column) => ( +
+
+ {column.label} +
+
+ ${formatPrice(column.value)} +
+
+ ))} +
+
+ ))} + {filtered.length > visible.length && ( +
+ {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}} 个),输入关键字可全量搜索", + })} +
+ )} +
+ )} +
+ + )} +
+ + + + + +
+
+ ); +} diff --git a/src/components/usage/PricingEditModal.tsx b/src/components/usage/PricingEditModal.tsx index 8354cb4793..e22da8cf3f 100644 --- a/src/components/usage/PricingEditModal.tsx +++ b/src/components/usage/PricingEditModal.tsx @@ -1,13 +1,14 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import { Save, Plus } from "lucide-react"; +import { Save, Plus, Globe } from "lucide-react"; import { FullScreenPanel } from "@/components/common/FullScreenPanel"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useUpdateModelPricing } from "@/lib/query/usage"; import { isNonNegativeDecimalString, type ModelPricing } from "@/types/usage"; +import { ModelsDevPickerDialog } from "./ModelsDevPickerDialog"; interface PricingEditModalProps { open: boolean; @@ -26,6 +27,7 @@ export function PricingEditModal({ }: PricingEditModalProps) { const { t } = useTranslation(); const updatePricing = useUpdateModelPricing(); + const [isPickerOpen, setIsPickerOpen] = useState(false); const [formData, setFormData] = useState({ modelId: model.modelId, @@ -111,6 +113,27 @@ export function PricingEditModal({ } > + {isNew && ( +
+

+ {t( + "usage.modelsDevHint", + "无需手动填写,可从 models.dev 批量选择模型定价", + )} +

+ +
+ )} +
{isNew && (
@@ -220,6 +243,17 @@ export function PricingEditModal({ />
+ + {isNew && isPickerOpen && ( + setIsPickerOpen(false)} + onImported={() => { + setIsPickerOpen(false); + onClose(); + }} + /> + )} ); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a0a1ddbff2..773f002bfd 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1488,6 +1488,23 @@ "editPricing": "Edit Pricing", "pricingAdded": "Pricing added", "pricingUpdated": "Pricing updated", + "importFromModelsDev": "Import from models.dev", + "modelsDevHint": "Skip manual entry — pick model pricing from models.dev in bulk", + "modelsDevPickerTitle": "Import Pricing from models.dev", + "modelsDevPickerDesc": "Select models to import (prices in USD per million tokens). Multiple selection supported.", + "modelsDevSearchPlaceholder": "Search models or providers (full search)...", + "modelsDevAllProviders": "All providers", + "modelsDevLoadError": "Failed to load models.dev data", + "modelsDevRetry": "Retry", + "modelsDevSelectAllVisible": "Select all shown", + "modelsDevSelectedCount": "{{count}} selected", + "modelsDevImportButton": "Import ({{count}})", + "modelsDevImporting": "Importing...", + "modelsDevImported": "Imported {{count}} model pricing entries", + "modelsDevImportedWithSkipped": "Imported {{count}} model pricing entries, skipped {{skipped}} duplicate models", + "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", "cacheReadCostPerMillion": "Cache Read Cost (per million tokens, USD)", "cacheCreationCostPerMillion": "Cache Write Cost (per million tokens, USD)" }, diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9f22d372b3..0f3f7843d7 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1488,6 +1488,23 @@ "editPricing": "価格設定を編集", "pricingAdded": "価格設定が追加されました", "pricingUpdated": "価格設定が更新されました", + "importFromModelsDev": "models.dev からインポート", + "modelsDevHint": "手動入力の代わりに、models.dev からモデル価格を一括選択できます", + "modelsDevPickerTitle": "models.dev から価格をインポート", + "modelsDevPickerDesc": "インポートするモデルを選択してください(価格単位:USD / 100万トークン)。複数選択できます。", + "modelsDevSearchPlaceholder": "モデルまたはプロバイダーを検索(全件検索)...", + "modelsDevAllProviders": "すべてのプロバイダー", + "modelsDevLoadError": "models.dev データの読み込みに失敗しました", + "modelsDevRetry": "再試行", + "modelsDevSelectAllVisible": "表示中をすべて選択", + "modelsDevSelectedCount": "{{count}} 件選択中", + "modelsDevImportButton": "インポート ({{count}})", + "modelsDevImporting": "インポート中...", + "modelsDevImported": "{{count}} 件のモデル価格をインポートしました", + "modelsDevImportedWithSkipped": "{{count}} 件のモデル価格をインポートしました(重複モデル {{skipped}} 件をスキップ)", + "modelsDevNoResults": "一致するモデルがありません", + "modelsDevTruncated": "{{total}} 件中、先頭 {{shown}} 件のみ表示しています。検索条件を絞り込んでください", + "modelsDevDefaultHint": "最新リリース順に {{shown}} 件を表示しています(全 {{total}} 件)。キーワード入力で全件検索できます", "cacheReadCostPerMillion": "キャッシュ読み取りコスト(100万トークンあたり、USD)", "cacheCreationCostPerMillion": "キャッシュ書き込みコスト(100万トークンあたり、USD)" }, diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 8b7e3e1bfd..0f6d686552 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1460,6 +1460,23 @@ "editPricing": "編輯定價", "pricingAdded": "定價已新增", "pricingUpdated": "定價已更新", + "importFromModelsDev": "從 models.dev 匯入", + "modelsDevHint": "無需手動填寫,可從 models.dev 批次選擇模型定價", + "modelsDevPickerTitle": "從 models.dev 匯入定價", + "modelsDevPickerDesc": "選擇要匯入的模型(價格單位:USD / 百萬 tokens),支援多選", + "modelsDevSearchPlaceholder": "搜尋模型或供應商(全量搜尋)...", + "modelsDevAllProviders": "全部供應商", + "modelsDevLoadError": "載入 models.dev 資料失敗", + "modelsDevRetry": "重試", + "modelsDevSelectAllVisible": "全選目前顯示", + "modelsDevSelectedCount": "已選 {{count}} 個", + "modelsDevImportButton": "匯入 ({{count}})", + "modelsDevImporting": "匯入中...", + "modelsDevImported": "已匯入 {{count}} 個模型定價", + "modelsDevImportedWithSkipped": "已匯入 {{count}} 個模型定價,跳過 {{skipped}} 個重複模型", + "modelsDevNoResults": "沒有符合的模型", + "modelsDevTruncated": "僅顯示前 {{shown}} 條,共 {{total}} 條結果,請縮小搜尋範圍", + "modelsDevDefaultHint": "預設展示最新發布的 {{shown}} 個模型(共 {{total}} 個),輸入關鍵字可全量搜尋", "cacheReadCostPerMillion": "快取讀取成本 (每百萬 tokens, USD)", "cacheCreationCostPerMillion": "快取寫入成本 (每百萬 tokens, USD)" }, diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index db494f20eb..437ed20638 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1488,6 +1488,23 @@ "editPricing": "编辑定价", "pricingAdded": "定价已添加", "pricingUpdated": "定价已更新", + "importFromModelsDev": "从 models.dev 导入", + "modelsDevHint": "无需手动填写,可从 models.dev 批量选择模型定价", + "modelsDevPickerTitle": "从 models.dev 导入定价", + "modelsDevPickerDesc": "选择要导入的模型(价格单位:USD / 百万 tokens),支持多选", + "modelsDevSearchPlaceholder": "搜索模型或供应商(全量搜索)...", + "modelsDevAllProviders": "全部供应商", + "modelsDevLoadError": "加载 models.dev 数据失败", + "modelsDevRetry": "重试", + "modelsDevSelectAllVisible": "全选当前显示", + "modelsDevSelectedCount": "已选 {{count}} 个", + "modelsDevImportButton": "导入 ({{count}})", + "modelsDevImporting": "导入中...", + "modelsDevImported": "已导入 {{count}} 个模型定价", + "modelsDevImportedWithSkipped": "已导入 {{count}} 个模型定价,跳过 {{skipped}} 个重复模型", + "modelsDevNoResults": "没有匹配的模型", + "modelsDevTruncated": "仅显示前 {{shown}} 条,共 {{total}} 条结果,请缩小搜索范围", + "modelsDevDefaultHint": "默认展示最新发布的 {{shown}} 个模型(共 {{total}} 个),输入关键字可全量搜索", "cacheReadCostPerMillion": "缓存读取成本 (每百万 tokens, USD)", "cacheCreationCostPerMillion": "缓存写入成本 (每百万 tokens, USD)" }, diff --git a/src/lib/query/usage.ts b/src/lib/query/usage.ts index 3458fb114b..e783fb6c56 100644 --- a/src/lib/query/usage.ts +++ b/src/lib/query/usage.ts @@ -307,6 +307,37 @@ export function useUpdateModelPricing() { }); } +export interface ModelPricingImportItem { + modelId: string; + displayName: string; + inputCost: string; + outputCost: string; + cacheReadCost: string; + cacheCreationCost: string; +} + +/** 批量导入模型定价:逐条调用与手动添加相同的 update_model_pricing 命令 */ +export function useImportModelPricing() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (items: ModelPricingImportItem[]) => { + for (const item of items) { + await usageApi.updateModelPricing( + item.modelId, + item.displayName, + item.inputCost, + item.outputCost, + item.cacheReadCost, + item.cacheCreationCost, + ); + } + }, + // 中途失败时前面的条目已写入数据库,所以成功与否都要刷新 + onSettled: () => queryClient.invalidateQueries({ queryKey: usageKeys.all }), + }); +} + export function useDeleteModelPricing() { const queryClient = useQueryClient(); diff --git a/tests/components/ModelsDevPickerDialog.test.ts b/tests/components/ModelsDevPickerDialog.test.ts new file mode 100644 index 0000000000..a162759d8f --- /dev/null +++ b/tests/components/ModelsDevPickerDialog.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; + +import { + flattenModels, + formatPrice, + normalizeModelIdForPricing, +} from "@/components/usage/ModelsDevPickerDialog"; + +describe("normalizeModelIdForPricing", () => { + it("keeps already-normalized ids unchanged", () => { + expect(normalizeModelIdForPricing("claude-opus-4-5")).toBe( + "claude-opus-4-5", + ); + }); + + it("strips the vendor prefix before the last slash", () => { + expect(normalizeModelIdForPricing("z-ai/glm-4.7")).toBe("glm-4.7"); + expect(normalizeModelIdForPricing("clarifai/main/models/mm-poly-8b")).toBe( + "mm-poly-8b", + ); + }); + + it("lowercases the id", () => { + expect(normalizeModelIdForPricing("MiniMaxAI/MiniMax-M2.1")).toBe( + "minimax-m2.1", + ); + }); + + it("truncates colon suffixes", () => { + expect(normalizeModelIdForPricing("claude-sonnet-4-thinking:8192")).toBe( + "claude-sonnet-4-thinking", + ); + }); + + it("maps @ to -", () => { + expect(normalizeModelIdForPricing("claude-sonnet-4@20250514")).toBe( + "claude-sonnet-4-20250514", + ); + }); + + it("strips the [1m] context marker", () => { + expect(normalizeModelIdForPricing("claude-sonnet-4-5[1m]")).toBe( + "claude-sonnet-4-5", + ); + }); + + it("combines all rules", () => { + expect(normalizeModelIdForPricing("Vendor/Claude-Sonnet-4@2025:free")).toBe( + "claude-sonnet-4-2025", + ); + }); +}); + +describe("formatPrice", () => { + it("formats integers without a decimal point", () => { + expect(formatPrice(5)).toBe("5"); + expect(formatPrice(25)).toBe("25"); + }); + + it("trims trailing zeros", () => { + expect(formatPrice(0.5)).toBe("0.5"); + expect(formatPrice(6.25)).toBe("6.25"); + expect(formatPrice(1.0395)).toBe("1.0395"); + }); + + it("keeps up to six decimal places", () => { + expect(formatPrice(0.000001)).toBe("0.000001"); + expect(formatPrice(0.0000004)).toBe("0"); + }); + + it("returns 0 for zero, negative and non-finite values", () => { + expect(formatPrice(0)).toBe("0"); + expect(formatPrice(-1)).toBe("0"); + expect(formatPrice(NaN)).toBe("0"); + expect(formatPrice(Infinity)).toBe("0"); + }); + + it("never produces exponent notation", () => { + // 后端 Decimal::from_str 不接受科学计数法 + expect(formatPrice(1e-8)).toBe("0"); + expect(formatPrice(1e21)).toBe("0"); + for (const value of [5, 0.5, 0.000123, 123456.789]) { + expect(formatPrice(value)).toMatch(/^\d+(\.\d+)?$/); + } + }); +}); + +describe("flattenModels", () => { + it("flattens providers, fills defaults and sorts by release date desc", () => { + const entries = flattenModels({ + acme: { + id: "acme", + name: "Acme AI", + models: { + "old-model": { + id: "old-model", + name: "Old Model", + release_date: "2024-01-01", + cost: { input: 1, output: 2 }, + }, + "new-model": { + id: "new-model", + name: "New Model", + release_date: "2025-06-01", + cost: { input: 3, output: 6, cache_read: 0.3, cache_write: 3.75 }, + }, + "free-model": { + id: "free-model", + name: "No Cost Model", + }, + }, + }, + bare: { + models: { + "Vendor/Some-Model:free": { + release_date: "2025-01", + cost: { input: 0.1 }, + }, + }, + }, + }); + + expect(entries.map((e) => e.key)).toEqual([ + "acme/new-model", + "bare/Vendor/Some-Model:free", + "acme/old-model", + ]); + + const newModel = entries[0]; + expect(newModel.normalizedId).toBe("new-model"); + expect(newModel.cacheRead).toBe(0.3); + expect(newModel.cacheWrite).toBe(3.75); + + // 没有 name 的 provider 用 id 兜底;缺失的成本字段补 0 + const bareModel = entries[1]; + expect(bareModel.providerName).toBe("bare"); + expect(bareModel.normalizedId).toBe("some-model"); + expect(bareModel.output).toBe(0); + expect(bareModel.cacheRead).toBe(0); + + // 完全没有定价的模型被过滤 + expect(entries.some((e) => e.modelId === "free-model")).toBe(false); + }); +}); From 936a776b0f7d2426a8ccb58a22a66883f2c15f31 Mon Sep 17 00:00:00 2001 From: kingcanfish Date: Fri, 12 Jun 2026 09:40:44 +0800 Subject: [PATCH 2/3] 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 --- src-tauri/src/services/usage_stats.rs | 106 ++++++++++++++++++++++---- 1 file changed, 91 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index 6002881b78..dce3f2b613 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -1496,23 +1496,20 @@ impl Database { OR cache_read_tokens > 0 OR cache_creation_tokens > 0)"; let mut logs = { - match only_model_id { - Some(model) => { - let sql = format!( - "{BASE_SQL} AND (model = ?1 OR request_model = ?1 OR pricing_model = ?1)" - ); - let mut stmt = conn.prepare(&sql)?; - let rows = stmt.query_map([model], row_to_request_log_detail)?; - rows.collect::, _>>()? - } - None => { - let mut stmt = conn.prepare(BASE_SQL)?; - let rows = stmt.query_map([], row_to_request_log_detail)?; - rows.collect::, _>>()? - } - } + let mut stmt = conn.prepare(BASE_SQL)?; + let rows = stmt.query_map([], row_to_request_log_detail)?; + rows.collect::, _>>()? }; + // 精准回填的行筛选必须与查价层共用 candidates 归一化:SQL 精确匹配会漏掉 + // 以原始别名落库的行(如 openrouter/anthropic/claude-sonnet-4.5:free), + // 这些行查价时能归一化命中新定价,却在筛选层被挡掉,导致导入定价后 + // 历史成本要等下次全量回填才更新。误纳无害——查不到价的行会被跳过。 + if let Some(model_id) = only_model_id { + let target = model_pricing_candidates(model_id); + logs.retain(|log| log_pricing_scope_matches(log, &target)); + } + if logs.is_empty() { return Ok(0); } @@ -1730,6 +1727,30 @@ pub(crate) fn find_model_pricing_row( Ok(None) } +/// 精准回填的行筛选:log 的任一模型字段归一化后与目标模型的 candidates 相交, +/// 或可按查价层的前缀规则命中目标,即视为相关。镜像 find_model_pricing_row 的 +/// 匹配语义,宁可误纳(后续查价会兜底)不可漏筛。 +fn log_pricing_scope_matches(log: &RequestLogDetail, target_candidates: &[String]) -> bool { + [ + Some(log.model.as_str()), + log.request_model.as_deref(), + log.pricing_model.as_deref(), + ] + .into_iter() + .flatten() + .any(|field| { + model_pricing_candidates(field).iter().any(|candidate| { + target_candidates.iter().any(|target| { + target == candidate + || (should_try_pricing_prefix_match(candidate) + && target + .strip_prefix(candidate.as_str()) + .is_some_and(|rest| rest.starts_with('-'))) + }) + }) + }) +} + pub(crate) fn is_placeholder_pricing_model(model_id: &str) -> bool { let normalized = model_id.trim().to_ascii_lowercase(); normalized.is_empty() || matches!(normalized.as_str(), "unknown" | "null" | "none") @@ -2340,6 +2361,61 @@ mod tests { Ok(()) } + #[test] + fn test_scoped_backfill_matches_raw_alias_rows() -> Result<(), AppError> { + let db = Database::memory()?; + + { + let conn = lock_conn!(db.conn); + // 代理日志按上游原文落库:带路由前缀和 :free 后缀的别名形式。 + // 精准回填的筛选必须归一化后匹配,否则这类行要等全量回填才更新。 + insert_usage_log( + &conn, + "openrouter-alias-zero-cost", + "claude", + "provider-1", + "openrouter/moonshot/kimi-k2-novel:free", + "proxy", + 1000, + 1_000_000, + 0, + 0, + 0, + 200, + "0", + )?; + } + + // 定价缺失时不应回填 + assert_eq!(db.backfill_missing_usage_costs()?, 0); + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million) + VALUES ('kimi-k2-novel', 'Kimi K2 Novel', '0.6', '2.5')", + [], + )?; + } + + // 按归一化 ID 精准回填,应命中以原始别名落库的行 + assert_eq!( + db.backfill_missing_usage_costs_for_model("kimi-k2-novel")?, + 1 + ); + + let conn = lock_conn!(db.conn); + let total_cost: String = conn.query_row( + "SELECT total_cost_usd + FROM proxy_request_logs WHERE request_id = 'openrouter-alias-zero-cost'", + [], + |row| row.get(0), + )?; + assert_eq!(total_cost, "0.600000"); + + Ok(()) + } + #[test] fn test_backfill_missing_usage_costs_keeps_claude_fresh_input() -> Result<(), AppError> { let db = Database::memory()?; From 6ce263528c43cda86f3a447cd3c83481b2f18705 Mon Sep 17 00:00:00 2001 From: kingcanfish Date: Fri, 12 Jun 2026 20:42:32 +0800 Subject: [PATCH 3/3] 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 --- .../usage/ModelsDevPickerDialog.tsx | 140 +++++------------- src/components/usage/PricingEditModal.tsx | 2 +- src/i18n/locales/en.json | 11 +- src/i18n/locales/ja.json | 11 +- src/i18n/locales/zh-TW.json | 11 +- src/i18n/locales/zh.json | 11 +- src/lib/query/usage.ts | 31 ---- 7 files changed, 57 insertions(+), 160 deletions(-) diff --git a/src/components/usage/ModelsDevPickerDialog.tsx b/src/components/usage/ModelsDevPickerDialog.tsx index e1e255a782..03025e1601 100644 --- a/src/components/usage/ModelsDevPickerDialog.tsx +++ b/src/components/usage/ModelsDevPickerDialog.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { toast } from "sonner"; -import { Loader2, Search } from "lucide-react"; +import { Check, Loader2, Search } from "lucide-react"; import { Dialog, DialogContent, @@ -13,7 +13,6 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Checkbox } from "@/components/ui/checkbox"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Select, @@ -22,7 +21,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { useImportModelPricing } from "@/lib/query/usage"; +import { useUpdateModelPricing } from "@/lib/query/usage"; import { isTextEditableTarget } from "@/utils/domUtils"; const MODELS_DEV_API_URL = "https://models.dev/api.json"; @@ -144,20 +143,18 @@ export function ModelsDevPickerDialog({ onImported, }: ModelsDevPickerDialogProps) { const { t } = useTranslation(); - const importPricing = useImportModelPricing(); + const updatePricing = useUpdateModelPricing(); const [search, setSearch] = useState(""); const [providerFilter, setProviderFilter] = useState("all"); - const [selected, setSelected] = useState>( - new Map(), - ); + const [selected, setSelected] = useState(null); // 每次打开时重置选择与过滤条件 useEffect(() => { if (open) { setSearch(""); setProviderFilter("all"); - setSelected(new Map()); + setSelected(null); } }, [open]); @@ -211,74 +208,30 @@ export function ModelsDevPickerDialog({ [filtered, isFiltering], ); - const allVisibleSelected = - visible.length > 0 && visible.every((entry) => selected.has(entry.key)); - + // 单选:点击未选中的行替换选择,点击已选中的行取消选择。 + // 限制单选是为了避免批量导入时每条都触发一次全量零成本回填扫描(见 update_model_pricing)。 const toggleEntry = (entry: ModelsDevEntry) => { - setSelected((prev) => { - const next = new Map(prev); - if (next.has(entry.key)) { - next.delete(entry.key); - } else { - next.set(entry.key, entry); - } - return next; - }); - }; - - const toggleAllVisible = () => { - setSelected((prev) => { - const next = new Map(prev); - if (allVisibleSelected) { - for (const entry of visible) { - next.delete(entry.key); - } - } else { - for (const entry of visible) { - next.set(entry.key, entry); - } - } - return next; - }); + setSelected((prev) => (prev?.key === entry.key ? null : entry)); }; const handleImport = async () => { - // 同一归一化 ID 可能被多个供应商条目选中,数据库以 model_id 为主键, - // 静默覆盖会导致最终价格取决于遍历顺序——按选择顺序保留第一个并提示跳过数量 - const deduped = new Map(); - for (const item of selected.values()) { - if (!deduped.has(item.normalizedId)) { - deduped.set(item.normalizedId, item); - } - } - const items = Array.from(deduped.values()); - if (items.length === 0) return; - const skipped = selected.size - items.length; + if (!selected) return; try { - await importPricing.mutateAsync( - items.map((item) => ({ - modelId: item.normalizedId, - displayName: item.modelName, - inputCost: formatPrice(item.input), - outputCost: formatPrice(item.output), - cacheReadCost: formatPrice(item.cacheRead), - cacheCreationCost: formatPrice(item.cacheWrite), - })), - ); + 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( - skipped > 0 - ? t("usage.modelsDevImportedWithSkipped", { - count: items.length, - skipped, - defaultValue: - "已导入 {{count}} 个模型定价,跳过 {{skipped}} 个重复模型", - }) - : t("usage.modelsDevImported", { - count: items.length, - defaultValue: "已导入 {{count}} 个模型定价", - }), + t("usage.modelsDevImported", { + name: selected.modelName, + defaultValue: "已导入 {{name}} 的定价", + }), { closeButton: true }, ); onImported(); @@ -302,7 +255,7 @@ export function ModelsDevPickerDialog({ { - if (!nextOpen && !importPricing.isPending) { + if (!nextOpen && !updatePricing.isPending) { onClose(); } }} @@ -311,7 +264,7 @@ export function ModelsDevPickerDialog({ zIndex="top" className="max-w-3xl h-[80vh]" onEscapeKeyDown={(e) => { - // 在搜索框里按 ESC 不应关闭弹窗丢掉已勾选的模型(与 FullScreenPanel 的约定一致) + // 在搜索框里按 ESC 不应关闭弹窗丢掉已选模型(与 FullScreenPanel 的约定一致) if (isTextEditableTarget(e.target)) { e.preventDefault(); } @@ -324,7 +277,7 @@ export function ModelsDevPickerDialog({ {t( "usage.modelsDevPickerDesc", - "选择要导入的模型(价格单位:USD / 百万 tokens),支持多选", + "选择要导入的模型(价格单位:USD / 百万 tokens),每次导入一个", )} @@ -386,23 +339,6 @@ export function ModelsDevPickerDialog({ -
- - - {t("usage.modelsDevSelectedCount", { - count: selected.size, - defaultValue: "已选 {{count}} 个", - })} - -
-
{filtered.length === 0 ? (
@@ -414,13 +350,20 @@ export function ModelsDevPickerDialog({
toggleEntry(entry)} - className="flex cursor-pointer items-center gap-3 px-3 py-2 hover:bg-muted/40" + className={`flex cursor-pointer items-center gap-3 px-3 py-2 ${ + selected?.key === entry.key + ? "bg-accent/50" + : "hover:bg-muted/40" + }`} > - toggleEntry(entry)} - onClick={(e) => e.stopPropagation()} +
@@ -485,24 +428,21 @@ export function ModelsDevPickerDialog({ diff --git a/src/components/usage/PricingEditModal.tsx b/src/components/usage/PricingEditModal.tsx index e22da8cf3f..18f81da281 100644 --- a/src/components/usage/PricingEditModal.tsx +++ b/src/components/usage/PricingEditModal.tsx @@ -118,7 +118,7 @@ export function PricingEditModal({

{t( "usage.modelsDevHint", - "无需手动填写,可从 models.dev 批量选择模型定价", + "无需手动填写,可从 models.dev 选择模型定价", )}