diff --git a/frontend/src/pages/Pricing.tsx b/frontend/src/pages/Pricing.tsx index d12d2a7..04cedaf 100644 --- a/frontend/src/pages/Pricing.tsx +++ b/frontend/src/pages/Pricing.tsx @@ -1,7 +1,7 @@ import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiClient } from "@/api/client"; -import { Channel, PricingConfig, PricingModel, Token } from "@/types"; +import { Channel, PricingBillingMode, PricingConfig, PricingModel, Token } from "@/types"; import { MultiSelectAutoCompleteInput, type AutoCompleteOption } from "@/components/ui/autocomplete"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -22,13 +22,14 @@ import { Check, FileJson, FileText, RefreshCw, Search } from "lucide-react"; import { PageContainer } from "@/components/ui/page-container"; type EditMode = "cards" | "json"; -type PricingField = "input" | "output" | "cache" | "request"; +type PricingField = "input" | "output" | "cache"; type PricingRow = { model: string; + billingMode: PricingBillingMode; input: number; output: number; cache: number; - request: number; + legacyRequest: number; }; type ChannelRef = { key: string; @@ -45,7 +46,6 @@ type TokenRef = { const PRICING_DECIMALS = 6; const PRICING_INPUT_STEP = "0.000001"; -const DEFAULT_REQUEST_VALUE = 1; const AUTO_SAVE_DELAY_MS = 800; type SaveSource = "auto" | "manual"; type SaveState = "idle" | "saving" | "saved" | "error" | "invalid"; @@ -73,10 +73,22 @@ const FIELD_META: Array<{ label: "缓存", placeholder: "0.000000", }, +]; + +const BILLING_MODE_OPTIONS: Array<{ + value: PricingBillingMode; + label: string; + description: string; +}> = [ { - key: "request", + value: "volume", + label: "按量", + description: "输入 / 输出 / 缓存按每 M tokens 计费", + }, + { + value: "request", label: "按次", - placeholder: "1.000000", + description: "成功响应后按固定金额计费", }, ]; @@ -95,6 +107,14 @@ const normalizePricingNumber = (value: unknown, fallback = 0): number => { return Number(parsed.toFixed(PRICING_DECIMALS)); }; +const normalizePricingBillingMode = (value: unknown): PricingBillingMode | undefined => { + if (value === "volume" || value === "request") { + return value; + } + + return undefined; +}; + const formatSaveTime = (value: Date | null): string => { if (!value) { return "--:--:--"; @@ -106,13 +126,35 @@ const formatSaveTime = (value: Date | null): string => { return `${hours}:${minutes}:${seconds}`; }; -const normalizePricingEntry = (pricing?: Partial | null): PricingRow => ({ - model: "", - input: normalizePricingNumber(pricing?.input, 0), - output: normalizePricingNumber(pricing?.output, 0), - cache: normalizePricingNumber(pricing?.cache, 0), - request: normalizePricingNumber(pricing?.request, DEFAULT_REQUEST_VALUE), -}); +const normalizePricingEntry = (pricing?: Partial | null): PricingRow => { + const input = normalizePricingNumber(pricing?.input, 0); + const output = normalizePricingNumber(pricing?.output, 0); + const cache = normalizePricingNumber(pricing?.cache, 0); + const legacyRequest = normalizePricingNumber(pricing?.request, 0); + const billingMode = normalizePricingBillingMode(pricing?.billingMode); + const hasVisiblePricing = input > 0 || output > 0 || cache > 0; + + // 旧版仅配置了独立 request 时,直接映射成新版按次计费。 + if (!billingMode && legacyRequest > 0 && !hasVisiblePricing) { + return { + model: "", + billingMode: "request", + input: legacyRequest, + output: 0, + cache: 0, + legacyRequest: 0, + }; + } + + return { + model: "", + billingMode: billingMode || "volume", + input, + output, + cache, + legacyRequest: billingMode ? 0 : legacyRequest, + }; +}; const normalizeChannelPriority = (value: unknown): number => { if (typeof value !== "number" || !Number.isFinite(value)) { @@ -124,13 +166,17 @@ const normalizeChannelPriority = (value: unknown): number => { const sanitizePricingEntry = (pricing: PricingRow): Partial | null => { const normalized = { + billingMode: pricing.billingMode, input: normalizePricingNumber(pricing.input, 0), output: normalizePricingNumber(pricing.output, 0), cache: normalizePricingNumber(pricing.cache, 0), - request: normalizePricingNumber(pricing.request, DEFAULT_REQUEST_VALUE), + legacyRequest: normalizePricingNumber(pricing.legacyRequest, 0), }; const filtered: Partial = {}; + const hasVisiblePricing = normalized.input > 0 || normalized.output > 0 || normalized.cache > 0; + const shouldPreserveLegacyRequest = normalized.legacyRequest > 0; + const isRequestMode = normalized.billingMode === "request"; if (normalized.input > 0) { filtered.input = normalized.input; @@ -144,8 +190,10 @@ const sanitizePricingEntry = (pricing: PricingRow): Partial | null filtered.cache = normalized.cache; } - if (normalized.request > 0 && normalized.request !== DEFAULT_REQUEST_VALUE) { - filtered.request = normalized.request; + if (shouldPreserveLegacyRequest) { + filtered.request = normalized.legacyRequest; + } else if (isRequestMode || hasVisiblePricing) { + filtered.billingMode = normalized.billingMode; } return Object.keys(filtered).length > 0 ? filtered : null; @@ -183,10 +231,11 @@ const createDefaultPricingRow = (model: string, pricing?: Partial const normalized = normalizePricingEntry(pricing); return { model, + billingMode: normalized.billingMode, input: normalized.input, output: normalized.output, cache: normalized.cache, - request: normalized.request, + legacyRequest: normalized.legacyRequest, }; }; @@ -300,7 +349,7 @@ const buildPricingRows = ( }; const isCustomPricingRow = (row: PricingRow): boolean => { - return row.input > 0 || row.output > 0 || row.cache > 0 || row.request !== DEFAULT_REQUEST_VALUE; + return row.billingMode === "request" || row.input > 0 || row.output > 0 || row.cache > 0 || row.legacyRequest > 0; }; type MultiSelectAutocompleteFieldProps = { @@ -355,7 +404,7 @@ export function Pricing() { const [searchQuery, setSearchQuery] = useState(""); const [selectedChannels, setSelectedChannels] = useState([]); const [selectedTokens, setSelectedTokens] = useState([]); - const [selectedTypes, setSelectedTypes] = useState([]); + const [selectedBillingModes, setSelectedBillingModes] = useState([]); const [saveState, setSaveState] = useState("idle"); const [lastSavedAt, setLastSavedAt] = useState(null); @@ -489,54 +538,25 @@ export function Pricing() { .sort((left, right) => (left.label || left.value).localeCompare(right.label || right.value, "zh-CN")); }, [tokensQuery.data]); - const typeOptions = useMemo(() => { - const typeMap = new Map(); - - (channelsQuery.data || []).forEach((channel) => { - const config = parseChannelConfig(channel); - if (!isChannelEnabled(config)) { - return; - } - - const type = config.type?.trim(); - if (!type) { - return; - } - - const label = type; - const channelLabel = config.name?.trim() || channel.key; - const current = typeMap.get(type) || { label, channelLabels: [] }; - - if (!current.channelLabels.includes(channelLabel)) { - current.channelLabels.push(channelLabel); - current.channelLabels.sort((left, right) => left.localeCompare(right, "zh-CN")); - } - - typeMap.set(type, current); - }); - - return Array.from(typeMap.entries()) - .map(([type, meta]) => ({ - value: type, - label: meta.label, - description: meta.channelLabels.join("、"), - keywords: [type, meta.label, ...meta.channelLabels], - })) - .sort((left, right) => (left.label || left.value).localeCompare(right.label || right.value, "zh-CN")); - }, [channelsQuery.data]); + const billingModeOptions = useMemo(() => { + return BILLING_MODE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + description: option.description, + keywords: [option.value, option.label, option.description], + })); + }, []); const pricingCards = useMemo(() => { return pricingRows.map((row) => { const channels = modelChannelMap.get(row.model) || []; const tokens = modelTokenMap.get(row.model) || []; - const types = Array.from(new Set(channels.map((channel) => channel.type).filter(Boolean))).sort((left, right) => - left.localeCompare(right, "zh-CN"), - ); const searchTarget = [ row.model, + row.billingMode, + BILLING_MODE_OPTIONS.find((option) => option.value === row.billingMode)?.label || "", ...channels.flatMap((channel) => channel.keywords), ...tokens.flatMap((token) => token.keywords), - ...types, ] .join(" ") .toLowerCase(); @@ -545,7 +565,6 @@ export function Pricing() { ...row, channels, tokens, - types, isCustomized: isCustomPricingRow(row), searchTarget, }; @@ -563,8 +582,10 @@ export function Pricing() { }, [tokenOptions]); useEffect(() => { - setSelectedTypes((current) => current.filter((value) => typeOptions.some((option) => option.value === value))); - }, [typeOptions]); + setSelectedBillingModes((current) => + current.filter((value) => billingModeOptions.some((option) => option.value === value)), + ); + }, [billingModeOptions]); const filteredCards = useMemo(() => { const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase(); @@ -578,7 +599,7 @@ export function Pricing() { return false; } - if (selectedTypes.length > 0 && !card.types.some((type) => selectedTypes.includes(type))) { + if (selectedBillingModes.length > 0 && !selectedBillingModes.includes(card.billingMode)) { return false; } @@ -588,7 +609,7 @@ export function Pricing() { return true; }); - }, [deferredSearchQuery, pricingCards, selectedChannels, selectedTokens, selectedTypes]); + }, [deferredSearchQuery, pricingCards, selectedBillingModes, selectedChannels, selectedTokens]); const handleRefresh = () => { pricingQuery.refetch(); @@ -727,7 +748,29 @@ export function Pricing() { const normalizedValue = normalizePricingNumber(nextValue, 0); setPricingRows((current) => - current.map((row) => (row.model === model ? { ...row, [field]: normalizedValue } : row)), + current.map((row) => + row.model === model + ? { + ...row, + [field]: normalizedValue, + legacyRequest: 0, + } + : row, + ), + ); + }; + + const updateBillingMode = (model: string, nextMode: PricingBillingMode) => { + setPricingRows((current) => + current.map((row) => + row.model === model + ? { + ...row, + billingMode: nextMode, + legacyRequest: row.billingMode === nextMode ? row.legacyRequest : 0, + } + : row, + ), ); }; @@ -779,12 +822,12 @@ export function Pricing() { /> setSearchQuery(event.target.value)} - placeholder="搜索模型、渠道、令牌或类型" + placeholder="搜索模型、渠道、令牌或收费类型" className="h-10 pl-9" /> @@ -851,10 +894,10 @@ export function Pricing() { -
+
{FIELD_META.map((field) => ( -
+ {card.legacyRequest > 0 && ( +

+ 兼容旧版独立按次价格 {card.legacyRequest.toFixed(PRICING_DECIMALS)},修改后会切换到新版计费规则。 +

+ )} +
{card.channels.length > 0 ? ( card.channels.map((channel) => ( @@ -877,7 +942,7 @@ export function Pricing() { {channel.label} - + {channel.weight}
@@ -896,7 +961,7 @@ export function Pricing() { JSON 配置 - 只会保存大于 0 的价格字段;切回卡片模式时,会与当前渠道中的模型集合自动合并。 + 默认只会保存大于 0 的价格字段;但 `billingMode: "request"` 即使价格全为 0,也会作为有效配置保留。切回卡片模式时,会与当前渠道中的模型集合自动合并。 @@ -905,10 +970,10 @@ export function Pricing() { onChange={(event) => setJsonValue(event.target.value)} rows={24} className="font-mono text-sm" - placeholder='{"gpt-4.1": {"input": 0.8, "output": 3.2, "request": 1}}' + placeholder='{"gpt-4.1": {"billingMode": "volume", "input": 0.8, "output": 3.2}}' />

- 格式:模型名称 → {"{"} input?: 输入倍率, output?: 输出倍率, cache?: 缓存倍率, request?: 按次 {"}"} + 格式:模型名称 → {"{"} billingMode?: "volume" | "request", input?: 输入价格, output?: 输出价格, cache?: 缓存价格, request?: 旧版独立按次价格 {"}"}

diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 060c235..a4a046d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -67,10 +67,14 @@ export interface AdminLoginResponse { sessionExpiresAt: string | null } +export type PricingBillingMode = 'volume' | 'request' + export interface PricingModel { + billingMode?: PricingBillingMode input?: number output?: number cache?: number + // 兼容旧版独立按次收费配置 request?: number } diff --git a/src/admin/channel_api.ts b/src/admin/channel_api.ts index 627198b..2c45e9f 100644 --- a/src/admin/channel_api.ts +++ b/src/admin/channel_api.ts @@ -196,10 +196,11 @@ export class ChannelUpsertEndpoint extends OpenAPIRoute { supported_models: z.array(z.string()).optional().describe('Deprecated supported request model list'), deployment_mapper: z.record(z.string(), z.string()).optional().describe('Deprecated model deployment mapping'), model_pricing: z.record(z.string(), z.object({ - input: z.number().describe('Input token price per 1000 tokens'), - output: z.number().describe('Output token price per 1000 tokens'), - cache: z.number().optional().describe('Cache token price per 1000 tokens'), - request: z.number().optional().describe('Fixed request price'), + billingMode: z.enum(["volume", "request"]).optional().describe('Billing mode: volume = per 1M tokens, request = fixed per successful response'), + input: z.number().optional().describe('Input price: per 1M tokens in volume mode, fixed per success in request mode'), + output: z.number().optional().describe('Output price: per 1M tokens in volume mode, fixed per success in request mode'), + cache: z.number().optional().describe('Cache price: per 1M cached tokens in volume mode, fixed when cache is used in request mode'), + request: z.number().optional().describe('Legacy fixed request price, kept for backward compatibility'), })).optional().describe('Custom model pricing for this channel'), }), }, @@ -269,8 +270,9 @@ export class ChannelFetchModelsEndpoint extends OpenAPIRoute { supported_models: z.array(z.string()).optional(), deployment_mapper: z.record(z.string(), z.string()).optional(), model_pricing: z.record(z.string(), z.object({ - input: z.number(), - output: z.number(), + billingMode: z.enum(["volume", "request"]).optional(), + input: z.number().optional(), + output: z.number().optional(), cache: z.number().optional(), request: z.number().optional(), })).optional(), diff --git a/src/admin/pricing_api.ts b/src/admin/pricing_api.ts index de4f48d..b65601b 100644 --- a/src/admin/pricing_api.ts +++ b/src/admin/pricing_api.ts @@ -5,7 +5,10 @@ import { getJsonSetting, saveSetting } from "../utils"; import { CONSTANTS } from "../constants"; import { CommonErrorResponse, CommonSuccessfulResponse } from "../model"; +const pricingBillingModeSchema = z.enum(["volume", "request"]); + const pricingModelSchema = z.object({ + billingMode: pricingBillingModeSchema.optional(), input: z.number().optional(), output: z.number().optional(), cache: z.number().optional(), @@ -20,6 +23,14 @@ const toPositiveNumber = (value: unknown): number | undefined => { return value > 0 ? value : undefined; }; +const normalizeBillingMode = (value: unknown): PricingBillingMode | undefined => { + if (value === "volume" || value === "request") { + return value; + } + + return undefined; +}; + const sanitizePricingConfig = ( config: Record> | null | undefined ): Record> => { @@ -40,6 +51,21 @@ const sanitizePricingConfig = ( const output = toPositiveNumber(pricing.output); const cache = toPositiveNumber(pricing.cache); const request = toPositiveNumber(pricing.request); + const billingMode = normalizeBillingMode(pricing.billingMode); + const hasVisiblePricing = input !== undefined || output !== undefined || cache !== undefined; + const isRequestMode = billingMode === "request"; + + // 旧版仅配置了 request 的场景,直接迁移为新版按次计费。 + if (!billingMode && request !== undefined && !hasVisiblePricing) { + normalizedEntries.push([ + normalizedModel, + { + billingMode: "request", + input: request, + }, + ]); + return; + } if (input !== undefined) { normalizedPricing.input = input; @@ -50,10 +76,17 @@ const sanitizePricingConfig = ( if (cache !== undefined) { normalizedPricing.cache = cache; } - if (request !== undefined) { + + // 只有旧版未声明 billingMode 的配置才保留 request, + // 避免和新版按量/按次模式混用。 + if (!billingMode && request !== undefined) { normalizedPricing.request = request; } + if ((isRequestMode || hasVisiblePricing) && (!normalizedPricing.request || billingMode)) { + normalizedPricing.billingMode = billingMode || "volume"; + } + if (Object.keys(normalizedPricing).length === 0) { return; } diff --git a/src/admin/token_utils.ts b/src/admin/token_utils.ts index 93a4c58..919e0ef 100644 --- a/src/admin/token_utils.ts +++ b/src/admin/token_utils.ts @@ -16,6 +16,18 @@ type UsageCostResult = { hasPricing: boolean; } +const normalizeBillingMode = (value: unknown): PricingBillingMode | undefined => { + if (value === "volume" || value === "request") { + return value; + } + + return undefined; +}; + +const calculateFixedCostRaw = (value: unknown): number => { + return calculateRequestCostRaw(value || 0); +}; + // Token 工具对象 export const TokenUtils = { async updateUsage(c: Context, key: string, usageAmount: number): Promise { @@ -49,9 +61,47 @@ export const TokenUtils = { ): Promise { const pricing = await this.getPricing(c, model, targetChannelConfig); const hasTokens = usage.prompt_tokens != null && usage.completion_tokens != null; - const requestCost = calculateRequestCostRaw(pricing?.request || 0); + const billingMode = normalizeBillingMode(pricing?.billingMode); + const hasVisiblePricing = Boolean(pricing?.input || pricing?.output || pricing?.cache); + const isLegacyRequestOnly = !billingMode && !hasVisiblePricing && Boolean(pricing?.request); + const requestCost = billingMode + ? 0 + : calculateRequestCostRaw(pricing?.request || 0); - if (!pricing || (!hasTokens && requestCost <= 0)) { + if (!pricing) { + return { + totalCost: 0, + requestCost, + inputCost: 0, + outputCost: 0, + cacheCost: 0, + hasPricing: false, + }; + } + + if (billingMode === "request" || isLegacyRequestOnly) { + const inputCost = isLegacyRequestOnly + ? calculateFixedCostRaw(pricing.request) + : calculateFixedCostRaw(pricing.input); + const outputCost = isLegacyRequestOnly + ? 0 + : calculateFixedCostRaw(pricing.output); + const cacheCost = !isLegacyRequestOnly && usage.cached_tokens && usage.cached_tokens > 0 + ? calculateFixedCostRaw(pricing.cache) + : 0; + const totalCost = inputCost + outputCost + cacheCost; + + return { + totalCost, + requestCost: 0, + inputCost, + outputCost, + cacheCost, + hasPricing: true, + }; + } + + if (!hasTokens && requestCost <= 0) { return { totalCost: 0, requestCost, diff --git a/type.d.ts b/type.d.ts index f7958c0..9f68fa6 100644 --- a/type.d.ts +++ b/type.d.ts @@ -109,10 +109,14 @@ type CommonResponse = { data?: any; } +type PricingBillingMode = "volume" | "request"; + type ModelPricing = { + billingMode?: PricingBillingMode; input?: number; output?: number; cache?: number; + // 兼容旧版独立按次收费配置 request?: number; }