mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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>
This commit is contained in:
@@ -1732,23 +1732,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::<Result<Vec<_>, _>>()?
|
||||
}
|
||||
None => {
|
||||
let mut stmt = conn.prepare(BASE_SQL)?;
|
||||
let rows = stmt.query_map([], row_to_request_log_detail)?;
|
||||
rows.collect::<Result<Vec<_>, _>>()?
|
||||
}
|
||||
}
|
||||
let mut stmt = conn.prepare(BASE_SQL)?;
|
||||
let rows = stmt.query_map([], row_to_request_log_detail)?;
|
||||
rows.collect::<Result<Vec<_>, _>>()?
|
||||
};
|
||||
|
||||
// 精准回填的行筛选必须与查价层共用 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);
|
||||
}
|
||||
@@ -1966,6 +1963,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")
|
||||
@@ -2652,6 +2673,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()?;
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
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_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 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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{isNew && (
|
||||
<div className="mb-6 flex items-center justify-between gap-3 rounded-md border border-border/50 bg-muted/20 px-3 py-2.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"usage.modelsDevHint",
|
||||
"无需手动填写,可从 models.dev 选择模型定价",
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsPickerOpen(true)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Globe className="mr-1.5 h-4 w-4" />
|
||||
{t("usage.importFromModelsDev", "从 models.dev 导入")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form id="pricing-form" onSubmit={handleSubmit} className="space-y-6">
|
||||
{isNew && (
|
||||
<div className="space-y-2">
|
||||
@@ -220,6 +243,17 @@ export function PricingEditModal({
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{isNew && isPickerOpen && (
|
||||
<ModelsDevPickerDialog
|
||||
open={isPickerOpen}
|
||||
onClose={() => setIsPickerOpen(false)}
|
||||
onImported={() => {
|
||||
setIsPickerOpen(false);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FullScreenPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1509,6 +1509,20 @@
|
||||
"editPricing": "Edit Pricing",
|
||||
"pricingAdded": "Pricing added",
|
||||
"pricingUpdated": "Pricing updated",
|
||||
"importFromModelsDev": "Import from models.dev",
|
||||
"modelsDevHint": "Skip manual entry — pick model pricing from models.dev",
|
||||
"modelsDevPickerTitle": "Import Pricing from models.dev",
|
||||
"modelsDevPickerDesc": "Select a model to import (prices in USD per million tokens). One model per import.",
|
||||
"modelsDevSearchPlaceholder": "Search models or providers (full search)...",
|
||||
"modelsDevAllProviders": "All providers",
|
||||
"modelsDevLoadError": "Failed to load models.dev data",
|
||||
"modelsDevRetry": "Retry",
|
||||
"modelsDevImportButton": "Import",
|
||||
"modelsDevImporting": "Importing...",
|
||||
"modelsDevImported": "Imported pricing for {{name}}",
|
||||
"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)"
|
||||
},
|
||||
|
||||
@@ -1509,6 +1509,20 @@
|
||||
"editPricing": "価格設定を編集",
|
||||
"pricingAdded": "価格設定が追加されました",
|
||||
"pricingUpdated": "価格設定が更新されました",
|
||||
"importFromModelsDev": "models.dev からインポート",
|
||||
"modelsDevHint": "手動入力の代わりに、models.dev からモデル価格を選択できます",
|
||||
"modelsDevPickerTitle": "models.dev から価格をインポート",
|
||||
"modelsDevPickerDesc": "インポートするモデルを選択してください(価格単位:USD / 100万トークン)。1回につき1モデルです。",
|
||||
"modelsDevSearchPlaceholder": "モデルまたはプロバイダーを検索(全件検索)...",
|
||||
"modelsDevAllProviders": "すべてのプロバイダー",
|
||||
"modelsDevLoadError": "models.dev データの読み込みに失敗しました",
|
||||
"modelsDevRetry": "再試行",
|
||||
"modelsDevImportButton": "インポート",
|
||||
"modelsDevImporting": "インポート中...",
|
||||
"modelsDevImported": "{{name}} の価格をインポートしました",
|
||||
"modelsDevNoResults": "一致するモデルがありません",
|
||||
"modelsDevTruncated": "{{total}} 件中、先頭 {{shown}} 件のみ表示しています。検索条件を絞り込んでください",
|
||||
"modelsDevDefaultHint": "最新リリース順に {{shown}} 件を表示しています(全 {{total}} 件)。キーワード入力で全件検索できます",
|
||||
"cacheReadCostPerMillion": "キャッシュ読み取りコスト(100万トークンあたり、USD)",
|
||||
"cacheCreationCostPerMillion": "キャッシュ書き込みコスト(100万トークンあたり、USD)"
|
||||
},
|
||||
|
||||
@@ -1481,6 +1481,20 @@
|
||||
"editPricing": "編輯定價",
|
||||
"pricingAdded": "定價已新增",
|
||||
"pricingUpdated": "定價已更新",
|
||||
"importFromModelsDev": "從 models.dev 匯入",
|
||||
"modelsDevHint": "無需手動填寫,可從 models.dev 選擇模型定價",
|
||||
"modelsDevPickerTitle": "從 models.dev 匯入定價",
|
||||
"modelsDevPickerDesc": "選擇要匯入的模型(價格單位:USD / 百萬 tokens),每次匯入一個",
|
||||
"modelsDevSearchPlaceholder": "搜尋模型或供應商(全量搜尋)...",
|
||||
"modelsDevAllProviders": "全部供應商",
|
||||
"modelsDevLoadError": "載入 models.dev 資料失敗",
|
||||
"modelsDevRetry": "重試",
|
||||
"modelsDevImportButton": "匯入",
|
||||
"modelsDevImporting": "匯入中...",
|
||||
"modelsDevImported": "已匯入 {{name}} 的定價",
|
||||
"modelsDevNoResults": "沒有符合的模型",
|
||||
"modelsDevTruncated": "僅顯示前 {{shown}} 條,共 {{total}} 條結果,請縮小搜尋範圍",
|
||||
"modelsDevDefaultHint": "預設展示最新發布的 {{shown}} 個模型(共 {{total}} 個),輸入關鍵字可全量搜尋",
|
||||
"cacheReadCostPerMillion": "快取讀取成本 (每百萬 tokens, USD)",
|
||||
"cacheCreationCostPerMillion": "快取寫入成本 (每百萬 tokens, USD)"
|
||||
},
|
||||
|
||||
@@ -1509,6 +1509,20 @@
|
||||
"editPricing": "编辑定价",
|
||||
"pricingAdded": "定价已添加",
|
||||
"pricingUpdated": "定价已更新",
|
||||
"importFromModelsDev": "从 models.dev 导入",
|
||||
"modelsDevHint": "无需手动填写,可从 models.dev 选择模型定价",
|
||||
"modelsDevPickerTitle": "从 models.dev 导入定价",
|
||||
"modelsDevPickerDesc": "选择要导入的模型(价格单位:USD / 百万 tokens),每次导入一个",
|
||||
"modelsDevSearchPlaceholder": "搜索模型或供应商(全量搜索)...",
|
||||
"modelsDevAllProviders": "全部供应商",
|
||||
"modelsDevLoadError": "加载 models.dev 数据失败",
|
||||
"modelsDevRetry": "重试",
|
||||
"modelsDevImportButton": "导入",
|
||||
"modelsDevImporting": "导入中...",
|
||||
"modelsDevImported": "已导入 {{name}} 的定价",
|
||||
"modelsDevNoResults": "没有匹配的模型",
|
||||
"modelsDevTruncated": "仅显示前 {{shown}} 条,共 {{total}} 条结果,请缩小搜索范围",
|
||||
"modelsDevDefaultHint": "默认展示最新发布的 {{shown}} 个模型(共 {{total}} 个),输入关键字可全量搜索",
|
||||
"cacheReadCostPerMillion": "缓存读取成本 (每百万 tokens, USD)",
|
||||
"cacheCreationCostPerMillion": "缓存写入成本 (每百万 tokens, USD)"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user