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

+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;