feat(billing): 优化计费精度
- 新增账单配置 API,包括获取和更新显示小数位数的功能。 - 在前端页面中集成账单配置,允许用户设置金额显示精度。 - 更新相关页面(分析、定价、令牌、使用日志)以支持动态显示小数位数。 - 规范化账单金额处理,确保金额在前端和后端一致。
This commit is contained in:
1 parent
9b52d43835
commit
0258367a64
21 files changed
+587
-74
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -38,6 +38,10 @@ export interface TokenConfig {
|
||||
total_quota: number
|
||||
}
|
||||
|
||||
export interface BillingConfig {
|
||||
displayDecimals: number
|
||||
}
|
||||
|
||||
export interface PricingModel {
|
||||
input: number
|
||||
output: number
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
@@ -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")}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user