前端模型定价配置 UI 增强(Editor/GeneratorModal/PreviewPanel/PricingGrid), 后端新增 representativeMatrixPrice 取矩阵定价表中位数作为展示价格, 替代原来的 model_price=0 导致的价格展示为空问题。 Co-Authored-By: Claude <noreply@anthropic.com>master
| @@ -3,12 +3,14 @@ package controller | |||
| import ( | |||
| "fmt" | |||
| "math" | |||
| "sort" | |||
| "strings" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "github.com/QuantumNous/new-api/service" | |||
| "github.com/QuantumNous/new-api/setting/ratio_setting" | |||
| "github.com/QuantumNous/new-api/types" | |||
| "github.com/gin-gonic/gin" | |||
| ) | |||
| @@ -139,8 +141,9 @@ func GetUserPricing(c *gin.Context) { | |||
| if pricingData.QuotaType == model.QuotaTypeByTokens { | |||
| if pricingData.PricingConfig != nil { | |||
| result["original_price"] = pricingData.ModelPrice | |||
| result["user_price"] = pricingData.ModelPrice * totalRatio | |||
| originalPrice := representativeMatrixPrice(pricingData.PricingConfig, pricingData.ModelPrice) | |||
| result["original_price"] = originalPrice | |||
| result["user_price"] = originalPrice * totalRatio | |||
| } | |||
| originalInput := pricingData.ModelRatio * 2 | |||
| originalOutput := pricingData.ModelRatio * pricingData.CompletionRatio * 2 | |||
| @@ -171,7 +174,7 @@ func respondOriginalPrice(c *gin.Context, pricingData *model.Pricing) { | |||
| if pricingData.QuotaType == model.QuotaTypeByTokens { | |||
| if pricingData.PricingConfig != nil { | |||
| result["pricing_config"] = pricingData.PricingConfig | |||
| result["original_price"] = pricingData.ModelPrice | |||
| result["original_price"] = representativeMatrixPrice(pricingData.PricingConfig, pricingData.ModelPrice) | |||
| } | |||
| result["original_input"] = pricingData.ModelRatio * 2 | |||
| result["original_output"] = pricingData.ModelRatio * pricingData.CompletionRatio * 2 | |||
| @@ -184,6 +187,45 @@ func respondOriginalPrice(c *gin.Context, pricingData *model.Pricing) { | |||
| c.JSON(200, result) | |||
| } | |||
| func representativeMatrixPrice(cfg *types.PricingConfig, fallback float64) float64 { | |||
| if cfg == nil || len(cfg.Table) == 0 { | |||
| return fallback | |||
| } | |||
| prices := make([]float64, 0, len(cfg.Table)) | |||
| for _, row := range cfg.Table { | |||
| price, ok := pricingRowFloat(row["price"]) | |||
| if ok { | |||
| prices = append(prices, price) | |||
| } | |||
| } | |||
| if len(prices) == 0 { | |||
| return fallback | |||
| } | |||
| sort.Float64s(prices) | |||
| mid := len(prices) / 2 | |||
| if len(prices)%2 == 1 { | |||
| return prices[mid] | |||
| } | |||
| return (prices[mid-1] + prices[mid]) / 2 | |||
| } | |||
| func pricingRowFloat(value any) (float64, bool) { | |||
| switch n := value.(type) { | |||
| case float64: | |||
| return n, true | |||
| case float32: | |||
| return float64(n), true | |||
| case int: | |||
| return float64(n), true | |||
| case int64: | |||
| return float64(n), true | |||
| case int32: | |||
| return float64(n), true | |||
| default: | |||
| return 0, false | |||
| } | |||
| } | |||
| // formatDiscount 将倍率转换为中文折扣格式 | |||
| func formatDiscount(ratio float64) string { | |||
| if ratio <= 0 { | |||
| @@ -3,6 +3,7 @@ package helper | |||
| import ( | |||
| "fmt" | |||
| "net/http" | |||
| "strings" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/dto" | |||
| @@ -97,7 +98,7 @@ func buildRemixMatrixDecision(origin *types.OriginPricingSnapshot, group types.G | |||
| decision.OtherRatios = types.CloneRatios(origin.OtherRatios) | |||
| decision.PerCallBilling = origin.PerCallBilling | |||
| if billingUnit == types.BillingUnitPer1MTokens { | |||
| decision.PriceUSD = 0 | |||
| decision.PriceUSD = origin.TokenUnitPriceUSD | |||
| decision.Quota = minimumUsagePreconsumeQuota(origin.TokenUnitPriceUSD, group.GroupRatio) | |||
| } | |||
| return decision, nil | |||
| @@ -139,6 +140,7 @@ func buildMatrixDecision(cfg *types.PricingConfig, result *pricingLookupResult, | |||
| if billingUnit == types.BillingUnitPer1MTokens { | |||
| decision.TokenUnitPriceUSD = result.PriceUSD | |||
| decision.PriceUSD = result.PriceUSD | |||
| decision.Quota = minimumUsagePreconsumeQuota(result.PriceUSD, group.GroupRatio) | |||
| decision.PerCallBilling = false | |||
| return decision | |||
| @@ -199,7 +201,7 @@ func rowMatchesDimensions(dimensions []types.PricingDimension, row types.Pricing | |||
| wildcards++ | |||
| continue | |||
| } | |||
| if !samePricingLiteral(rv, values[dim.Key]) { | |||
| if !samePricingLiteral(rv, values[dim.Key], dim.Type) { | |||
| return 0, false | |||
| } | |||
| } | |||
| @@ -266,12 +268,15 @@ func pricingNumberValue(v any) (float64, bool) { | |||
| } | |||
| } | |||
| func samePricingLiteral(a, b any) bool { | |||
| func samePricingLiteral(a, b any, dimensionType string) bool { | |||
| af, aok := pricingNumberValue(a) | |||
| bf, bok := pricingNumberValue(b) | |||
| if aok && bok { | |||
| return af == bf | |||
| } | |||
| if dimensionType == "string" { | |||
| return strings.EqualFold(strings.TrimSpace(fmt.Sprintf("%v", a)), strings.TrimSpace(fmt.Sprintf("%v", b))) | |||
| } | |||
| return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b) | |||
| } | |||
| @@ -20,6 +20,17 @@ func TestPricingLookup_MatchSpecificity(t *testing.T) { | |||
| require.Equal(t, 10.0, result.Snapshot["duration"]) | |||
| } | |||
| func TestPricingLookup_StringDimensionMatchesCaseInsensitive(t *testing.T) { | |||
| cfg := testPricingLookupConfig(types.PricingFallbackReject) | |||
| resolved := &ResolvedDimensionValues{Values: map[string]any{"resolution": "720p", "duration": 10.0}} | |||
| result, taskErr := lookupPricingConfig(cfg, resolved) | |||
| require.Nil(t, taskErr) | |||
| require.Equal(t, types.PricingModeMatched, result.Mode) | |||
| require.Equal(t, 0.08, result.PriceUSD) | |||
| require.Equal(t, "720p", result.Snapshot["resolution"]) | |||
| } | |||
| func TestPricingLookup_WildcardFallbackMatch(t *testing.T) { | |||
| cfg := testPricingLookupConfig(types.PricingFallbackReject) | |||
| resolved := &ResolvedDimensionValues{Values: map[string]any{"resolution": "480P", "duration": 10.0}} | |||
| @@ -94,11 +105,28 @@ func TestPricingLookup_UsageDecisionMinimumPreconsume(t *testing.T) { | |||
| result := &pricingLookupResult{Mode: types.PricingModeMatched, PriceUSD: 2.0, Snapshot: map[string]any{"price": 2.0}} | |||
| decision := buildMatchedDecision(cfg, result, types.GroupRatioInfo{GroupRatio: 1}) | |||
| require.Equal(t, 2.0, decision.PriceUSD) | |||
| require.Equal(t, 2.0, decision.TokenUnitPriceUSD) | |||
| require.Equal(t, minimumUsagePreconsumeQuota(2.0, 1), decision.Quota) | |||
| require.False(t, decision.PerCallBilling) | |||
| } | |||
| func TestPricingLookup_RemixUsageDecisionKeepsDisplayPrice(t *testing.T) { | |||
| decision, taskErr := buildRemixMatrixDecision(&types.OriginPricingSnapshot{ | |||
| PriceUSD: 0, | |||
| TokenUnitPriceUSD: 46, | |||
| BillingUnit: types.BillingUnitPer1MTokens, | |||
| Snapshot: map[string]any{"price": 46}, | |||
| PerCallBilling: false, | |||
| }, types.GroupRatioInfo{GroupRatio: 1}) | |||
| require.Nil(t, taskErr) | |||
| require.Equal(t, 46.0, decision.PriceUSD) | |||
| require.Equal(t, 46.0, decision.TokenUnitPriceUSD) | |||
| require.Equal(t, minimumUsagePreconsumeQuota(46, 1), decision.Quota) | |||
| require.False(t, decision.PerCallBilling) | |||
| } | |||
| func testPricingLookupConfig(fallback string) *types.PricingConfig { | |||
| return &types.PricingConfig{ | |||
| SchemaVersion: types.PricingSchemaVersion, | |||
| @@ -94,7 +94,7 @@ const ModelPricingTable = ({ | |||
| }); | |||
| }); | |||
| columns.push({ | |||
| title: t('价格'), | |||
| title: t('价格({{unit}})', { unit }), | |||
| dataIndex: 'price', | |||
| render: (price) => ( | |||
| <> | |||
| @@ -171,7 +171,9 @@ const ModelPricingTable = ({ | |||
| ? priceData.cachePrice | |||
| : '-', | |||
| cacheCreationPrice: | |||
| modelData?.quota_type === 0 && priceData.cacheRatio !== 0 && priceData.cacheCreationRatio !== 0 | |||
| modelData?.quota_type === 0 && | |||
| priceData.cacheRatio !== 0 && | |||
| priceData.cacheCreationRatio !== 0 | |||
| ? priceData.cacheCreationPrice | |||
| : '-', | |||
| fixedPrice: modelData?.quota_type === 1 ? priceData.price : '-', | |||
| @@ -225,7 +227,7 @@ const ModelPricingTable = ({ | |||
| const hasCachePricing = tableData.some((row) => row.cachePrice !== '-'); | |||
| // 检查是否有缓存创建价格数据(有些模型只有缓存读取没有缓存创建) | |||
| const hasCacheCreationPricing = tableData.some( | |||
| (row) => row.cacheCreationPrice !== '-' | |||
| (row) => row.cacheCreationPrice !== '-', | |||
| ); | |||
| // 根据计费类型添加价格列 | |||
| @@ -39,6 +39,7 @@ import { | |||
| stringToColor, | |||
| getLobeHubIcon, | |||
| hasMatrixPricing, | |||
| calculateModelPrice, | |||
| } from '../../../../../helpers'; | |||
| import PricingCardSkeleton from './PricingCardSkeleton'; | |||
| import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime'; | |||
| @@ -72,6 +73,11 @@ const PricingCardView = ({ | |||
| gridColsClass = 'grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3', | |||
| showPagination = true, | |||
| showTryButton = false, | |||
| selectedGroup, | |||
| groupRatio = {}, | |||
| currency, | |||
| tokenUnit, | |||
| displayPrice, | |||
| }) => { | |||
| const showSkeleton = useMinimumLoadingTime(loading); | |||
| const startIndex = (currentPage - 1) * pageSize; | |||
| @@ -148,6 +154,48 @@ const PricingCardView = ({ | |||
| return record.description || ''; | |||
| }; | |||
| const renderCardPrice = (model) => { | |||
| if (!displayPrice) return null; | |||
| const priceData = calculateModelPrice({ | |||
| record: model, | |||
| selectedGroup, | |||
| groupRatio, | |||
| tokenUnit, | |||
| displayPrice, | |||
| currency, | |||
| }); | |||
| if (!priceData?.isMatrix) { | |||
| return null; | |||
| } | |||
| return ( | |||
| <div className='mb-3 text-xs'> | |||
| <div className='font-semibold text-gray-900'> | |||
| {priceData.price}{' '} | |||
| <span className='font-normal text-gray-500'>/ {priceData.unit}</span> | |||
| </div> | |||
| {(priceData.details || []).map((detail, detailIndex) => ( | |||
| <div | |||
| key={`${detail.dimensionText}-${detailIndex}`} | |||
| className='mt-1 flex items-center justify-between gap-2 text-gray-500' | |||
| title={`${detail.dimensionText} ${detail.formattedPrice}`} | |||
| > | |||
| <span className='min-w-0 truncate'>{detail.dimensionText}</span> | |||
| <span className='shrink-0 text-gray-700'> | |||
| {detail.formattedPrice} | |||
| </span> | |||
| </div> | |||
| ))} | |||
| {priceData.hiddenDetailCount > 0 && ( | |||
| <div className='mt-1 text-gray-400'> | |||
| + {priceData.hiddenDetailCount} | |||
| </div> | |||
| )} | |||
| </div> | |||
| ); | |||
| }; | |||
| // 渲染标签(卡片底部) | |||
| // 左侧: 计费类型标签(quota_type: 0=按量计费, 1=按次计费) | |||
| // 右侧: 自定义标签(来自 Model.tags,逗号分隔,最多显示3个,颜色由 stringToColor 哈希生成) | |||
| @@ -300,6 +348,8 @@ const PricingCardView = ({ | |||
| </p> | |||
| </div> | |||
| {renderCardPrice(model)} | |||
| {/* 底部区域 */} | |||
| <div className='mt-auto'> | |||
| {/* 标签区域 */} | |||
| @@ -314,7 +364,10 @@ const PricingCardView = ({ | |||
| style={{ marginTop: 8, width: '100%' }} | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| navigate('/console/playground?model=' + encodeURIComponent(model.model_name)); | |||
| navigate( | |||
| '/console/playground?model=' + | |||
| encodeURIComponent(model.model_name), | |||
| ); | |||
| }} | |||
| > | |||
| {t('去体验')} → | |||
| @@ -198,6 +198,11 @@ const HomePricingFilters = ({ t }) => { | |||
| gridColsClass='grid-cols-1 md:grid-cols-2 lg:grid-cols-3' | |||
| showPagination={true} | |||
| showTryButton={true} | |||
| selectedGroup={pricingData.selectedGroup} | |||
| groupRatio={pricingData.groupRatio} | |||
| currency={pricingData.currency} | |||
| tokenUnit={pricingData.tokenUnit} | |||
| displayPrice={pricingData.displayPrice} | |||
| /> | |||
| </div> | |||
| @@ -1,5 +1,15 @@ | |||
| import React, { useEffect, useMemo, useState } from 'react'; | |||
| import { Button, Input, Select, SideSheet, Space, TabPane, Tabs, TextArea, Typography } from '@douyinfe/semi-ui'; | |||
| import { | |||
| Button, | |||
| Input, | |||
| Select, | |||
| SideSheet, | |||
| Space, | |||
| TabPane, | |||
| Tabs, | |||
| TextArea, | |||
| Typography, | |||
| } from '@douyinfe/semi-ui'; | |||
| import { Redo2, Save, Undo2, Wand2 } from 'lucide-react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { API, showError, showSuccess } from '../../../../helpers'; | |||
| @@ -7,9 +17,20 @@ import DimensionEditor from './DimensionEditor'; | |||
| import GeneratorModal from './GeneratorModal'; | |||
| import PreviewPanel from './PreviewPanel'; | |||
| import PricingGrid from './PricingGrid'; | |||
| import { BILLING_UNITS, FALLBACKS, createDefaultConfig, validatePricingConfig } from './pricingConfig'; | |||
| import { | |||
| BILLING_UNITS, | |||
| FALLBACKS, | |||
| createDefaultConfig, | |||
| validatePricingConfig, | |||
| } from './pricingConfig'; | |||
| export default function Editor({ visible, modelName, config, onClose, onSaved }) { | |||
| export default function Editor({ | |||
| visible, | |||
| modelName, | |||
| config, | |||
| onClose, | |||
| onSaved, | |||
| }) { | |||
| const { t } = useTranslation(); | |||
| const [name, setName] = useState(modelName || ''); | |||
| const [draft, setDraft] = useState(createDefaultConfig()); | |||
| @@ -76,7 +97,10 @@ export default function Editor({ visible, modelName, config, onClose, onSaved }) | |||
| } | |||
| setSaving(true); | |||
| try { | |||
| const res = await API.put(`/api/option/model_pricing/${encodeURIComponent(name.trim())}`, draft); | |||
| const res = await API.put( | |||
| `/api/option/model_pricing/${encodeURIComponent(name.trim())}`, | |||
| draft, | |||
| ); | |||
| if (!res.data?.success) { | |||
| showError(res.data?.message || t('保存模型多维计费失败')); | |||
| return; | |||
| @@ -95,7 +119,8 @@ export default function Editor({ visible, modelName, config, onClose, onSaved }) | |||
| updateDraft({ | |||
| ...draft, | |||
| billing_unit: value, | |||
| preconsume_strategy: value === BILLING_UNITS.per1MTokens ? 'minimum' : 'exact', | |||
| preconsume_strategy: | |||
| value === BILLING_UNITS.per1MTokens ? 'minimum' : 'exact', | |||
| }); | |||
| }; | |||
| @@ -108,10 +133,24 @@ export default function Editor({ visible, modelName, config, onClose, onSaved }) | |||
| onCancel={onClose} | |||
| footer={ | |||
| <Space> | |||
| <Button icon={<Undo2 size={16} />} onClick={undo} disabled={history.length === 0} /> | |||
| <Button icon={<Redo2 size={16} />} onClick={redo} disabled={future.length === 0} /> | |||
| <Button | |||
| icon={<Undo2 size={16} />} | |||
| onClick={undo} | |||
| disabled={history.length === 0} | |||
| /> | |||
| <Button | |||
| icon={<Redo2 size={16} />} | |||
| onClick={redo} | |||
| disabled={future.length === 0} | |||
| /> | |||
| <Button onClick={onClose}>{t('取消')}</Button> | |||
| <Button type='primary' icon={<Save size={16} />} data-testid='model-pricing-save' onClick={save} loading={saving}> | |||
| <Button | |||
| type='primary' | |||
| icon={<Save size={16} />} | |||
| data-testid='model-pricing-save' | |||
| onClick={save} | |||
| loading={saving} | |||
| > | |||
| {t('保存')} | |||
| </Button> | |||
| </Space> | |||
| @@ -119,21 +158,47 @@ export default function Editor({ visible, modelName, config, onClose, onSaved }) | |||
| > | |||
| <Space vertical align='start' style={{ width: '100%' }}> | |||
| <Space wrap> | |||
| <Input prefix={t('模型')} value={name} onChange={setName} style={{ width: 260 }} disabled={Boolean(modelName)} data-testid='model-pricing-model-name' /> | |||
| <Select value={draft.billing_unit} onChange={setBillingUnit} style={{ width: 180 }}> | |||
| <Input | |||
| prefix={t('模型')} | |||
| value={name} | |||
| onChange={setName} | |||
| style={{ width: 260 }} | |||
| disabled={Boolean(modelName)} | |||
| data-testid='model-pricing-model-name' | |||
| /> | |||
| <Select | |||
| value={draft.billing_unit} | |||
| onChange={setBillingUnit} | |||
| style={{ width: 180 }} | |||
| > | |||
| <Select.Option value='per_call'>{t('每次调用')}</Select.Option> | |||
| <Select.Option value='per_1m_tokens'>{t('每百万 tokens')}</Select.Option> | |||
| <Select.Option value='per_1m_tokens'> | |||
| {t('每百万 tokens')} | |||
| </Select.Option> | |||
| </Select> | |||
| <Select | |||
| value={draft.fallback?.strategy} | |||
| onChange={(value) => updateDraft({ ...draft, fallback: { ...draft.fallback, strategy: value } })} | |||
| onChange={(value) => | |||
| updateDraft({ | |||
| ...draft, | |||
| fallback: { ...draft.fallback, strategy: value }, | |||
| }) | |||
| } | |||
| style={{ width: 160 }} | |||
| > | |||
| <Select.Option value={FALLBACKS.reject}>{t('拒绝')}</Select.Option> | |||
| <Select.Option value={FALLBACKS.max}>{t('使用最高价')}</Select.Option> | |||
| <Select.Option value={FALLBACKS.default}>{t('使用默认价')}</Select.Option> | |||
| <Select.Option value={FALLBACKS.max}> | |||
| {t('使用最高价')} | |||
| </Select.Option> | |||
| <Select.Option value={FALLBACKS.default}> | |||
| {t('使用默认价')} | |||
| </Select.Option> | |||
| </Select> | |||
| <Button icon={<Wand2 size={16} />} data-testid='model-pricing-open-generator' onClick={() => setGeneratorVisible(true)}> | |||
| <Button | |||
| icon={<Wand2 size={16} />} | |||
| data-testid='model-pricing-open-generator' | |||
| onClick={() => setGeneratorVisible(true)} | |||
| > | |||
| {t('生成价格行')} | |||
| </Button> | |||
| </Space> | |||
| @@ -144,16 +209,29 @@ export default function Editor({ visible, modelName, config, onClose, onSaved }) | |||
| )} | |||
| <Tabs type='line' style={{ width: '100%' }}> | |||
| <TabPane tab={t('维度')} itemKey='dimensions'> | |||
| <DimensionEditor dimensions={draft.dimensions || []} onChange={(dimensions) => updateDraft({ ...draft, dimensions })} /> | |||
| <DimensionEditor | |||
| dimensions={draft.dimensions || []} | |||
| onChange={(dimensions) => updateDraft({ ...draft, dimensions })} | |||
| /> | |||
| </TabPane> | |||
| <TabPane tab={t('价格表')} itemKey='table'> | |||
| <PricingGrid dimensions={draft.dimensions || []} rows={draft.table || []} onChange={(table) => updateDraft({ ...draft, table })} /> | |||
| <PricingGrid | |||
| dimensions={draft.dimensions || []} | |||
| rows={draft.table || []} | |||
| billingUnit={draft.billing_unit} | |||
| onChange={(table) => updateDraft({ ...draft, table })} | |||
| /> | |||
| </TabPane> | |||
| <TabPane tab={t('预览')} itemKey='preview'> | |||
| <PreviewPanel config={draft} /> | |||
| </TabPane> | |||
| <TabPane tab={t('JSON')} itemKey='json'> | |||
| <TextArea value={jsonText} onChange={setJsonText} autosize={{ minRows: 18, maxRows: 28 }} data-testid='model-pricing-json-editor' /> | |||
| <TextArea | |||
| value={jsonText} | |||
| onChange={setJsonText} | |||
| autosize={{ minRows: 18, maxRows: 28 }} | |||
| data-testid='model-pricing-json-editor' | |||
| /> | |||
| <Button style={{ marginTop: 12 }} onClick={applyJson}> | |||
| {t('应用 JSON')} | |||
| </Button> | |||
| @@ -1,15 +1,26 @@ | |||
| import React, { useEffect, useMemo, useState } from 'react'; | |||
| import { Button, Input, InputNumber, Modal, Radio, Select, Space, Table, Typography } from '@douyinfe/semi-ui'; | |||
| import { | |||
| Button, | |||
| Input, | |||
| InputNumber, | |||
| Modal, | |||
| Radio, | |||
| Select, | |||
| Space, | |||
| Table, | |||
| Typography, | |||
| } from '@douyinfe/semi-ui'; | |||
| import { Plus, Trash2 } from 'lucide-react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { getCurrencyConfig } from '../../../../helpers'; | |||
| import { | |||
| BILLING_UNITS, | |||
| countRatioEntries, | |||
| displayCurrencyToUSD, | |||
| estimateGeneratedRowCount, | |||
| formatDisplayCurrency, | |||
| generateRowsFromRatios, | |||
| getBillingUnitLabel, | |||
| getPriceUnitLabel, | |||
| mergeGeneratedRows, | |||
| parseDimensionValue, | |||
| } from './pricingConfig'; | |||
| @@ -19,25 +30,48 @@ const DEFAULT_RATIOS = { | |||
| duration: { 5: 1, 10: 1.5 }, | |||
| }; | |||
| export default function GeneratorModal({ visible, dimensions, billingUnit, existingRows, onCancel, onGenerate }) { | |||
| export default function GeneratorModal({ | |||
| visible, | |||
| dimensions, | |||
| billingUnit, | |||
| existingRows, | |||
| onCancel, | |||
| onGenerate, | |||
| }) { | |||
| const { t } = useTranslation(); | |||
| const [basePrice, setBasePrice] = useState(0.1); | |||
| const [ratioEntries, setRatioEntries] = useState([]); | |||
| const [strategy, setStrategy] = useState('preserve_manual'); | |||
| const currencyConfig = useMemo(() => getCurrencyConfig(), [visible]); | |||
| const basePriceUSD = useMemo(() => displayCurrencyToUSD(basePrice, currencyConfig), [basePrice, currencyConfig]); | |||
| const unitLabel = billingUnit === BILLING_UNITS.per1MTokens ? t('每 1M tokens') : t('每次调用'); | |||
| const basePriceUSD = useMemo( | |||
| () => displayCurrencyToUSD(basePrice, currencyConfig), | |||
| [basePrice, currencyConfig], | |||
| ); | |||
| const billingUnitLabel = getBillingUnitLabel(billingUnit, t); | |||
| const displayUnitLabel = getPriceUnitLabel( | |||
| billingUnit, | |||
| currencyConfig.symbol, | |||
| t, | |||
| ); | |||
| const savedUnitLabel = getPriceUnitLabel(billingUnit, 'USD', t); | |||
| useEffect(() => { | |||
| if (!visible) return; | |||
| setRatioEntries(createDefaultRatioEntries(dimensions)); | |||
| }, [visible, dimensions]); | |||
| const ratios = useMemo(() => entriesToRatios(ratioEntries, dimensions), [ratioEntries, dimensions]); | |||
| const ratios = useMemo( | |||
| () => entriesToRatios(ratioEntries, dimensions), | |||
| [ratioEntries, dimensions], | |||
| ); | |||
| const preview = useMemo(() => { | |||
| const rows = generateRowsFromRatios({ dimensions, basePrice: basePriceUSD, ratios }); | |||
| const rows = generateRowsFromRatios({ | |||
| dimensions, | |||
| basePrice: basePriceUSD, | |||
| ratios, | |||
| }); | |||
| return { | |||
| rows, | |||
| linearCount: countRatioEntries(ratios), | |||
| @@ -46,7 +80,11 @@ export default function GeneratorModal({ visible, dimensions, billingUnit, exist | |||
| }, [dimensions, basePriceUSD, ratios]); | |||
| const apply = () => { | |||
| const table = mergeGeneratedRows({ existingRows, generatedRows: preview.rows, strategy }); | |||
| const table = mergeGeneratedRows({ | |||
| existingRows, | |||
| generatedRows: preview.rows, | |||
| strategy, | |||
| }); | |||
| onGenerate(table); | |||
| }; | |||
| @@ -58,10 +96,14 @@ export default function GeneratorModal({ visible, dimensions, billingUnit, exist | |||
| const addEntry = () => { | |||
| const firstDimension = dimensions?.[0]?.key || ''; | |||
| setRatioEntries([...ratioEntries, { dimensionKey: firstDimension, value: '', ratio: 1 }]); | |||
| setRatioEntries([ | |||
| ...ratioEntries, | |||
| { dimensionKey: firstDimension, value: '', ratio: 1 }, | |||
| ]); | |||
| }; | |||
| const removeEntry = (index) => setRatioEntries(ratioEntries.filter((_, itemIndex) => itemIndex !== index)); | |||
| const removeEntry = (index) => | |||
| setRatioEntries(ratioEntries.filter((_, itemIndex) => itemIndex !== index)); | |||
| const ratioColumns = [ | |||
| { | |||
| @@ -69,7 +111,13 @@ export default function GeneratorModal({ visible, dimensions, billingUnit, exist | |||
| dataIndex: 'dimensionKey', | |||
| width: 160, | |||
| render: (value, _, index) => ( | |||
| <Select value={value} onChange={(dimensionKey) => updateEntry(index, { dimensionKey, value: '' })} style={{ width: 140 }}> | |||
| <Select | |||
| value={value} | |||
| onChange={(dimensionKey) => | |||
| updateEntry(index, { dimensionKey, value: '' }) | |||
| } | |||
| style={{ width: 140 }} | |||
| > | |||
| {(dimensions || []).map((dim) => ( | |||
| <Select.Option key={dim.key} value={dim.key}> | |||
| {dim.key} | |||
| @@ -81,35 +129,59 @@ export default function GeneratorModal({ visible, dimensions, billingUnit, exist | |||
| { | |||
| title: t('候选值'), | |||
| dataIndex: 'value', | |||
| render: (value, _, index) => <Input value={value} onChange={(nextValue) => updateEntry(index, { value: nextValue })} data-testid={`generator-ratio-value-${index}`} />, | |||
| render: (value, _, index) => ( | |||
| <Input | |||
| value={value} | |||
| onChange={(nextValue) => updateEntry(index, { value: nextValue })} | |||
| data-testid={`generator-ratio-value-${index}`} | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| title: t('倍率'), | |||
| dataIndex: 'ratio', | |||
| width: 150, | |||
| render: (value, _, index) => ( | |||
| <InputNumber value={Number(value || 0)} min={0} step={0.000001} onChange={(ratio) => updateEntry(index, { ratio: Number(ratio || 0) })} data-testid={`generator-ratio-ratio-${index}`} /> | |||
| <InputNumber | |||
| value={Number(value || 0)} | |||
| min={0} | |||
| step={0.000001} | |||
| onChange={(ratio) => | |||
| updateEntry(index, { ratio: Number(ratio || 0) }) | |||
| } | |||
| data-testid={`generator-ratio-ratio-${index}`} | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| title: t('操作'), | |||
| width: 80, | |||
| render: (_, __, index) => <Button icon={<Trash2 size={16} />} type='danger' theme='borderless' onClick={() => removeEntry(index)} />, | |||
| render: (_, __, index) => ( | |||
| <Button | |||
| icon={<Trash2 size={16} />} | |||
| type='danger' | |||
| theme='borderless' | |||
| onClick={() => removeEntry(index)} | |||
| /> | |||
| ), | |||
| }, | |||
| ]; | |||
| const previewColumns = [ | |||
| ...(dimensions || []).map((dim) => ({ title: dim.key, dataIndex: dim.key })), | |||
| ...(dimensions || []).map((dim) => ({ | |||
| title: dim.key, | |||
| dataIndex: dim.key, | |||
| })), | |||
| { | |||
| title: t('展示价格'), | |||
| title: t('展示价格({{unit}})', { unit: displayUnitLabel }), | |||
| dataIndex: 'display_price', | |||
| width: 140, | |||
| width: 170, | |||
| render: (_, row) => formatDisplayCurrency(row.price, currencyConfig, 6), | |||
| }, | |||
| { | |||
| title: t('保存价格 USD'), | |||
| title: t('保存价格 USD({{unit}})', { unit: savedUnitLabel }), | |||
| dataIndex: 'price', | |||
| width: 140, | |||
| width: 190, | |||
| render: (price) => `$${Number(price || 0).toFixed(6)}`, | |||
| }, | |||
| { title: t('来源'), dataIndex: 'source', width: 120 }, | |||
| @@ -124,22 +196,38 @@ export default function GeneratorModal({ visible, dimensions, billingUnit, exist | |||
| footer={ | |||
| <Space> | |||
| <Button onClick={onCancel}>{t('取消')}</Button> | |||
| <Button type='primary' data-testid='generator-apply' onClick={apply} disabled={preview.rows.length === 0}> | |||
| <Button | |||
| type='primary' | |||
| data-testid='generator-apply' | |||
| onClick={apply} | |||
| disabled={preview.rows.length === 0} | |||
| > | |||
| {t('应用生成结果')} | |||
| </Button> | |||
| </Space> | |||
| } | |||
| > | |||
| <Space vertical align='start' style={{ width: '100%' }} data-testid='model-pricing-generator'> | |||
| <Space | |||
| vertical | |||
| align='start' | |||
| style={{ width: '100%' }} | |||
| data-testid='model-pricing-generator' | |||
| > | |||
| <Typography.Text> | |||
| {t('基础价格是所有倍率计算的起点,当前单位为 {{unitLabel}}。最终价格 = 基础价格 × 各维度倍率。', { unitLabel })} | |||
| {t( | |||
| '基础价格是所有倍率计算的起点,当前单位为 {{unitLabel}}。最终价格 = 基础价格 × 各维度倍率。', | |||
| { unitLabel: billingUnitLabel }, | |||
| )} | |||
| </Typography.Text> | |||
| <Typography.Text type='tertiary'> | |||
| {t('当前按 {{symbol}} 展示和输入,应用时会按汇率换算为 USD 保存到 table[].price。当前输入 {{symbol}}{{displayPrice}} = ${{usdPrice}}。', { | |||
| symbol: currencyConfig.symbol, | |||
| displayPrice: Number(basePrice || 0).toFixed(6), | |||
| usdPrice: basePriceUSD.toFixed(6), | |||
| })} | |||
| {t( | |||
| '当前按 {{symbol}} 展示和输入,应用时会按汇率换算为 USD 保存到 table[].price。当前输入 {{symbol}}{{displayPrice}} = ${{usdPrice}}。', | |||
| { | |||
| symbol: currencyConfig.symbol, | |||
| displayPrice: Number(basePrice || 0).toFixed(6), | |||
| usdPrice: basePriceUSD.toFixed(6), | |||
| }, | |||
| )} | |||
| </Typography.Text> | |||
| <Space wrap> | |||
| <InputNumber | |||
| @@ -147,29 +235,56 @@ export default function GeneratorModal({ visible, dimensions, billingUnit, exist | |||
| min={0} | |||
| step={0.000001} | |||
| onChange={(value) => setBasePrice(Number(value || 0))} | |||
| prefix={`${t('基础价格')}(${currencyConfig.symbol}/${unitLabel})`} | |||
| prefix={t('价格({{unit}})', { unit: displayUnitLabel })} | |||
| data-testid='generator-base-price' | |||
| /> | |||
| <Radio.Group value={strategy} onChange={(event) => setStrategy(event.target.value)}> | |||
| <Radio value='preserve_manual' data-testid='generator-strategy-preserve-manual'> | |||
| <Radio.Group | |||
| value={strategy} | |||
| onChange={(event) => setStrategy(event.target.value)} | |||
| > | |||
| <Radio | |||
| value='preserve_manual' | |||
| data-testid='generator-strategy-preserve-manual' | |||
| > | |||
| {t('保留手动行')} | |||
| </Radio> | |||
| <Radio value='replace_all' data-testid='generator-strategy-replace-all'> | |||
| <Radio | |||
| value='replace_all' | |||
| data-testid='generator-strategy-replace-all' | |||
| > | |||
| {t('全部替换')} | |||
| </Radio> | |||
| </Radio.Group> | |||
| </Space> | |||
| <Table columns={ratioColumns} dataSource={ratioEntries} pagination={false} rowKey={(_, index) => `${index}`} /> | |||
| <Button icon={<Plus size={16} />} data-testid='generator-add-ratio' onClick={addEntry}> | |||
| <Table | |||
| columns={ratioColumns} | |||
| dataSource={ratioEntries} | |||
| pagination={false} | |||
| rowKey={(_, index) => `${index}`} | |||
| /> | |||
| <Button | |||
| icon={<Plus size={16} />} | |||
| data-testid='generator-add-ratio' | |||
| onClick={addEntry} | |||
| > | |||
| {t('添加倍率项')} | |||
| </Button> | |||
| <Typography.Text type='tertiary'> | |||
| {t('线性倍率项 {{linearCount}} 项,最终展开 {{expandedCount}} 行。任一维度没有候选值时不会生成结果。预览最多展示前 50 行。', { | |||
| linearCount: preview.linearCount, | |||
| expandedCount: preview.expandedCount, | |||
| })} | |||
| {t( | |||
| '线性倍率项 {{linearCount}} 项,最终展开 {{expandedCount}} 行。任一维度没有候选值时不会生成结果。预览最多展示前 50 行。', | |||
| { | |||
| linearCount: preview.linearCount, | |||
| expandedCount: preview.expandedCount, | |||
| }, | |||
| )} | |||
| </Typography.Text> | |||
| <Table columns={previewColumns} dataSource={preview.rows.slice(0, 50)} pagination={false} rowKey={(_, index) => `${index}`} scroll={{ x: 720 }} /> | |||
| <Table | |||
| columns={previewColumns} | |||
| dataSource={preview.rows.slice(0, 50)} | |||
| pagination={false} | |||
| rowKey={(_, index) => `${index}`} | |||
| scroll={{ x: 720 }} | |||
| /> | |||
| </Space> | |||
| </Modal> | |||
| ); | |||
| @@ -197,7 +312,10 @@ const entriesToRatios = (entries, dimensions) => { | |||
| const dim = dimByKey.get(entry.dimensionKey); | |||
| if (!dim || entry.value === '') continue; | |||
| const parsedValue = String(parseDimensionValue(entry.value, dim.type)); | |||
| ratios[dim.key] = { ...(ratios[dim.key] || {}), [parsedValue]: Number(entry.ratio || 0) }; | |||
| ratios[dim.key] = { | |||
| ...(ratios[dim.key] || {}), | |||
| [parsedValue]: Number(entry.ratio || 0), | |||
| }; | |||
| } | |||
| return ratios; | |||
| }; | |||
| @@ -1,11 +1,26 @@ | |||
| import React, { useMemo, useState } from 'react'; | |||
| import { Descriptions, TextArea, Typography } from '@douyinfe/semi-ui'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { previewLookup } from './pricingConfig'; | |||
| import { getCurrencyConfig } from '../../../../helpers'; | |||
| import { | |||
| formatDisplayCurrency, | |||
| getBillingUnitLabel, | |||
| getPriceUnitLabel, | |||
| previewLookup, | |||
| } from './pricingConfig'; | |||
| export default function PreviewPanel({ config }) { | |||
| const { t } = useTranslation(); | |||
| const [requestText, setRequestText] = useState('{\n "resolution": "720P"\n}'); | |||
| const [requestText, setRequestText] = useState( | |||
| '{\n "resolution": "720P"\n}', | |||
| ); | |||
| const currencyConfig = getCurrencyConfig(); | |||
| const displayUnitLabel = getPriceUnitLabel( | |||
| config?.billing_unit, | |||
| currencyConfig.symbol, | |||
| t, | |||
| ); | |||
| const savedBillingUnitLabel = getBillingUnitLabel(config?.billing_unit, t); | |||
| const result = useMemo(() => { | |||
| try { | |||
| @@ -15,15 +30,32 @@ export default function PreviewPanel({ config }) { | |||
| } | |||
| }, [config, requestText]); | |||
| const displayPrice = | |||
| result.price === undefined | |||
| ? '-' | |||
| : `${formatDisplayCurrency(result.price, currencyConfig, 6)} / ${displayUnitLabel}`; | |||
| const savedPrice = | |||
| result.price === undefined | |||
| ? '-' | |||
| : `$${Number(result.price || 0).toFixed(6)} / ${savedBillingUnitLabel}`; | |||
| return ( | |||
| <> | |||
| <TextArea value={requestText} onChange={setRequestText} autosize={{ minRows: 8, maxRows: 14 }} /> | |||
| <TextArea | |||
| value={requestText} | |||
| onChange={setRequestText} | |||
| autosize={{ minRows: 8, maxRows: 14 }} | |||
| /> | |||
| <Descriptions | |||
| style={{ marginTop: 16 }} | |||
| data={[ | |||
| { key: t('命中模式'), value: result.mode }, | |||
| { key: t('最终价格'), value: result.price ?? '-' }, | |||
| { key: t('快照'), value: result.snapshot ? JSON.stringify(result.snapshot) : '-' }, | |||
| { key: t('最终价格'), value: displayPrice }, | |||
| { key: t('保存价格 USD'), value: savedPrice }, | |||
| { | |||
| key: t('快照'), | |||
| value: result.snapshot ? JSON.stringify(result.snapshot) : '-', | |||
| }, | |||
| ]} | |||
| /> | |||
| {result.error && ( | |||
| @@ -1,11 +1,32 @@ | |||
| import React from 'react'; | |||
| import { Button, Input, InputNumber, Select, Space, Table } from '@douyinfe/semi-ui'; | |||
| import { | |||
| Button, | |||
| Input, | |||
| InputNumber, | |||
| Select, | |||
| Space, | |||
| Table, | |||
| } from '@douyinfe/semi-ui'; | |||
| import { Plus, Trash2 } from 'lucide-react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { ROW_SOURCES, parseDimensionValue } from './pricingConfig'; | |||
| import { getCurrencyConfig } from '../../../../helpers'; | |||
| import { | |||
| ROW_SOURCES, | |||
| displayCurrencyToUSD, | |||
| formatDisplayCurrencyNumber, | |||
| getPriceUnitLabel, | |||
| parseDimensionValue, | |||
| } from './pricingConfig'; | |||
| export default function PricingGrid({ dimensions, rows, onChange }) { | |||
| export default function PricingGrid({ | |||
| dimensions, | |||
| rows, | |||
| billingUnit, | |||
| onChange, | |||
| }) { | |||
| const { t } = useTranslation(); | |||
| const currencyConfig = getCurrencyConfig(); | |||
| const unitLabel = getPriceUnitLabel(billingUnit, currencyConfig.symbol, t); | |||
| const update = (index, patch) => { | |||
| const next = [...rows]; | |||
| @@ -19,22 +40,43 @@ export default function PricingGrid({ dimensions, rows, onChange }) { | |||
| onChange([...(rows || []), row]); | |||
| }; | |||
| const remove = (index) => onChange(rows.filter((_, itemIndex) => itemIndex !== index)); | |||
| const remove = (index) => | |||
| onChange(rows.filter((_, itemIndex) => itemIndex !== index)); | |||
| const columns = [ | |||
| ...dimensions.map((dim) => ({ | |||
| title: dim.key, | |||
| dataIndex: dim.key, | |||
| render: (value, _, index) => ( | |||
| <Input value={value ?? ''} onChange={(text) => update(index, { [dim.key]: text === '*' ? '*' : parseDimensionValue(text, dim.type) })} /> | |||
| <Input | |||
| value={value ?? ''} | |||
| onChange={(text) => | |||
| update(index, { | |||
| [dim.key]: | |||
| text === '*' ? '*' : parseDimensionValue(text, dim.type), | |||
| }) | |||
| } | |||
| /> | |||
| ), | |||
| })), | |||
| { | |||
| title: t('价格'), | |||
| title: t('价格({{unit}})', { unit: unitLabel }), | |||
| dataIndex: 'price', | |||
| width: 140, | |||
| width: 220, | |||
| render: (value, _, index) => ( | |||
| <InputNumber value={Number(value || 0)} min={0} step={0.000001} onChange={(price) => update(index, { price: Number(price || 0), source: ROW_SOURCES.manual })} /> | |||
| <InputNumber | |||
| value={formatDisplayCurrencyNumber(value, currencyConfig)} | |||
| min={0} | |||
| step={0.000001} | |||
| suffix={unitLabel} | |||
| style={{ width: 200 }} | |||
| onChange={(price) => | |||
| update(index, { | |||
| price: displayCurrencyToUSD(price, currencyConfig), | |||
| source: ROW_SOURCES.manual, | |||
| }) | |||
| } | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| @@ -42,22 +84,40 @@ export default function PricingGrid({ dimensions, rows, onChange }) { | |||
| dataIndex: 'source', | |||
| width: 140, | |||
| render: (value, _, index) => ( | |||
| <Select value={value || ROW_SOURCES.manual} onChange={(source) => update(index, { source })} style={{ width: 120 }}> | |||
| <Select | |||
| value={value || ROW_SOURCES.manual} | |||
| onChange={(source) => update(index, { source })} | |||
| style={{ width: 120 }} | |||
| > | |||
| <Select.Option value={ROW_SOURCES.manual}>{t('手动')}</Select.Option> | |||
| <Select.Option value={ROW_SOURCES.generated}>{t('生成')}</Select.Option> | |||
| <Select.Option value={ROW_SOURCES.generated}> | |||
| {t('生成')} | |||
| </Select.Option> | |||
| </Select> | |||
| ), | |||
| }, | |||
| { | |||
| title: t('操作'), | |||
| width: 80, | |||
| render: (_, __, index) => <Button icon={<Trash2 size={16} />} type='danger' theme='borderless' onClick={() => remove(index)} />, | |||
| render: (_, __, index) => ( | |||
| <Button | |||
| icon={<Trash2 size={16} />} | |||
| type='danger' | |||
| theme='borderless' | |||
| onClick={() => remove(index)} | |||
| /> | |||
| ), | |||
| }, | |||
| ]; | |||
| return ( | |||
| <Space vertical align='start' style={{ width: '100%' }}> | |||
| <Table columns={columns} dataSource={rows} pagination={{ pageSize: 8 }} rowKey={(_, index) => `${index}`} /> | |||
| <Table | |||
| columns={columns} | |||
| dataSource={rows} | |||
| pagination={{ pageSize: 8 }} | |||
| rowKey={(_, index) => `${index}`} | |||
| /> | |||
| <Button icon={<Plus size={16} />} onClick={add}> | |||
| {t('添加价格行')} | |||
| </Button> | |||
| @@ -74,32 +74,51 @@ export const validatePricingConfig = (config) => { | |||
| const billingUnit = config.billing_unit || BILLING_UNITS.perCall; | |||
| const preconsume = config.preconsume_strategy || PRECONSUME_STRATEGIES.exact; | |||
| if (!Object.values(BILLING_UNITS).includes(billingUnit)) errors.push('billing_unit is invalid'); | |||
| if (!Object.values(PRECONSUME_STRATEGIES).includes(preconsume)) errors.push('preconsume_strategy is invalid'); | |||
| if (billingUnit === BILLING_UNITS.perCall && preconsume !== PRECONSUME_STRATEGIES.exact) { | |||
| if (!Object.values(BILLING_UNITS).includes(billingUnit)) | |||
| errors.push('billing_unit is invalid'); | |||
| if (!Object.values(PRECONSUME_STRATEGIES).includes(preconsume)) | |||
| errors.push('preconsume_strategy is invalid'); | |||
| if ( | |||
| billingUnit === BILLING_UNITS.perCall && | |||
| preconsume !== PRECONSUME_STRATEGIES.exact | |||
| ) { | |||
| errors.push('per_call requires exact preconsume_strategy'); | |||
| } | |||
| if (billingUnit === BILLING_UNITS.per1MTokens && preconsume !== PRECONSUME_STRATEGIES.minimum) { | |||
| if ( | |||
| billingUnit === BILLING_UNITS.per1MTokens && | |||
| preconsume !== PRECONSUME_STRATEGIES.minimum | |||
| ) { | |||
| errors.push('per_1m_tokens requires minimum preconsume_strategy'); | |||
| } | |||
| if (!Array.isArray(config.dimensions) || config.dimensions.length === 0) errors.push('dimensions must not be empty'); | |||
| if (!Array.isArray(config.table) || config.table.length === 0) errors.push('table must not be empty'); | |||
| if (!Array.isArray(config.dimensions) || config.dimensions.length === 0) | |||
| errors.push('dimensions must not be empty'); | |||
| if (!Array.isArray(config.table) || config.table.length === 0) | |||
| errors.push('table must not be empty'); | |||
| const dimensionKeys = new Set(); | |||
| for (const dim of config.dimensions || []) { | |||
| if (!/^[a-z][a-z0-9_]{0,31}$/.test(dim.key || '')) errors.push(`dimension key "${dim.key || ''}" is invalid`); | |||
| if (RESERVED_KEYS.has(dim.key)) errors.push(`dimension key "${dim.key}" is reserved`); | |||
| if (dimensionKeys.has(dim.key)) errors.push(`dimension key "${dim.key}" is duplicated`); | |||
| if (!/^[a-z][a-z0-9_]{0,31}$/.test(dim.key || '')) | |||
| errors.push(`dimension key "${dim.key || ''}" is invalid`); | |||
| if (RESERVED_KEYS.has(dim.key)) | |||
| errors.push(`dimension key "${dim.key}" is reserved`); | |||
| if (dimensionKeys.has(dim.key)) | |||
| errors.push(`dimension key "${dim.key}" is duplicated`); | |||
| dimensionKeys.add(dim.key); | |||
| if (!['string', 'number', 'boolean'].includes(dim.type)) errors.push(`dimension "${dim.key}" type is invalid`); | |||
| if (!['string', 'number', 'boolean'].includes(dim.type)) | |||
| errors.push(`dimension "${dim.key}" type is invalid`); | |||
| if (dim.optional && (dim.default === null || dim.default === undefined)) { | |||
| errors.push(`optional dimension "${dim.key}" requires a default value`); | |||
| } | |||
| if (!dim.optional && dim.default !== null && dim.default !== undefined) { | |||
| errors.push(`required dimension "${dim.key}" must use null default`); | |||
| } | |||
| if (dim.optional && dim.default !== null && dim.default !== undefined && !valueMatchesType(dim.default, dim.type)) { | |||
| if ( | |||
| dim.optional && | |||
| dim.default !== null && | |||
| dim.default !== undefined && | |||
| !valueMatchesType(dim.default, dim.type) | |||
| ) { | |||
| errors.push(`dimension "${dim.key}" default does not match type`); | |||
| } | |||
| } | |||
| @@ -108,7 +127,8 @@ export const validatePricingConfig = (config) => { | |||
| const rowPatterns = []; | |||
| for (const [index, row] of (config.table || []).entries()) { | |||
| for (const key of Object.keys(row || {})) { | |||
| if (!dimensionKeys.has(key) && key !== 'price' && key !== 'source') errors.push(`row ${index + 1} contains extra field "${key}"`); | |||
| if (!dimensionKeys.has(key) && key !== 'price' && key !== 'source') | |||
| errors.push(`row ${index + 1} contains extra field "${key}"`); | |||
| } | |||
| for (const dim of config.dimensions || []) { | |||
| if (!(dim.key in row)) { | |||
| @@ -116,20 +136,28 @@ export const validatePricingConfig = (config) => { | |||
| continue; | |||
| } | |||
| if (row[dim.key] !== '*' && !valueMatchesType(row[dim.key], dim.type)) { | |||
| errors.push(`row ${index + 1} value for "${dim.key}" does not match type`); | |||
| errors.push( | |||
| `row ${index + 1} value for "${dim.key}" does not match type`, | |||
| ); | |||
| } | |||
| } | |||
| const price = Number(row.price); | |||
| if (Number.isNaN(price) || price < 0) errors.push(`row ${index + 1} price must be >= 0`); | |||
| if (!Object.values(ROW_SOURCES).includes(row.source)) errors.push(`row ${index + 1} source must be manual or generated`); | |||
| const signature = (config.dimensions || []).map((dim) => String(row[dim.key])).join('\u0001'); | |||
| if (rowSignatures.has(signature)) errors.push(`row ${index + 1} duplicates another row`); | |||
| if (Number.isNaN(price) || price < 0) | |||
| errors.push(`row ${index + 1} price must be >= 0`); | |||
| if (!Object.values(ROW_SOURCES).includes(row.source)) | |||
| errors.push(`row ${index + 1} source must be manual or generated`); | |||
| const signature = (config.dimensions || []) | |||
| .map((dim) => String(row[dim.key])) | |||
| .join('\u0001'); | |||
| if (rowSignatures.has(signature)) | |||
| errors.push(`row ${index + 1} duplicates another row`); | |||
| rowSignatures.add(signature); | |||
| rowPatterns.push({ | |||
| index, | |||
| values: (config.dimensions || []).map((dim) => row[dim.key]), | |||
| wildcards: (config.dimensions || []).filter((dim) => row[dim.key] === '*').length, | |||
| wildcards: (config.dimensions || []).filter((dim) => row[dim.key] === '*') | |||
| .length, | |||
| }); | |||
| } | |||
| @@ -137,14 +165,21 @@ export const validatePricingConfig = (config) => { | |||
| for (let j = i + 1; j < rowPatterns.length; j += 1) { | |||
| if (rowPatterns[i].wildcards !== rowPatterns[j].wildcards) continue; | |||
| if (patternsOverlap(rowPatterns[i].values, rowPatterns[j].values)) { | |||
| errors.push(`rows ${rowPatterns[i].index + 1} and ${rowPatterns[j].index + 1} are ambiguous`); | |||
| errors.push( | |||
| `rows ${rowPatterns[i].index + 1} and ${rowPatterns[j].index + 1} are ambiguous`, | |||
| ); | |||
| } | |||
| } | |||
| } | |||
| const strategy = config.fallback?.strategy; | |||
| if (!Object.values(FALLBACKS).includes(strategy)) errors.push('fallback.strategy is invalid'); | |||
| if (strategy === FALLBACKS.default && (config.fallback.default_price === undefined || Number(config.fallback.default_price) < 0)) { | |||
| if (!Object.values(FALLBACKS).includes(strategy)) | |||
| errors.push('fallback.strategy is invalid'); | |||
| if ( | |||
| strategy === FALLBACKS.default && | |||
| (config.fallback.default_price === undefined || | |||
| Number(config.fallback.default_price) < 0) | |||
| ) { | |||
| errors.push('fallback.default_price must be >= 0'); | |||
| } | |||
| @@ -156,11 +191,22 @@ export const generateRowsFromRatios = ({ dimensions, basePrice, ratios }) => { | |||
| if (dims.length === 0) return []; | |||
| const build = (index, prefix, price) => { | |||
| if (index >= dims.length) return [{ ...prefix, price: Number(Number(price).toFixed(6)), source: ROW_SOURCES.generated }]; | |||
| if (index >= dims.length) | |||
| return [ | |||
| { | |||
| ...prefix, | |||
| price: Number(Number(price).toFixed(6)), | |||
| source: ROW_SOURCES.generated, | |||
| }, | |||
| ]; | |||
| const dim = dims[index]; | |||
| const dimRatios = ratios?.[dim.key] || {}; | |||
| return Object.entries(dimRatios).flatMap(([value, ratio]) => | |||
| build(index + 1, { ...prefix, [dim.key]: parseDimensionValue(value, dim.type) }, price * Number(ratio || 1)), | |||
| build( | |||
| index + 1, | |||
| { ...prefix, [dim.key]: parseDimensionValue(value, dim.type) }, | |||
| price * Number(ratio || 1), | |||
| ), | |||
| ); | |||
| }; | |||
| @@ -169,8 +215,16 @@ export const generateRowsFromRatios = ({ dimensions, basePrice, ratios }) => { | |||
| export const splitDimensionSource = (source) => { | |||
| const text = String(source || '').trim(); | |||
| if (text.startsWith('request.')) return { sourceType: DIMENSION_SOURCE_TYPES.request, path: text.slice('request.'.length) }; | |||
| if (text.startsWith('derived.')) return { sourceType: DIMENSION_SOURCE_TYPES.derived, path: text.slice('derived.'.length) }; | |||
| if (text.startsWith('request.')) | |||
| return { | |||
| sourceType: DIMENSION_SOURCE_TYPES.request, | |||
| path: text.slice('request.'.length), | |||
| }; | |||
| if (text.startsWith('derived.')) | |||
| return { | |||
| sourceType: DIMENSION_SOURCE_TYPES.derived, | |||
| path: text.slice('derived.'.length), | |||
| }; | |||
| return { sourceType: DIMENSION_SOURCE_TYPES.advanced, path: text }; | |||
| }; | |||
| @@ -186,7 +240,10 @@ export const composeDimensionSource = (sourceType, path) => { | |||
| }; | |||
| export const countRatioEntries = (ratios) => | |||
| Object.values(ratios || {}).reduce((total, values) => total + Object.keys(values || {}).length, 0); | |||
| Object.values(ratios || {}).reduce( | |||
| (total, values) => total + Object.keys(values || {}).length, | |||
| 0, | |||
| ); | |||
| export const estimateGeneratedRowCount = (dimensions, ratios) => { | |||
| const dims = dimensions || []; | |||
| @@ -202,24 +259,54 @@ export const displayCurrencyToUSD = (amount, currencyConfig) => { | |||
| const rate = Number(currencyConfig?.rate || 1); | |||
| const value = Number(amount || 0); | |||
| if (!Number.isFinite(rate) || rate <= 0) return value; | |||
| return Number((value / rate).toFixed(6)); | |||
| return Number((value / rate).toFixed(12)); | |||
| }; | |||
| export const formatDisplayCurrency = (usdAmount, currencyConfig, digits = 6) => { | |||
| const symbol = currencyConfig?.symbol || '$'; | |||
| export const formatDisplayCurrencyNumber = (usdAmount, currencyConfig) => { | |||
| const rate = Number(currencyConfig?.rate || 1); | |||
| const value = Number(usdAmount || 0) * (Number.isFinite(rate) && rate > 0 ? rate : 1); | |||
| const value = | |||
| Number(usdAmount || 0) * (Number.isFinite(rate) && rate > 0 ? rate : 1); | |||
| return Number(value.toFixed(6)); | |||
| }; | |||
| export const formatDisplayCurrency = ( | |||
| usdAmount, | |||
| currencyConfig, | |||
| digits = 6, | |||
| ) => { | |||
| const symbol = currencyConfig?.symbol || '$'; | |||
| const value = formatDisplayCurrencyNumber(usdAmount, currencyConfig); | |||
| return `${symbol}${value.toFixed(digits)}`; | |||
| }; | |||
| export const mergeGeneratedRows = ({ existingRows, generatedRows, strategy }) => { | |||
| export const getBillingUnitLabel = (billingUnit, t = (key) => key) => { | |||
| if (billingUnit === BILLING_UNITS.per1MTokens) return t('每 1M tokens'); | |||
| return t('每次调用'); | |||
| }; | |||
| export const getPriceUnitLabel = ( | |||
| billingUnit, | |||
| symbol = '$', | |||
| t = (key) => key, | |||
| ) => `${symbol} / ${getBillingUnitLabel(billingUnit, t)}`; | |||
| export const mergeGeneratedRows = ({ | |||
| existingRows, | |||
| generatedRows, | |||
| strategy, | |||
| }) => { | |||
| if (strategy === 'replace_all') return [...(generatedRows || [])]; | |||
| const manualRows = (existingRows || []).filter((row) => row.source === ROW_SOURCES.manual); | |||
| const manualRows = (existingRows || []).filter( | |||
| (row) => row.source === ROW_SOURCES.manual, | |||
| ); | |||
| return [...manualRows, ...(generatedRows || [])]; | |||
| }; | |||
| export const importPricingCsv = (text, dimensions) => { | |||
| const lines = String(text || '').trim().split(/\r?\n/).filter(Boolean); | |||
| const lines = String(text || '') | |||
| .trim() | |||
| .split(/\r?\n/) | |||
| .filter(Boolean); | |||
| if (lines.length === 0) return { rows: [], errors: [] }; | |||
| const headers = parseCsvLine(lines[0]); | |||
| @@ -230,10 +317,15 @@ export const importPricingCsv = (text, dimensions) => { | |||
| headers.forEach((header, i) => { | |||
| row[header] = cells[i] ?? ''; | |||
| }); | |||
| for (const dim of dimensions || []) row[dim.key] = row[dim.key] === '*' ? '*' : parseDimensionValue(row[dim.key], dim.type); | |||
| for (const dim of dimensions || []) | |||
| row[dim.key] = | |||
| row[dim.key] === '*' | |||
| ? '*' | |||
| : parseDimensionValue(row[dim.key], dim.type); | |||
| row.price = Number(row.price); | |||
| row.source = row.source || ROW_SOURCES.manual; | |||
| if (Number.isNaN(row.price)) errors.push(`line ${index + 2} has invalid price`); | |||
| if (Number.isNaN(row.price)) | |||
| errors.push(`line ${index + 2} has invalid price`); | |||
| return row; | |||
| }); | |||
| @@ -241,14 +333,21 @@ export const importPricingCsv = (text, dimensions) => { | |||
| }; | |||
| export const exportPricingCsv = (rows, dimensions) => { | |||
| const headers = [...(dimensions || []).map((dim) => dim.key), 'price', 'source']; | |||
| const body = (rows || []).map((row) => headers.map((header) => escapeCsvValue(row[header] ?? '')).join(',')); | |||
| const headers = [ | |||
| ...(dimensions || []).map((dim) => dim.key), | |||
| 'price', | |||
| 'source', | |||
| ]; | |||
| const body = (rows || []).map((row) => | |||
| headers.map((header) => escapeCsvValue(row[header] ?? '')).join(','), | |||
| ); | |||
| return [headers.join(','), ...body].join('\n'); | |||
| }; | |||
| export const previewLookup = (config, requestValues) => { | |||
| const validation = validatePricingConfig(config); | |||
| if (!validation.ok) return { mode: 'invalid', error: validation.errors.join('\n') }; | |||
| if (!validation.ok) | |||
| return { mode: 'invalid', error: validation.errors.join('\n') }; | |||
| const matches = []; | |||
| for (const row of config.table || []) { | |||
| @@ -269,25 +368,54 @@ export const previewLookup = (config, requestValues) => { | |||
| } | |||
| matches.sort((a, b) => a.wildcards - b.wildcards); | |||
| if (matches.length > 1 && matches[0].wildcards === matches[1].wildcards) return { mode: 'reject', error: 'ambiguous matching rows' }; | |||
| if (matches.length > 1 && matches[0].wildcards === matches[1].wildcards) | |||
| return { mode: 'reject', error: 'ambiguous matching rows' }; | |||
| if (matches.length > 0) { | |||
| const best = matches[0]; | |||
| return { | |||
| mode: 'matched', | |||
| row: best.row, | |||
| price: Number(best.row.price), | |||
| snapshot: buildSnapshot(config, requestValues, Number(best.row.price), 'matched'), | |||
| snapshot: buildSnapshot( | |||
| config, | |||
| requestValues, | |||
| Number(best.row.price), | |||
| 'matched', | |||
| ), | |||
| }; | |||
| } | |||
| if (config.fallback.strategy === FALLBACKS.reject) return { mode: 'reject', error: 'no pricing row matched' }; | |||
| if (config.fallback.strategy === FALLBACKS.reject) | |||
| return { mode: 'reject', error: 'no pricing row matched' }; | |||
| if (config.fallback.strategy === FALLBACKS.max) { | |||
| const price = Math.max(...config.table.map((row) => Number(row.price || 0))); | |||
| return { mode: 'fallback_max', price, snapshot: buildSnapshot(config, requestValues, price, 'fallback_max', FALLBACKS.max) }; | |||
| const price = Math.max( | |||
| ...config.table.map((row) => Number(row.price || 0)), | |||
| ); | |||
| return { | |||
| mode: 'fallback_max', | |||
| price, | |||
| snapshot: buildSnapshot( | |||
| config, | |||
| requestValues, | |||
| price, | |||
| 'fallback_max', | |||
| FALLBACKS.max, | |||
| ), | |||
| }; | |||
| } | |||
| const price = Number(config.fallback.default_price || 0); | |||
| return { mode: 'fallback_default', price, snapshot: buildSnapshot(config, requestValues, price, 'fallback_default', FALLBACKS.default) }; | |||
| return { | |||
| mode: 'fallback_default', | |||
| price, | |||
| snapshot: buildSnapshot( | |||
| config, | |||
| requestValues, | |||
| price, | |||
| 'fallback_default', | |||
| FALLBACKS.default, | |||
| ), | |||
| }; | |||
| }; | |||
| export const parseDimensionValue = (value, type) => { | |||
| @@ -298,13 +426,19 @@ export const parseDimensionValue = (value, type) => { | |||
| }; | |||
| const valueMatchesType = (value, type) => { | |||
| if (type === 'number') return typeof value === 'number' && !Number.isNaN(value); | |||
| if (type === 'number') | |||
| return typeof value === 'number' && !Number.isNaN(value); | |||
| if (type === 'boolean') return typeof value === 'boolean'; | |||
| return typeof value === 'string'; | |||
| }; | |||
| const patternsOverlap = (left, right) => | |||
| left.every((leftValue, index) => leftValue === '*' || right[index] === '*' || String(leftValue) === String(right[index])); | |||
| left.every( | |||
| (leftValue, index) => | |||
| leftValue === '*' || | |||
| right[index] === '*' || | |||
| String(leftValue) === String(right[index]), | |||
| ); | |||
| const parseCsvLine = (line) => line.split(',').map((item) => item.trim()); | |||
| @@ -316,7 +450,8 @@ const escapeCsvValue = (value) => { | |||
| const buildSnapshot = (config, values, price, mode, fallbackStrategy) => { | |||
| const snapshot = {}; | |||
| for (const dim of config.dimensions || []) snapshot[dim.key] = values?.[dim.key] ?? null; | |||
| for (const dim of config.dimensions || []) | |||
| snapshot[dim.key] = values?.[dim.key] ?? null; | |||
| snapshot.price = price; | |||
| snapshot.billing_unit = config.billing_unit || BILLING_UNITS.perCall; | |||
| snapshot.pricing_mode = mode; | |||
| @@ -8,7 +8,10 @@ import { | |||
| estimateGeneratedRowCount, | |||
| exportPricingCsv, | |||
| formatDisplayCurrency, | |||
| formatDisplayCurrencyNumber, | |||
| generateRowsFromRatios, | |||
| getBillingUnitLabel, | |||
| getPriceUnitLabel, | |||
| importPricingCsv, | |||
| mergeGeneratedRows, | |||
| PRECONSUME_STRATEGIES, | |||
| @@ -43,7 +46,14 @@ describe('pricingConfig utilities', () => { | |||
| scope: 'model', | |||
| billing_unit: BILLING_UNITS.per1MTokens, | |||
| preconsume_strategy: PRECONSUME_STRATEGIES.minimum, | |||
| dimensions: [{ key: 'video_input', source: 'derived.video_input', type: 'boolean', default: null }], | |||
| dimensions: [ | |||
| { | |||
| key: 'video_input', | |||
| source: 'derived.video_input', | |||
| type: 'boolean', | |||
| default: null, | |||
| }, | |||
| ], | |||
| table: [ | |||
| { video_input: false, price: 46, source: ROW_SOURCES.manual }, | |||
| { video_input: true, price: 28, source: ROW_SOURCES.manual }, | |||
| @@ -70,7 +80,9 @@ describe('pricingConfig utilities', () => { | |||
| expect(generatedRows).toHaveLength(4); | |||
| const merged = mergeGeneratedRows({ | |||
| existingRows: [{ resolution: '480P', duration: 5, price: 0.05, source: 'manual' }], | |||
| existingRows: [ | |||
| { resolution: '480P', duration: 5, price: 0.05, source: 'manual' }, | |||
| ], | |||
| generatedRows, | |||
| strategy: 'preserve_manual', | |||
| }); | |||
| @@ -79,14 +91,31 @@ describe('pricingConfig utilities', () => { | |||
| }); | |||
| test('split and compose dimension source', () => { | |||
| expect(splitDimensionSource('request.duration')).toEqual({ sourceType: 'request', path: 'duration' }); | |||
| expect(splitDimensionSource('derived.aspect_ratio')).toEqual({ sourceType: 'derived', path: 'aspect_ratio' }); | |||
| expect(splitDimensionSource('metadata.custom')).toEqual({ sourceType: 'advanced', path: 'metadata.custom' }); | |||
| expect(composeDimensionSource('request', 'duration')).toBe('request.duration'); | |||
| expect(composeDimensionSource('request', 'request.duration')).toBe('request.duration'); | |||
| expect(composeDimensionSource('derived', 'aspect_ratio')).toBe('derived.aspect_ratio'); | |||
| expect(composeDimensionSource('advanced', 'metadata.custom')).toBe('metadata.custom'); | |||
| expect(splitDimensionSource('request.duration')).toEqual({ | |||
| sourceType: 'request', | |||
| path: 'duration', | |||
| }); | |||
| expect(splitDimensionSource('derived.aspect_ratio')).toEqual({ | |||
| sourceType: 'derived', | |||
| path: 'aspect_ratio', | |||
| }); | |||
| expect(splitDimensionSource('metadata.custom')).toEqual({ | |||
| sourceType: 'advanced', | |||
| path: 'metadata.custom', | |||
| }); | |||
| expect(composeDimensionSource('request', 'duration')).toBe( | |||
| 'request.duration', | |||
| ); | |||
| expect(composeDimensionSource('request', 'request.duration')).toBe( | |||
| 'request.duration', | |||
| ); | |||
| expect(composeDimensionSource('derived', 'aspect_ratio')).toBe( | |||
| 'derived.aspect_ratio', | |||
| ); | |||
| expect(composeDimensionSource('advanced', 'metadata.custom')).toBe( | |||
| 'metadata.custom', | |||
| ); | |||
| }); | |||
| test('count linear ratio entries and expanded rows', () => { | |||
| @@ -100,7 +129,9 @@ describe('pricingConfig utilities', () => { | |||
| }; | |||
| expect(countRatioEntries(ratios)).toBe(5); | |||
| expect(estimateGeneratedRowCount(dimensions, ratios)).toBe(6); | |||
| expect(estimateGeneratedRowCount(dimensions, { resolution: { '720P': 1 } })).toBe(0); | |||
| expect( | |||
| estimateGeneratedRowCount(dimensions, { resolution: { '720P': 1 } }), | |||
| ).toBe(0); | |||
| }); | |||
| test('convert display currency amounts to stored USD prices', () => { | |||
| @@ -108,16 +139,33 @@ describe('pricingConfig utilities', () => { | |||
| const custom = { symbol: '¤', rate: 2, type: 'CUSTOM' }; | |||
| expect(displayCurrencyToUSD(0.7, cny)).toBe(0.1); | |||
| expect(displayCurrencyToUSD(0.4, custom)).toBe(0.2); | |||
| expect(displayCurrencyToUSD(0.1, { symbol: '$', rate: 1, type: 'USD' })).toBe(0.1); | |||
| expect( | |||
| displayCurrencyToUSD(0.1, { symbol: '$', rate: 1, type: 'USD' }), | |||
| ).toBe(0.1); | |||
| expect(formatDisplayCurrency(0.1, cny, 4)).toBe('¥0.7000'); | |||
| expect(formatDisplayCurrencyNumber(46, cny)).toBe(322); | |||
| expect(formatDisplayCurrencyNumber(displayCurrencyToUSD(37, cny), cny)).toBe( | |||
| 37, | |||
| ); | |||
| expect(getBillingUnitLabel(BILLING_UNITS.per1MTokens, (key) => key)).toBe( | |||
| '每 1M tokens', | |||
| ); | |||
| expect( | |||
| getPriceUnitLabel(BILLING_UNITS.per1MTokens, '¥', (key) => key), | |||
| ).toBe('¥ / 每 1M tokens'); | |||
| }); | |||
| test('csv import and export', () => { | |||
| const dimensions = [{ key: 'resolution', type: 'string' }]; | |||
| const parsed = importPricingCsv('resolution,price,source\n720P,0.1,manual', dimensions); | |||
| const parsed = importPricingCsv( | |||
| 'resolution,price,source\n720P,0.1,manual', | |||
| dimensions, | |||
| ); | |||
| expect(parsed.errors).toHaveLength(0); | |||
| expect(parsed.rows[0].price).toBe(0.1); | |||
| expect(exportPricingCsv(parsed.rows, dimensions)).toContain('720P,0.1,manual'); | |||
| expect(exportPricingCsv(parsed.rows, dimensions)).toContain( | |||
| '720P,0.1,manual', | |||
| ); | |||
| }); | |||
| test('preview exact row, wildcard row, and fallbacks', () => { | |||