Ver a proveniência

feat: 模型定价矩阵价格取中位数展示

前端模型定价配置 UI 增强(Editor/GeneratorModal/PreviewPanel/PricingGrid),
后端新增 representativeMatrixPrice 取矩阵定价表中位数作为展示价格,
替代原来的 model_price=0 导致的价格展示为空问题。

Co-Authored-By: Claude <noreply@anthropic.com>
master
fengsilin há 2 semanas
ascendente
cometimento
2e40bab478
12 ficheiros alterados com 752 adições e 146 eliminações
  1. +45
    -3
      controller/pricing.go
  2. +8
    -3
      relay/helper/pricing_lookup.go
  3. +28
    -0
      relay/helper/pricing_lookup_test.go
  4. +5
    -3
      web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx
  5. +54
    -1
      web/src/components/table/model-pricing/view/card/PricingCardView.jsx
  6. +5
    -0
      web/src/pages/Home/HomePricingFilters.jsx
  7. +96
    -18
      web/src/pages/Setting/Operation/ModelPricing/Editor.jsx
  8. +157
    -39
      web/src/pages/Setting/Operation/ModelPricing/GeneratorModal.jsx
  9. +37
    -5
      web/src/pages/Setting/Operation/ModelPricing/PreviewPanel.jsx
  10. +72
    -12
      web/src/pages/Setting/Operation/ModelPricing/PricingGrid.jsx
  11. +183
    -48
      web/src/pages/Setting/Operation/ModelPricing/pricingConfig.js
  12. +62
    -14
      web/src/pages/Setting/Operation/ModelPricing/pricingConfig.test.js

+ 45
- 3
controller/pricing.go Ver ficheiro

@@ -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 {


+ 8
- 3
relay/helper/pricing_lookup.go Ver ficheiro

@@ -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)
}



+ 28
- 0
relay/helper/pricing_lookup_test.go Ver ficheiro

@@ -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,


+ 5
- 3
web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx Ver ficheiro

@@ -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 !== '-',
);

// 根据计费类型添加价格列


+ 54
- 1
web/src/components/table/model-pricing/view/card/PricingCardView.jsx Ver ficheiro

@@ -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('去体验')} →


+ 5
- 0
web/src/pages/Home/HomePricingFilters.jsx Ver ficheiro

@@ -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>



+ 96
- 18
web/src/pages/Setting/Operation/ModelPricing/Editor.jsx Ver ficheiro

@@ -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>


+ 157
- 39
web/src/pages/Setting/Operation/ModelPricing/GeneratorModal.jsx Ver ficheiro

@@ -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;
};

+ 37
- 5
web/src/pages/Setting/Operation/ModelPricing/PreviewPanel.jsx Ver ficheiro

@@ -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 && (


+ 72
- 12
web/src/pages/Setting/Operation/ModelPricing/PricingGrid.jsx Ver ficheiro

@@ -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>


+ 183
- 48
web/src/pages/Setting/Operation/ModelPricing/pricingConfig.js Ver ficheiro

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


+ 62
- 14
web/src/pages/Setting/Operation/ModelPricing/pricingConfig.test.js Ver ficheiro

@@ -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', () => {


Carregando…
Cancelar
Guardar