diff --git a/controller/pricing.go b/controller/pricing.go index ef9e8f8..de90e0b 100644 --- a/controller/pricing.go +++ b/controller/pricing.go @@ -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 { diff --git a/relay/helper/pricing_lookup.go b/relay/helper/pricing_lookup.go index 2169405..2c27a56 100644 --- a/relay/helper/pricing_lookup.go +++ b/relay/helper/pricing_lookup.go @@ -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) } diff --git a/relay/helper/pricing_lookup_test.go b/relay/helper/pricing_lookup_test.go index 8db194c..364eca9 100644 --- a/relay/helper/pricing_lookup_test.go +++ b/relay/helper/pricing_lookup_test.go @@ -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, diff --git a/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx b/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx index 03425b6..ca38963 100644 --- a/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx +++ b/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx @@ -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 !== '-', ); // 根据计费类型添加价格列 diff --git a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx index 022ea6f..0418390 100644 --- a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx +++ b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx @@ -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 ( +
+
+ {priceData.price}{' '} + / {priceData.unit} +
+ {(priceData.details || []).map((detail, detailIndex) => ( +
+ {detail.dimensionText} + + {detail.formattedPrice} + +
+ ))} + {priceData.hiddenDetailCount > 0 && ( +
+ + {priceData.hiddenDetailCount} +
+ )} +
+ ); + }; + // 渲染标签(卡片底部) // 左侧: 计费类型标签(quota_type: 0=按量计费, 1=按次计费) // 右侧: 自定义标签(来自 Model.tags,逗号分隔,最多显示3个,颜色由 stringToColor 哈希生成) @@ -300,6 +348,8 @@ const PricingCardView = ({

+ {renderCardPrice(model)} + {/* 底部区域 */}
{/* 标签区域 */} @@ -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('去体验')} → diff --git a/web/src/pages/Home/HomePricingFilters.jsx b/web/src/pages/Home/HomePricingFilters.jsx index 0966940..0b634f2 100644 --- a/web/src/pages/Home/HomePricingFilters.jsx +++ b/web/src/pages/Home/HomePricingFilters.jsx @@ -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} />
diff --git a/web/src/pages/Setting/Operation/ModelPricing/Editor.jsx b/web/src/pages/Setting/Operation/ModelPricing/Editor.jsx index 44dea9a..1504e1a 100644 --- a/web/src/pages/Setting/Operation/ModelPricing/Editor.jsx +++ b/web/src/pages/Setting/Operation/ModelPricing/Editor.jsx @@ -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={ - - @@ -119,21 +158,47 @@ export default function Editor({ visible, modelName, config, onClose, onSaved }) > - - + - @@ -144,16 +209,29 @@ export default function Editor({ visible, modelName, config, onClose, onSaved }) )} - updateDraft({ ...draft, dimensions })} /> + updateDraft({ ...draft, dimensions })} + /> - updateDraft({ ...draft, table })} /> + updateDraft({ ...draft, table })} + /> -