diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a67d653..f00e065 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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>('/api/admin/billing/config', { method: 'GET' }), + + saveBillingConfig: (config: BillingConfig) => + request>('/api/admin/billing/config', { + method: 'POST', + body: JSON.stringify(config), + }), + // Analytics APIs getAnalyticsOverview: (range: AnalyticsRange) => request>(`/api/admin/analytics/overview?range=${encodeURIComponent(range)}`, { method: 'GET' }), diff --git a/frontend/src/components/analytics/BreakdownChartCard.tsx b/frontend/src/components/analytics/BreakdownChartCard.tsx index 80df34d..968480f 100644 --- a/frontend/src/components/analytics/BreakdownChartCard.tsx +++ b/frontend/src/components/analytics/BreakdownChartCard.tsx @@ -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({
- {useCostScale ? formatCurrency(item.totalCost) : formatCompactNumber(item.requests)} + {useCostScale ? formatCurrency(item.totalCost, displayDecimals) : formatCompactNumber(item.requests)}
{Math.round(item.avgLatencyMs)} ms
diff --git a/frontend/src/components/analytics/TrendBarChart.tsx b/frontend/src/components/analytics/TrendBarChart.tsx index 73fdb1e..9f6140b 100644 --- a/frontend/src/components/analytics/TrendBarChart.tsx +++ b/frontend/src/components/analytics/TrendBarChart.tsx @@ -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[]
Requests
@@ -81,7 +89,7 @@ export function TrendBarChart({ points, range }: { points: AnalyticsTrendPoint[] {formatCompactNumber(point.requests)}
Total Cost
-
{formatCurrency(point.totalCost)}
+
{formatCurrency(point.totalCost, displayDecimals)}
{ + const response = await apiClient.getBillingConfig(); + return normalizeBillingConfig(response.data as BillingConfig | undefined); + }, + staleTime: 60_000, + placeholderData: { + displayDecimals: DEFAULT_BILLING_DISPLAY_DECIMALS, + }, + }); +} diff --git a/frontend/src/lib/billing.ts b/frontend/src/lib/billing.ts new file mode 100644 index 0000000..ea2adf1 --- /dev/null +++ b/frontend/src/lib/billing.ts @@ -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 | 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"; +}; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 6ce6dff..bc90a46 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -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 { diff --git a/frontend/src/pages/Analytics.tsx b/frontend/src/pages/Analytics.tsx index e70c702..ea0a191 100644 --- a/frontend/src/pages/Analytics.tsx +++ b/frontend/src/pages/Analytics.tsx @@ -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(getInitialAnalyticsRange); + const { data: billingConfig } = useBillingConfig(); + const displayDecimals = billingConfig?.displayDecimals ?? 6; const overviewCacheEntry = useMemo( () => readScopedCache(getAnalyticsOverviewCacheKey(range)), @@ -292,7 +295,7 @@ export function Analytics() { {formatCompactNumber(overview?.totals.completionTokens || 0)} - {formatCurrency(overview?.totals.totalCost || 0)} + {formatCurrency(overview?.totals.totalCost || 0, displayDecimals)} @@ -315,7 +318,7 @@ export function Analytics() {

写入第一批使用事件后,这里会展示完整柱状趋势。

) : ( - + )}
@@ -329,6 +332,7 @@ export function Analytics() { isError={breakdownStateMap[chart.dimension].isError} barClassName={chart.barClassName} badgeClassName={chart.badgeClassName} + displayDecimals={displayDecimals} /> ))}
diff --git a/frontend/src/pages/Pricing.tsx b/frontend/src/pages/Pricing.tsx index b132c87..1c803d5 100644 --- a/frontend/src/pages/Pricing.tsx +++ b/frontend/src/pages/Pricing.tsx @@ -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("table"); const [jsonValue, setJsonValue] = useState(""); @@ -24,9 +60,11 @@ export function Pricing() { >([]); const [searchQuery, setSearchQuery] = useState(""); const [modelOptions, setModelOptions] = useState([]); + 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 ( -
} > + +
+
+ + setBillingDisplayDecimals(normalizeBillingDisplayDecimals(Number(e.target.value)))} + className="w-32" + /> +

+ 默认 6 位。只影响前端展示,不改变 API 返回的原始数值。 +

+
+ +
+
+ {editMode === "table" ? ( isLoading ? (
@@ -208,7 +303,7 @@ export function Pricing() {

暂无定价配置

- 可调整倍率控制输入/输出成本,或者设置按次扣费。 + 可调整倍率控制输入/输出成本,或者设置按次扣费;金额展示精度单独配置。

@@ -448,7 +461,7 @@ export function Tokens({
- {formatCurrency(token.usage || 0)} / {formatCurrency(config.total_quota || 0)} + {formatCurrency(token.usage || 0, displayDecimals)} / {formatCurrency(config.total_quota || 0, displayDecimals)}

使用配额

-

设置令牌的最大使用额度(1百万 token = $1.00)

+

+ 输入美元金额展示值,系统会按原始整数计费单位保存;当前默认展示 {displayDecimals} 位小数。 +

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} />
{quotaPresets.map((preset) => (
-
{formatCurrency(item.totalCost)}
+
{formatCurrency(item.totalCost, displayDecimals)}
@@ -577,7 +580,7 @@ export function UsageLogs() { label="输入 / 输出 Tokens" value={`${formatCompactNumber(selectedItem.promptTokens)} / ${formatCompactNumber(selectedItem.completionTokens)}`} /> - +
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 05cc630..3f8c628 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -38,6 +38,10 @@ export interface TokenConfig { total_quota: number } +export interface BillingConfig { + displayDecimals: number +} + export interface PricingModel { input: number output: number diff --git a/src/admin/billing_api.ts b/src/admin/billing_api.ts new file mode 100644 index 0000000..5f8573f --- /dev/null +++ b/src/admin/billing_api.ts @@ -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) { + const billingConfig = await getJsonSetting( + 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) { + const body = await c.req.json(); + 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; + } +} diff --git a/src/admin/index.ts b/src/admin/index.ts index cf43caa..4476a24 100644 --- a/src/admin/index.ts +++ b/src/admin/index.ts @@ -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) diff --git a/src/admin/token_api.ts b/src/admin/token_api.ts index 54257c6..0a593cf 100644 --- a/src/admin/token_api.ts +++ b/src/admin/token_api.ts @@ -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'), }), }, }, diff --git a/src/admin/token_utils.ts b/src/admin/token_utils.ts index 9852aae..93a4c58 100644 --- a/src/admin/token_utils.ts +++ b/src/admin/token_utils.ts @@ -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 { 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 { diff --git a/src/analytics/query.ts b/src/analytics/query.ts index f04d75b..ef39389 100644 --- a/src/analytics/query.ts +++ b/src/analytics/query.ts @@ -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 = { @@ -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>(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>(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>(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")} diff --git a/src/analytics/usage-logger.ts b/src/analytics/usage-logger.ts index 3804c33..fec8252 100644 --- a/src/analytics/usage-logger.ts +++ b/src/analytics/usage-logger.ts @@ -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, ], }; }; diff --git a/src/billing.ts b/src/billing.ts new file mode 100644 index 0000000..fe316b2 --- /dev/null +++ b/src/billing.ts @@ -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 | null | undefined): BillingConfig => { + return { + displayDecimals: normalizeBillingDisplayDecimals(value?.displayDecimals), + }; +}; diff --git a/src/constants.ts b/src/constants.ts index 04f4c46..7f9ccfb 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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", } diff --git a/src/db/index.ts b/src/db/index.ts index 2f07a2c..12377bc 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -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) => { 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, tableName: string): Promise => { + const table = await c.env.DB.prepare( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?` + ).bind(tableName).first(); + + return Boolean(table?.name); +}; + +const getApiTokenUsageColumnType = async (c: Context): Promise => { + const tableInfo = await c.env.DB.prepare( + `PRAGMA table_info(api_token)` + ).all(); + + const usageColumn = tableInfo.results?.find((column) => column.name === "usage"); + return (usageColumn?.type || "").toUpperCase(); +}; + +const createApiTokenTable = async (c: Context) => { + 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) => { + 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(); + + 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) => { await initializeSchema(c); @@ -44,6 +154,8 @@ const dbOperations = { migrate: async (c: Context) => { await initializeSchema(c); + await migrateApiTokenUsagePrecision(c); + const version = await getSetting(c, CONSTANTS.DB_VERSION_KEY); if (version === CONSTANTS.DB_VERSION) { return; diff --git a/type.d.ts b/type.d.ts index 7497421..89fa0fc 100644 --- a/type.d.ts +++ b/type.d.ts @@ -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 = {