feat(billing): 优化计费精度

- 新增账单配置 API,包括获取和更新显示小数位数的功能。
- 在前端页面中集成账单配置,允许用户设置金额显示精度。
- 更新相关页面(分析、定价、令牌、使用日志)以支持动态显示小数位数。
- 规范化账单金额处理,确保金额在前端和后端一致。
This commit is contained in:
Jeff Gu committed 2026-04-13 13:56:13 +08:00
1 parent 9b52d43835
commit 0258367a64
21 files changed
+587 -74

No files matched your search

+9
View File
@@ -8,6 +8,7 @@ import {
AnalyticsEventsData,
AnalyticsBreakdownDimension,
AnalyticsRange,
BillingConfig,
UsageLogFilters,
UsageLogSearchData,
} from '@/types'
@@ -219,6 +220,14 @@ export const apiClient = {
body: JSON.stringify(config),
}),
getBillingConfig: () => request<ApiResponse<BillingConfig>>('/api/admin/billing/config', { method: 'GET' }),
saveBillingConfig: (config: BillingConfig) =>
request<ApiResponse<BillingConfig>>('/api/admin/billing/config', {
method: 'POST',
body: JSON.stringify(config),
}),
// Analytics APIs
getAnalyticsOverview: (range: AnalyticsRange) =>
request<ApiResponse<AnalyticsOverviewData>>(`/api/admin/analytics/overview?range=${encodeURIComponent(range)}`, { method: 'GET' }),
@@ -19,6 +19,7 @@ type BreakdownChartCardProps = {
isError?: boolean;
barClassName?: string;
badgeClassName?: string;
displayDecimals: number;
};
export function BreakdownChartCard({
@@ -29,6 +30,7 @@ export function BreakdownChartCard({
isError,
barClassName,
badgeClassName,
displayDecimals,
}: BreakdownChartCardProps) {
const visibleItems = items.slice(0, 6);
const maxCost = Math.max(...visibleItems.map((item) => item.totalCost), 0);
@@ -88,7 +90,7 @@ export function BreakdownChartCard({
</div>
<div className="text-right">
<div className="font-semibold">
{useCostScale ? formatCurrency(item.totalCost) : formatCompactNumber(item.requests)}
{useCostScale ? formatCurrency(item.totalCost, displayDecimals) : formatCompactNumber(item.requests)}
</div>
<div className="text-xs text-muted-foreground">{Math.round(item.avgLatencyMs)} ms</div>
</div>
@@ -27,7 +27,15 @@ const formatCompactNumber = (value: number): string => {
}).format(value);
};
export function TrendBarChart({ points, range }: { points: AnalyticsTrendPoint[]; range: AnalyticsRange }) {
export function TrendBarChart({
points,
range,
displayDecimals,
}: {
points: AnalyticsTrendPoint[];
range: AnalyticsRange;
displayDecimals: number;
}) {
const peakRequests = Math.max(...points.map((point) => point.requests), 1);
const minColumnWidth = range === "90d" ? 28 : range === "30d" ? 34 : range === "24h" ? 42 : 56;
const chartMinWidth = Math.max(points.length * minColumnWidth, 720);
@@ -73,7 +81,7 @@ export function TrendBarChart({ points, range }: { points: AnalyticsTrendPoint[]
<div
className="group relative flex h-56 items-end bg-muted/50 outline-none hover:bg-muted"
tabIndex={0}
aria-label={`${label},请求数 ${formatCompactNumber(point.requests)},花费 ${formatCurrency(point.totalCost)}`}
aria-label={`${label},请求数 ${formatCompactNumber(point.requests)},花费 ${formatCurrency(point.totalCost, displayDecimals)}`}
>
<div className="pointer-events-none absolute top-3 left-[-100%] right-[-100%] mx-auto z-20 w-24 rounded-md border border-border/50 bg-card px-3 py-2 text-left opacity-0 shadow-lg transition-all duration-200 translate-y-2 group-hover:translate-y-0 group-hover:opacity-100 group-focus-visible:translate-y-0 group-focus-visible:opacity-100">
<div className="text-[10px] text-muted-foreground">Requests</div>
@@ -81,7 +89,7 @@ export function TrendBarChart({ points, range }: { points: AnalyticsTrendPoint[]
{formatCompactNumber(point.requests)}
</div>
<div className="text-[10px] text-muted-foreground">Total Cost</div>
<div className="text-xs font-semibold text-foreground">{formatCurrency(point.totalCost)}</div>
<div className="text-xs font-semibold text-foreground">{formatCurrency(point.totalCost, displayDecimals)}</div>
</div>
<div className="absolute inset-0 ring-1 ring-sky-400/0 transition-all duration-300 group-focus-visible:ring-sky-400/30" />
<div
+24
View File
@@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
import { BillingConfig } from "@/types";
import {
DEFAULT_BILLING_DISPLAY_DECIMALS,
normalizeBillingConfig,
} from "@/lib/billing";
export const BILLING_CONFIG_QUERY_KEY = ["billing-config"] as const;
export function useBillingConfig() {
return useQuery({
queryKey: BILLING_CONFIG_QUERY_KEY,
queryFn: async () => {
const response = await apiClient.getBillingConfig();
return normalizeBillingConfig(response.data as BillingConfig | undefined);
},
staleTime: 60_000,
placeholderData: {
displayDecimals: DEFAULT_BILLING_DISPLAY_DECIMALS,
},
});
}
+43
View File
@@ -0,0 +1,43 @@
import { BillingConfig } from "@/types";
export const BILLING_RAW_SCALE = 1_000_000_000;
export const DEFAULT_BILLING_DISPLAY_DECIMALS = 6;
export const MIN_BILLING_DISPLAY_DECIMALS = 0;
export const MAX_BILLING_DISPLAY_DECIMALS = 9;
const trimTrailingZeros = (value: string): string => {
return value.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, "");
};
export const normalizeBillingDisplayDecimals = (value: unknown): number => {
const parsed = typeof value === "number" && Number.isFinite(value)
? Math.round(value)
: DEFAULT_BILLING_DISPLAY_DECIMALS;
return Math.min(MAX_BILLING_DISPLAY_DECIMALS, Math.max(MIN_BILLING_DISPLAY_DECIMALS, parsed));
};
export const normalizeBillingConfig = (value?: Partial<BillingConfig> | null): BillingConfig => {
return {
displayDecimals: normalizeBillingDisplayDecimals(value?.displayDecimals),
};
};
export const rawBillingToUsd = (value: number): number => {
return Number.isFinite(value) ? value / BILLING_RAW_SCALE : 0;
};
export const usdToRawBilling = (value: string | number): number => {
const parsed = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(parsed)) {
return 0;
}
return Math.max(0, Math.round(parsed * BILLING_RAW_SCALE));
};
export const formatRawBillingInput = (value: number, displayDecimals = DEFAULT_BILLING_DISPLAY_DECIMALS): string => {
const decimals = normalizeBillingDisplayDecimals(displayDecimals);
const formatted = rawBillingToUsd(value).toFixed(decimals);
return trimTrailingZeros(formatted) || "0";
};
+15 -7
View File
@@ -1,5 +1,10 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import {
DEFAULT_BILLING_DISPLAY_DECIMALS,
normalizeBillingDisplayDecimals,
rawBillingToUsd,
} from "./billing"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -24,15 +29,18 @@ export function parseUtcTimestamp(value: string): Date | null {
return Number.isNaN(date.getTime()) ? null : date
}
export function formatCurrency(value: number): string {
return `$${(value / 1000000).toFixed(2)}`
export function formatCurrency(
value: number,
displayDecimals = DEFAULT_BILLING_DISPLAY_DECIMALS,
): string {
const decimals = normalizeBillingDisplayDecimals(displayDecimals)
const normalizedValue = rawBillingToUsd(value)
const safeValue = Math.abs(normalizedValue) < 10 ** -decimals ? 0 : normalizedValue
return `$${safeValue.toFixed(decimals)}`
}
export function formatQuota(value: number): string {
if (value >= 1000000) {
return `$${(value / 1000000).toFixed(2)}`
}
return value.toString()
export function formatQuota(value: number, displayDecimals = DEFAULT_BILLING_DISPLAY_DECIMALS): string {
return formatCurrency(value, displayDecimals)
}
export function copyToClipboard(text: string): Promise<void> {
+6 -2
View File
@@ -13,6 +13,7 @@ import { PageContainer } from "@/components/ui/page-container";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Select } from "@/components/ui/select";
import { useBillingConfig } from "@/hooks/use-billing-config";
import { readScopedCache, writeScopedCache } from "@/lib/local-cache";
import { cn, formatCurrency } from "@/lib/utils";
import { Activity, CircleDollarSign, Clock3, DatabaseZap, RefreshCw, ShieldCheck } from "lucide-react";
@@ -95,6 +96,8 @@ const getInitialAnalyticsRange = (): AnalyticsRange => {
export function Analytics() {
const [range, setRange] = useState<AnalyticsRange>(getInitialAnalyticsRange);
const { data: billingConfig } = useBillingConfig();
const displayDecimals = billingConfig?.displayDecimals ?? 6;
const overviewCacheEntry = useMemo(
() => readScopedCache<AnalyticsOverviewData>(getAnalyticsOverviewCacheKey(range)),
@@ -292,7 +295,7 @@ export function Analytics() {
<CardTitle className="text-3xl">{formatCompactNumber(overview?.totals.completionTokens || 0)}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between text-sm text-muted-foreground">
<span>{formatCurrency(overview?.totals.totalCost || 0)}</span>
<span>{formatCurrency(overview?.totals.totalCost || 0, displayDecimals)}</span>
<CircleDollarSign className="h-4 w-4 text-amber-500" />
</CardContent>
</Card>
@@ -315,7 +318,7 @@ export function Analytics() {
<p className="mt-1 text-sm text-muted-foreground">写入第一批使用事件后,这里会展示完整柱状趋势。</p>
</Card>
) : (
<TrendBarChart points={trend.points} range={range} />
<TrendBarChart points={trend.points} range={range} displayDecimals={displayDecimals} />
)}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
@@ -329,6 +332,7 @@ export function Analytics() {
isError={breakdownStateMap[chart.dimension].isError}
barClassName={chart.barClassName}
badgeClassName={chart.badgeClassName}
displayDecimals={displayDecimals}
/>
))}
</div>
+125 -26
View File
@@ -8,14 +8,50 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/components/ui/use-toast";
import { BILLING_CONFIG_QUERY_KEY, useBillingConfig } from "@/hooks/use-billing-config";
import { normalizeBillingDisplayDecimals } from "@/lib/billing";
import { cn } from "@/lib/utils";
import { getUniqueModelNamesFromChannels } from "@/lib/channel-models";
import { Plus, RefreshCw, Trash2, FileJson, FileText, DollarSign, Check, Info, Search } from "lucide-react";
import { Plus, RefreshCw, Trash2, FileJson, FileText, DollarSign, Check, Search } from "lucide-react";
import { PageContainer } from "@/components/ui/page-container";
import { Card } from "@/components/ui/card";
type EditMode = "table" | "json";
const PRICING_DECIMALS = 6;
const PRICING_INPUT_STEP = "0.000001";
const normalizePricingNumber = (value: number): number => {
if (!Number.isFinite(value)) {
return 0;
}
return Number(value.toFixed(PRICING_DECIMALS));
};
const normalizePricingConfig = (config: PricingConfig): PricingConfig => {
return Object.fromEntries(
Object.entries(config)
.map(([model, pricing]) => {
const normalizedModel = model.trim();
if (!normalizedModel) {
return null;
}
return [
normalizedModel,
{
input: normalizePricingNumber(pricing.input),
output: normalizePricingNumber(pricing.output),
cache: normalizePricingNumber(pricing.cache || 0),
request: normalizePricingNumber(pricing.request || 0),
},
] as const;
})
.filter((entry): entry is readonly [string, PricingConfig[string]] => entry !== null),
);
};
export function Pricing() {
const [editMode, setEditMode] = useState<EditMode>("table");
const [jsonValue, setJsonValue] = useState("");
@@ -24,9 +60,11 @@ export function Pricing() {
>([]);
const [searchQuery, setSearchQuery] = useState("");
const [modelOptions, setModelOptions] = useState<AutoCompleteOption[]>([]);
const [billingDisplayDecimals, setBillingDisplayDecimals] = useState(PRICING_DECIMALS);
const { addToast } = useToast();
const queryClient = useQueryClient();
const billingConfigQuery = useBillingConfig();
const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: ["pricing"],
@@ -53,7 +91,8 @@ export function Pricing() {
useEffect(() => {
if (data) {
const rows = Object.entries(data).map(([model, pricing]) => ({
const normalizedConfig = normalizePricingConfig(data);
const rows = Object.entries(normalizedConfig).map(([model, pricing]) => ({
model,
input: pricing.input,
output: pricing.output,
@@ -61,10 +100,16 @@ export function Pricing() {
request: pricing.request || 0,
}));
setPricingRows(rows);
setJsonValue(JSON.stringify(data, null, 2));
setJsonValue(JSON.stringify(normalizedConfig, null, 2));
}
}, [data]);
useEffect(() => {
if (billingConfigQuery.data) {
setBillingDisplayDecimals(billingConfigQuery.data.displayDecimals);
}
}, [billingConfigQuery.data]);
const saveMutation = useMutation({
mutationFn: async (config: PricingConfig) => {
return apiClient.savePricing(config);
@@ -78,6 +123,21 @@ export function Pricing() {
},
});
const billingConfigMutation = useMutation({
mutationFn: async (displayDecimals: number) => {
return apiClient.saveBillingConfig({
displayDecimals: normalizeBillingDisplayDecimals(displayDecimals),
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: BILLING_CONFIG_QUERY_KEY });
addToast("金额显示精度已保存", "success");
},
onError: (error: Error) => {
addToast("保存显示精度失败:" + error.message, "error");
},
});
const handleSave = () => {
let config: PricingConfig;
@@ -91,10 +151,10 @@ export function Pricing() {
pricingRows.forEach((row) => {
if (row.model) {
config[row.model] = {
input: row.input || 0,
output: row.output || 0,
cache: row.cache || 0,
request: row.request || 0,
input: normalizePricingNumber(row.input || 0),
output: normalizePricingNumber(row.output || 0),
cache: normalizePricingNumber(row.cache || 0),
request: normalizePricingNumber(row.request || 0),
};
}
});
@@ -107,7 +167,7 @@ export function Pricing() {
}
}
saveMutation.mutate(config);
saveMutation.mutate(normalizePricingConfig(config));
};
const toggleEditMode = () => {
@@ -116,24 +176,24 @@ export function Pricing() {
pricingRows.forEach((row) => {
if (row.model) {
config[row.model] = {
input: row.input || 0,
output: row.output || 0,
cache: row.cache || 0,
request: row.request || 0,
input: normalizePricingNumber(row.input || 0),
output: normalizePricingNumber(row.output || 0),
cache: normalizePricingNumber(row.cache || 0),
request: normalizePricingNumber(row.request || 0),
};
}
});
setJsonValue(JSON.stringify(config, null, 2));
setJsonValue(JSON.stringify(normalizePricingConfig(config), null, 2));
setEditMode("json");
} else {
try {
const config = JSON.parse(jsonValue);
const config = normalizePricingConfig(JSON.parse(jsonValue));
const rows = Object.entries(config).map(([model, pricing]: [string, unknown]) => ({
model,
input: (pricing as { input: number }).input || 0,
output: (pricing as { output: number }).output || 0,
cache: (pricing as { cache?: number }).cache || 0,
request: (pricing as { request?: number }).request || 0,
input: normalizePricingNumber((pricing as { input: number }).input || 0),
output: normalizePricingNumber((pricing as { output: number }).output || 0),
cache: normalizePricingNumber((pricing as { cache?: number }).cache || 0),
request: normalizePricingNumber((pricing as { request?: number }).request || 0),
}));
setPricingRows(rows);
setEditMode("table");
@@ -176,11 +236,19 @@ export function Pricing() {
return (
<PageContainer
title="定价管理"
description="配置模型使用成本倍率,基础配额单位:1M tokens = $1.00"
description="配置模型成本倍率,并独立控制金额展示精度。"
actions={
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
<Button
variant="outline"
size="sm"
onClick={() => {
refetch();
billingConfigQuery.refetch();
}}
disabled={isFetching || billingConfigQuery.isFetching}
>
<RefreshCw className={cn("h-4 w-4", (isFetching || billingConfigQuery.isFetching) && "animate-spin")} />
</Button>
<Button variant="outline" size="sm" onClick={toggleEditMode}>
{editMode === "table" ? <FileJson className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
@@ -193,6 +261,33 @@ export function Pricing() {
</div>
}
>
<Card className="mb-4 border-0">
<div className="flex flex-col gap-4 p-5 md:flex-row md:items-end md:justify-between">
<div className="space-y-2">
<Label className="text-sm font-medium">金额显示小数位</Label>
<Input
type="number"
min="0"
max="9"
value={billingDisplayDecimals}
onChange={(e) => setBillingDisplayDecimals(normalizeBillingDisplayDecimals(Number(e.target.value)))}
className="w-32"
/>
<p className="text-xs text-muted-foreground">
默认 6 位。只影响前端展示,不改变 API 返回的原始数值。
</p>
</div>
<Button
variant="outline"
onClick={() => billingConfigMutation.mutate(billingDisplayDecimals)}
disabled={billingConfigMutation.isPending}
>
<Check className="h-4 w-4 mr-1" />
保存显示设置
</Button>
</div>
</Card>
{editMode === "table" ? (
isLoading ? (
<div className="flex items-center justify-center py-20">
@@ -208,7 +303,7 @@ export function Pricing() {
</div>
<h3 className="font-semibold text-lg mb-1">暂无定价配置</h3>
<p className="text-muted-foreground text-sm text-center max-w-sm mb-6">
可调整倍率控制输入/输出成本,或者设置按次扣费。
可调整倍率控制输入/输出成本,或者设置按次扣费;金额展示精度单独配置。
</p>
<Button onClick={addRow}>
<Plus className="h-4 w-4 mr-1" />
@@ -265,32 +360,36 @@ export function Pricing() {
type="number"
value={row.input}
onChange={(e) => updateRow(row._i, "input", parseFloat(e.target.value) || 0)}
step="0.001"
step={PRICING_INPUT_STEP}
min="0"
placeholder="0.000000"
className="font-mono text-sm h-8"
/>
<Input
type="number"
value={row.output}
onChange={(e) => updateRow(row._i, "output", parseFloat(e.target.value) || 0)}
step="0.001"
step={PRICING_INPUT_STEP}
min="0"
placeholder="0.000000"
className="font-mono text-sm h-8"
/>
<Input
type="number"
value={row.cache}
onChange={(e) => updateRow(row._i, "cache", parseFloat(e.target.value) || 0)}
step="0.001"
step={PRICING_INPUT_STEP}
min="0"
placeholder="0.000000"
className="font-mono text-sm h-8"
/>
<Input
type="number"
value={row.request}
onChange={(e) => updateRow(row._i, "request", parseFloat(e.target.value) || 0)}
step="0.001"
step={PRICING_INPUT_STEP}
min="0"
placeholder="0.000000"
className="font-mono text-sm h-8"
/>
<Button
+41 -18
View File
@@ -9,6 +9,8 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast'
import { useBillingConfig } from '@/hooks/use-billing-config'
import { formatRawBillingInput, usdToRawBilling } from '@/lib/billing'
import { formatCurrency, copyToClipboard, generateTokenKey, cn } from '@/lib/utils'
import {
Plus,
@@ -23,7 +25,6 @@ import {
ArrowLeft,
Check,
MoreHorizontal,
ChevronRight,
AlertCircle,
Search,
RotateCcw,
@@ -57,9 +58,13 @@ export function Tokens({
const [selectedChannels, setSelectedChannels] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [openMenu, setOpenMenu] = useState<string | null>(null)
const [quotaInputValue, setQuotaInputValue] = useState('0')
const { addToast } = useToast()
const queryClient = useQueryClient()
const { data: billingConfig } = useBillingConfig()
const displayDecimals = billingConfig?.displayDecimals ?? 6
const quotaInputStep = displayDecimals > 0 ? `0.${'0'.repeat(displayDecimals - 1)}1` : '1'
const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: ['tokens'],
@@ -89,8 +94,9 @@ export function Tokens({
setFormData(config)
setJsonValue(JSON.stringify(config, null, 2))
setSelectedChannels(config.channel_keys || [])
setQuotaInputValue(formatRawBillingInput(config.total_quota || 0, displayDecimals))
setView('form')
}, [])
}, [displayDecimals])
// Close menu when clicking outside
useEffect(() => {
@@ -146,6 +152,7 @@ export function Tokens({
setSelectedChannels([])
setEditingKey(null)
setEditMode('form')
setQuotaInputValue('0')
}, [])
useEffect(() => {
@@ -234,6 +241,7 @@ export function Tokens({
} else {
try {
config = JSON.parse(jsonValue)
config.total_quota = Math.max(0, Math.round(config.total_quota || 0))
} catch {
addToast('JSON格式错误', 'error')
return
@@ -251,8 +259,13 @@ export function Tokens({
} else {
try {
const config = JSON.parse(jsonValue)
setFormData(config)
setSelectedChannels(config.channel_keys || [])
const normalizedConfig = {
...config,
total_quota: Math.max(0, Math.round(config.total_quota || 0)),
}
setFormData(normalizedConfig)
setSelectedChannels(normalizedConfig.channel_keys || [])
setQuotaInputValue(formatRawBillingInput(normalizedConfig.total_quota || 0, displayDecimals))
setEditMode('form')
} catch {
addToast('JSON格式错误', 'error')
@@ -267,12 +280,12 @@ export function Tokens({
}
const quotaPresets = [
{ label: '$1', value: 1000000 },
{ label: '$5', value: 5000000 },
{ label: '$10', value: 10000000 },
{ label: '$20', value: 20000000 },
{ label: '$50', value: 50000000 },
{ label: '$100', value: 100000000 },
{ label: '$1', value: usdToRawBilling(1) },
{ label: '$5', value: usdToRawBilling(5) },
{ label: '$10', value: usdToRawBilling(10) },
{ label: '$20', value: usdToRawBilling(20) },
{ label: '$50', value: usdToRawBilling(50) },
{ label: '$100', value: usdToRawBilling(100) },
]
const filteredData = data?.filter((token) => {
@@ -413,7 +426,7 @@ export function Tokens({
渠道: <span className="text-foreground">{channelKeys.length === 0 ? '全部' : `${channelKeys.length}个`}</span>
</span>
<span className="text-muted-foreground">
配额: <span className="text-foreground">{formatCurrency(token.usage || 0)}/{formatCurrency(config.total_quota || 0)}</span>
配额: <span className="text-foreground">{formatCurrency(token.usage || 0, displayDecimals)}/{formatCurrency(config.total_quota || 0, displayDecimals)}</span>
</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
@@ -448,7 +461,7 @@ export function Tokens({
<div className="w-48 flex-shrink-0">
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-muted-foreground">
{formatCurrency(token.usage || 0)} / {formatCurrency(config.total_quota || 0)}
{formatCurrency(token.usage || 0, displayDecimals)} / {formatCurrency(config.total_quota || 0, displayDecimals)}
</span>
<span className={cn(
"font-medium",
@@ -615,21 +628,31 @@ export function Tokens({
<Card>
<CardContent className="p-5">
<h3 className="font-medium mb-1">使用配额</h3>
<p className="text-sm text-muted-foreground mb-4">设置令牌的最大使用额度(1百万 token = $1.00)</p>
<p className="text-sm text-muted-foreground mb-4">
输入美元金额展示值,系统会按原始整数计费单位保存;当前默认展示 {displayDecimals} 位小数。
</p>
<div className="space-y-4">
<Input
type="number"
value={formData.total_quota}
onChange={(e) => setFormData({ ...formData, total_quota: parseInt(e.target.value) || 0 })}
placeholder="1000000"
value={quotaInputValue}
onChange={(e) => {
const nextValue = e.target.value
setQuotaInputValue(nextValue)
setFormData({ ...formData, total_quota: usdToRawBilling(nextValue) })
}}
placeholder={displayDecimals > 0 ? `1.${'0'.repeat(displayDecimals)}` : '1'}
min="0"
step={quotaInputStep}
/>
<div className="flex flex-wrap gap-2">
{quotaPresets.map((preset) => (
<button
key={preset.value}
type="button"
onClick={() => setFormData({ ...formData, total_quota: preset.value })}
onClick={() => {
setFormData({ ...formData, total_quota: preset.value })
setQuotaInputValue(formatRawBillingInput(preset.value, displayDecimals))
}}
className={cn(
"px-4 py-2 rounded-lg text-sm font-medium transition-all",
formData.total_quota === preset.value
@@ -654,7 +677,7 @@ export function Tokens({
onChange={(e) => setJsonValue(e.target.value)}
rows={14}
className="font-mono text-sm"
placeholder='{"name": "令牌名称", "channel_keys": [], "total_quota": 1000000}'
placeholder='{"name": "令牌名称", "channel_keys": [], "total_quota": 1000000000}'
/>
</CardContent>
</Card>
+5 -2
View File
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { useBillingConfig } from "@/hooks/use-billing-config";
import { readScopedCache, writeScopedCache } from "@/lib/local-cache";
import { cn, formatCurrency, parseUtcTimestamp } from "@/lib/utils";
import { Eye, RefreshCw, Search } from "lucide-react";
@@ -182,6 +183,8 @@ const DetailField = ({ label, value, mono = false }: { label: string; value: str
};
export function UsageLogs() {
const { data: billingConfig } = useBillingConfig();
const displayDecimals = billingConfig?.displayDecimals ?? 6;
const defaultFilters = useMemo(() => createFilterPreset("24h"), []);
const cachedLogsSnapshot = useMemo(() => readScopedCache<UsageLogPageCacheSnapshot>(USAGE_LOGS_PAGE_CACHE_KEY), []);
const initialDraftFilters = useMemo(
@@ -450,7 +453,7 @@ export function UsageLogs() {
输入 {formatCompactNumber(item.promptTokens)} / 输出{" "}
{formatCompactNumber(item.completionTokens)}
</div>
<div className="mt-1 text-xs text-muted-foreground">{formatCurrency(item.totalCost)}</div>
<div className="mt-1 text-xs text-muted-foreground">{formatCurrency(item.totalCost, displayDecimals)}</div>
</td>
<td className="px-2 py-3 text-right">
@@ -577,7 +580,7 @@ export function UsageLogs() {
label="输入 / 输出 Tokens"
value={`${formatCompactNumber(selectedItem.promptTokens)} / ${formatCompactNumber(selectedItem.completionTokens)}`}
/>
<DetailField label="总成本" value={formatCurrency(selectedItem.totalCost)} />
<DetailField label="总成本" value={formatCurrency(selectedItem.totalCost, displayDecimals)} />
<DetailField label="延迟" value={formatDuration(selectedItem.latencyMs)} />
</div>
</div>
+4
View File
@@ -38,6 +38,10 @@ export interface TokenConfig {
total_quota: number
}
export interface BillingConfig {
displayDecimals: number
}
export interface PricingModel {
input: number
output: number
+72
View File
@@ -0,0 +1,72 @@
import { Context } from "hono";
import { OpenAPIRoute } from "chanfana";
import { z } from "zod";
import { CONSTANTS } from "../constants";
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
import { BillingConfig, normalizeBillingConfig } from "../billing";
import { getJsonSetting, saveSetting } from "../utils";
const billingConfigSchema = z.object({
displayDecimals: z.number().int().min(0).max(9),
});
export class BillingConfigGetEndpoint extends OpenAPIRoute {
schema = {
tags: ["Admin API"],
summary: "Get billing display configuration",
responses: {
...CommonSuccessfulResponse(billingConfigSchema),
...CommonErrorResponse,
},
};
async handle(c: Context<HonoCustomType>) {
const billingConfig = await getJsonSetting<BillingConfig>(
c,
CONSTANTS.BILLING_CONFIG_KEY
);
return {
success: true,
data: normalizeBillingConfig(billingConfig),
} as CommonResponse;
}
}
export class BillingConfigUpdateEndpoint extends OpenAPIRoute {
schema = {
tags: ["Admin API"],
summary: "Update billing display configuration",
request: {
body: {
content: {
"application/json": {
schema: billingConfigSchema,
},
},
},
},
responses: {
...CommonSuccessfulResponse(billingConfigSchema),
...CommonErrorResponse,
},
};
async handle(c: Context<HonoCustomType>) {
const body = await c.req.json<BillingConfig>();
const config = normalizeBillingConfig(body);
await saveSetting(
c,
CONSTANTS.BILLING_CONFIG_KEY,
JSON.stringify(config)
);
return {
success: true,
data: config,
message: "Billing config updated successfully",
} as CommonResponse;
}
}
+5
View File
@@ -11,6 +11,9 @@ import {
import {
PricingGetEndpoint, PricingUpdateEndpoint
} from "./pricing_api"
import {
BillingConfigGetEndpoint, BillingConfigUpdateEndpoint
} from "./billing_api"
import {
AnalyticsOverviewEndpoint,
AnalyticsTrendEndpoint,
@@ -54,6 +57,8 @@ api.delete("/api/admin/token/:key", TokenDeleteEndpoint)
// Pricing management routes
api.get("/api/admin/pricing", PricingGetEndpoint)
api.post("/api/admin/pricing", PricingUpdateEndpoint)
api.get("/api/admin/billing/config", BillingConfigGetEndpoint)
api.post("/api/admin/billing/config", BillingConfigUpdateEndpoint)
// Analytics management routes
api.get("/api/admin/analytics/overview", AnalyticsOverviewEndpoint)
+2 -2
View File
@@ -13,7 +13,7 @@ export class TokenListEndpoint extends OpenAPIRoute {
...CommonSuccessfulResponse(z.array(z.object({
key: z.string(),
value: z.string(),
usage: z.number(),
usage: z.number().int(),
created_at: z.string(),
updated_at: z.string(),
}))),
@@ -48,7 +48,7 @@ export class TokenUpsertEndpoint extends OpenAPIRoute {
schema: z.object({
name: z.string().describe('Token name'),
channel_keys: z.array(z.string()).describe('Channel keys to bind (empty array means access to all channels)'),
total_quota: z.number().describe('Total quota amount'),
total_quota: z.number().int().nonnegative().describe('Total quota amount in raw billing units'),
}),
},
},
+12 -4
View File
@@ -1,5 +1,9 @@
import { Context } from "hono";
import {
calculateRequestCostRaw,
calculateTokenRateCostRaw,
} from "../billing";
import { CONSTANTS } from "../constants";
import { getJsonSetting } from "../utils";
@@ -45,7 +49,7 @@ export const TokenUtils = {
): Promise<UsageCostResult> {
const pricing = await this.getPricing(c, model, targetChannelConfig);
const hasTokens = usage.prompt_tokens != null && usage.completion_tokens != null;
const requestCost = pricing?.request || 0;
const requestCost = calculateRequestCostRaw(pricing?.request || 0);
if (!pricing || (!hasTokens && requestCost <= 0)) {
return {
@@ -58,12 +62,16 @@ export const TokenUtils = {
};
}
const inputCost = hasTokens ? usage.prompt_tokens! * pricing.input : 0;
const outputCost = hasTokens ? usage.completion_tokens! * pricing.output : 0;
const inputCost = hasTokens
? calculateTokenRateCostRaw(usage.prompt_tokens!, pricing.input)
: 0;
const outputCost = hasTokens
? calculateTokenRateCostRaw(usage.completion_tokens!, pricing.output)
: 0;
let cacheCost = 0;
if (hasTokens && usage.cached_tokens && usage.cached_tokens > 0 && pricing.cache) {
cacheCost = usage.cached_tokens * pricing.cache;
cacheCost = calculateTokenRateCostRaw(usage.cached_tokens, pricing.cache);
}
return {
+26 -5
View File
@@ -1,5 +1,6 @@
import { Context } from "hono";
import { BILLING_RAW_SCALE, LEGACY_TO_RAW_FACTOR } from "../billing";
import { DEFAULT_USAGE_ANALYTICS_DATASET_NAME } from "./usage-logger";
export type AnalyticsRange = "24h" | "7d" | "30d" | "90d";
@@ -129,6 +130,7 @@ const DOUBLE_FIELDS = {
retryCount: "double7",
upstreamStatus: "double8",
successFlag: "double9",
billingScale: "double10",
} as const;
const BREAKDOWN_FIELDS: Record<AnalyticsBreakdownDimension, string> = {
@@ -194,6 +196,7 @@ const USAGE_LOG_NUMERIC_COLUMNS = [
DOUBLE_FIELDS.retryCount,
DOUBLE_FIELDS.upstreamStatus,
DOUBLE_FIELDS.successFlag,
DOUBLE_FIELDS.billingScale,
];
const ALL_PROBED_COLUMNS = [
@@ -535,6 +538,18 @@ const buildDoubleSelect = (
: `0 AS ${alias}`;
};
const buildNormalizedTotalCostExpression = (support: DatasetColumnSupport): string => {
if (!isColumnAvailable(support, DOUBLE_FIELDS.totalCost)) {
return "0";
}
if (!isColumnAvailable(support, DOUBLE_FIELDS.billingScale)) {
return `${DOUBLE_FIELDS.totalCost} * ${LEGACY_TO_RAW_FACTOR}`;
}
return `if(${DOUBLE_FIELDS.billingScale} > 0, ${DOUBLE_FIELDS.totalCost} * (${BILLING_RAW_SCALE} / ${DOUBLE_FIELDS.billingScale}), ${DOUBLE_FIELDS.totalCost} * ${LEGACY_TO_RAW_FACTOR})`;
};
const hasAnyLegacyLogSchema = (support: DatasetColumnSupport): boolean => {
return LEGACY_BLOB_COLUMNS.some((columnName) => isColumnAvailable(support, columnName));
};
@@ -608,11 +623,13 @@ export const queryUsageOverview = async (
) => {
const { range, config } = getRangeConfig(requestedRange);
const dataset = getDatasetName(c);
const columnSupport = await getDatasetColumnSupport(c, dataset, buildRangeWhereClause(config));
const normalizedTotalCostExpression = buildNormalizedTotalCostExpression(columnSupport);
const rows = await runAnalyticsQuery<Record<string, unknown>>(c, `
SELECT
sum(_sample_interval) AS requests,
sum(${DOUBLE_FIELDS.successFlag} * _sample_interval) AS successes,
sum(${DOUBLE_FIELDS.totalCost} * _sample_interval) AS total_cost,
sum(${normalizedTotalCostExpression} * _sample_interval) AS total_cost,
sum(${DOUBLE_FIELDS.totalTokens} * _sample_interval) AS total_tokens,
sum(${DOUBLE_FIELDS.promptTokens} * _sample_interval) AS prompt_tokens,
sum(${DOUBLE_FIELDS.completionTokens} * _sample_interval) AS completion_tokens,
@@ -648,12 +665,14 @@ export const queryUsageTrend = async (
const { range, config } = getRangeConfig(requestedRange);
const dataset = getDatasetName(c);
const trendWindow = buildTrendWindow(range, config);
const columnSupport = await getDatasetColumnSupport(c, dataset, trendWindow.whereClause);
const normalizedTotalCostExpression = buildNormalizedTotalCostExpression(columnSupport);
const rows = await runAnalyticsQuery<Record<string, unknown>>(c, `
SELECT
toUnixTimestamp(${buildBucketClause(config)}) AS bucket_ts,
sum(_sample_interval) AS requests,
sum(${DOUBLE_FIELDS.successFlag} * _sample_interval) AS successes,
sum(${DOUBLE_FIELDS.totalCost} * _sample_interval) AS total_cost
sum(${normalizedTotalCostExpression} * _sample_interval) AS total_cost
FROM ${dataset}
WHERE ${trendWindow.whereClause}
GROUP BY bucket_ts
@@ -696,12 +715,14 @@ export const queryUsageBreakdown = async (
: "token") as AnalyticsBreakdownDimension;
const dataset = getDatasetName(c);
const dimensionField = BREAKDOWN_FIELDS[dimension];
const columnSupport = await getDatasetColumnSupport(c, dataset, buildRangeWhereClause(config));
const normalizedTotalCostExpression = buildNormalizedTotalCostExpression(columnSupport);
const rows = await runAnalyticsQuery<Record<string, unknown>>(c, `
SELECT
${dimensionField} AS label,
sum(_sample_interval) AS requests,
sum(${DOUBLE_FIELDS.successFlag} * _sample_interval) AS successes,
sum(${DOUBLE_FIELDS.totalCost} * _sample_interval) AS total_cost,
sum(${normalizedTotalCostExpression} * _sample_interval) AS total_cost,
sum(${DOUBLE_FIELDS.promptTokens} * _sample_interval) AS prompt_tokens,
sum(${DOUBLE_FIELDS.completionTokens} * _sample_interval) AS completion_tokens,
sum(${DOUBLE_FIELDS.latencyMs} * _sample_interval) / sum(_sample_interval) AS avg_latency_ms
@@ -789,7 +810,7 @@ SELECT
${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.completionTokens, "completion_tokens")},
${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.cachedTokens, "cached_tokens")},
${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.totalTokens, "total_tokens")},
${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.totalCost, "total_cost")},
${buildNormalizedTotalCostExpression(columnSupport)} AS total_cost,
${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.latencyMs, "latency_ms")},
${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.retryCount, "retry_count")},
${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.upstreamStatus, "upstream_status")}
@@ -962,7 +983,7 @@ SELECT
${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.completionTokens, "completion_tokens")},
${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.cachedTokens, "cached_tokens")},
${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.totalTokens, "total_tokens")},
${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.totalCost, "total_cost")},
${buildNormalizedTotalCostExpression(columnSupport)} AS total_cost,
${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.latencyMs, "latency_ms")},
${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.retryCount, "retry_count")},
${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.upstreamStatus, "upstream_status")}
+2
View File
@@ -1,4 +1,5 @@
import { Context } from "hono";
import { BILLING_RAW_SCALE } from "../billing";
export const DEFAULT_USAGE_ANALYTICS_DATASET_NAME = "usage_events_by_token";
@@ -234,6 +235,7 @@ const buildCommonPoint = (
safeNumber(context.trackingState.retryCount),
upstreamStatus,
result === "success" ? 1 : 0,
BILLING_RAW_SCALE,
],
};
};
+61
View File
@@ -0,0 +1,61 @@
export const BILLING_RAW_SCALE = 1_000_000_000;
export const LEGACY_BILLING_SCALE = 1_000_000;
export const LEGACY_TO_RAW_FACTOR = BILLING_RAW_SCALE / LEGACY_BILLING_SCALE;
export const DEFAULT_BILLING_DISPLAY_DECIMALS = 6;
export const MIN_BILLING_DISPLAY_DECIMALS = 0;
export const MAX_BILLING_DISPLAY_DECIMALS = 9;
export type BillingConfig = {
displayDecimals: number;
}
const toFiniteNumber = (value: unknown): number => {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return 0;
};
export const normalizeRawBillingAmount = (value: unknown): number => {
return Math.max(0, Math.round(toFiniteNumber(value)));
};
export const legacyBillingAmountToRaw = (value: unknown): number => {
return normalizeRawBillingAmount(toFiniteNumber(value) * LEGACY_TO_RAW_FACTOR);
};
export const calculateTokenRateCostRaw = (tokens: unknown, rate: unknown): number => {
return normalizeRawBillingAmount(toFiniteNumber(tokens) * toFiniteNumber(rate) * LEGACY_TO_RAW_FACTOR);
};
export const calculateRequestCostRaw = (value: unknown): number => {
return legacyBillingAmountToRaw(value);
};
export const normalizeBillingDisplayDecimals = (value: unknown): number => {
const parsed = Math.round(toFiniteNumber(value));
if (!Number.isFinite(parsed)) {
return DEFAULT_BILLING_DISPLAY_DECIMALS;
}
return Math.min(
MAX_BILLING_DISPLAY_DECIMALS,
Math.max(MIN_BILLING_DISPLAY_DECIMALS, parsed)
);
};
export const normalizeBillingConfig = (value: Partial<BillingConfig> | null | undefined): BillingConfig => {
return {
displayDecimals: normalizeBillingDisplayDecimals(value?.displayDecimals),
};
};
+2 -1
View File
@@ -1,6 +1,7 @@
export const CONSTANTS = {
VERSION: "v0.0.1",
DB_VERSION_KEY: "db_version",
DB_VERSION: "v0.0.5",
DB_VERSION: "v0.0.6",
MODEL_PRICING_KEY: "MODEL_PRICING_CONFIG",
BILLING_CONFIG_KEY: "BILLING_CONFIG",
}
+113 -1
View File
@@ -1,4 +1,5 @@
import { Context } from "hono";
import { legacyBillingAmountToRaw } from "../billing";
import { CONSTANTS } from "../constants";
import { getSetting, saveSetting } from "../utils";
import { sanitizeChannelConfig } from "../channel-config";
@@ -13,7 +14,7 @@ CREATE TABLE IF NOT EXISTS channel_config (
CREATE TABLE IF NOT EXISTS api_token (
key TEXT PRIMARY KEY,
value TEXT,
usage REAL DEFAULT 0,
usage INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -33,10 +34,119 @@ const getInitQuery = () => DB_INIT_QUERIES.replace(/[\r\n]/g, "")
.filter(Boolean)
.join(";\n");
const toSingleStatement = (sql: string) => {
return sql
.replace(/[\r\n]/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/;?$/, ";");
};
const initializeSchema = async (c: Context<HonoCustomType>) => {
await c.env.DB.exec(getInitQuery());
};
type TableInfoRow = {
name: string;
type: string;
}
type SqliteMasterRow = {
name: string;
}
type LegacyTokenRow = {
key: string;
value: string;
usage: number | null;
created_at: string;
updated_at: string;
}
const doesTableExist = async (c: Context<HonoCustomType>, tableName: string): Promise<boolean> => {
const table = await c.env.DB.prepare(
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`
).bind(tableName).first<SqliteMasterRow>();
return Boolean(table?.name);
};
const getApiTokenUsageColumnType = async (c: Context<HonoCustomType>): Promise<string> => {
const tableInfo = await c.env.DB.prepare(
`PRAGMA table_info(api_token)`
).all<TableInfoRow>();
const usageColumn = tableInfo.results?.find((column) => column.name === "usage");
return (usageColumn?.type || "").toUpperCase();
};
const createApiTokenTable = async (c: Context<HonoCustomType>) => {
await c.env.DB.exec(toSingleStatement(`
CREATE TABLE api_token (
key TEXT PRIMARY KEY,
value TEXT,
usage INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`));
};
const migrateApiTokenUsagePrecision = async (c: Context<HonoCustomType>) => {
const hasLegacyTable = await doesTableExist(c, "api_token_legacy_precision");
const hasCurrentTable = await doesTableExist(c, "api_token");
const usageColumnType = hasCurrentTable ? await getApiTokenUsageColumnType(c) : "";
const needsPrecisionMigration = usageColumnType !== "INTEGER";
if (!hasLegacyTable && !needsPrecisionMigration) {
return;
}
const sourceTable = hasLegacyTable ? "api_token_legacy_precision" : "api_token";
const legacyTokens = await c.env.DB.prepare(
`SELECT key, value, usage, created_at, updated_at FROM ${sourceTable}`
).all<LegacyTokenRow>();
if (!hasLegacyTable && hasCurrentTable) {
await c.env.DB.exec(
`ALTER TABLE api_token RENAME TO api_token_legacy_precision`
);
}
if (await doesTableExist(c, "api_token")) {
await c.env.DB.exec(`DROP TABLE IF EXISTS api_token`);
}
await createApiTokenTable(c);
for (const row of legacyTokens.results || []) {
let migratedValue = row.value;
try {
const tokenData = JSON.parse(row.value) as ApiTokenData;
migratedValue = JSON.stringify({
...tokenData,
total_quota: legacyBillingAmountToRaw(tokenData.total_quota),
});
} catch (error) {
console.error(`Failed to migrate token config for ${row.key}:`, error);
}
await c.env.DB.prepare(
`INSERT INTO api_token (key, value, usage, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)`
).bind(
row.key,
migratedValue,
legacyBillingAmountToRaw(row.usage || 0),
row.created_at,
row.updated_at
).run();
}
await c.env.DB.exec(`DROP TABLE IF EXISTS api_token_legacy_precision`);
};
const dbOperations = {
initialize: async (c: Context<HonoCustomType>) => {
await initializeSchema(c);
@@ -44,6 +154,8 @@ const dbOperations = {
migrate: async (c: Context<HonoCustomType>) => {
await initializeSchema(c);
await migrateApiTokenUsagePrecision(c);
const version = await getSetting(c, CONSTANTS.DB_VERSION_KEY);
if (version === CONSTANTS.DB_VERSION) {
return;
Vendored
+6 -2
View File
@@ -45,7 +45,7 @@ type ChannelConfigRow = BaseDbRow & {
type ApiTokenRow = BaseDbRow & {
key: string;
value: string; // JSON 字符串,解析后为 ApiTokenData 类型
usage: number;
usage: number; // 原始计费单位整数
}
type ChannelType =
@@ -116,10 +116,14 @@ type ModelPricing = {
request?: number;
}
type BillingConfig = {
displayDecimals: number;
}
type ApiTokenData = {
name: string;
channel_keys: string[];
total_quota: number;
total_quota: number; // 原始计费单位整数
}
type RequestTrackingState = {