Skip to content

Commit f424f90

Browse files
authored
feat: sync upstream pricing from pricing endpoint (#4452)
* feat: sync upstream pricing from pricing endpoint * feat: sync upstream pricing with expression priority * fix: add feedback while syncing upstream pricing * fix: show loading state for empty upstream pricing sync
1 parent cc4ad6c commit f424f90

7 files changed

Lines changed: 645 additions & 269 deletions

File tree

controller/ratio_sync.go

Lines changed: 161 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,16 @@ import (
2121

2222
"github.com/QuantumNous/new-api/dto"
2323
"github.com/QuantumNous/new-api/model"
24+
"github.com/QuantumNous/new-api/setting/billing_setting"
2425
"github.com/QuantumNous/new-api/setting/ratio_setting"
26+
"github.com/samber/lo"
2527

2628
"github.com/gin-gonic/gin"
2729
)
2830

2931
const (
3032
defaultTimeoutSeconds = 10
31-
defaultEndpoint = "/api/ratio_config"
33+
defaultEndpoint = "/api/pricing"
3234
maxConcurrentFetches = 8
3335
maxRatioConfigBytes = 10 << 20 // 10MB
3436
floatEpsilon = 1e-9
@@ -59,14 +61,84 @@ func valuesEqual(a, b interface{}) bool {
5961
return a == b
6062
}
6163

62-
var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"}
64+
var pricingSyncFields = []string{
65+
"model_ratio",
66+
"completion_ratio",
67+
"cache_ratio",
68+
"create_cache_ratio",
69+
"image_ratio",
70+
"audio_ratio",
71+
"audio_completion_ratio",
72+
"model_price",
73+
billing_setting.BillingModeField,
74+
billing_setting.BillingExprField,
75+
}
76+
77+
var numericPricingSyncFields = map[string]bool{
78+
"model_ratio": true,
79+
"completion_ratio": true,
80+
"cache_ratio": true,
81+
"create_cache_ratio": true,
82+
"image_ratio": true,
83+
"audio_ratio": true,
84+
"audio_completion_ratio": true,
85+
"model_price": true,
86+
}
6387

6488
type upstreamResult struct {
6589
Name string `json:"name"`
6690
Data map[string]any `json:"data,omitempty"`
6791
Err string `json:"err,omitempty"`
6892
}
6993

94+
func valueMap(value any) map[string]any {
95+
switch typed := value.(type) {
96+
case map[string]any:
97+
return typed
98+
case map[string]float64:
99+
return lo.MapValues(typed, func(value float64, _ string) any { return value })
100+
case map[string]string:
101+
return lo.MapValues(typed, func(value string, _ string) any { return value })
102+
default:
103+
return nil
104+
}
105+
}
106+
107+
func asFloat64(value any) (float64, bool) {
108+
switch typed := value.(type) {
109+
case float64:
110+
return typed, true
111+
case float32:
112+
return float64(typed), true
113+
case int:
114+
return float64(typed), true
115+
case int64:
116+
return float64(typed), true
117+
case json.Number:
118+
parsed, err := typed.Float64()
119+
return parsed, err == nil
120+
default:
121+
return 0, false
122+
}
123+
}
124+
125+
func normalizeSyncValue(field string, value any) any {
126+
if numericPricingSyncFields[field] {
127+
if parsed, ok := asFloat64(value); ok {
128+
return parsed
129+
}
130+
}
131+
return value
132+
}
133+
134+
func getLocalPricingSyncData() map[string]any {
135+
data := billing_setting.GetPricingSyncData(map[string]any(ratio_setting.GetExposedData()))
136+
data["image_ratio"] = ratio_setting.GetImageRatioCopy()
137+
data["audio_ratio"] = ratio_setting.GetAudioRatioCopy()
138+
data["audio_completion_ratio"] = ratio_setting.GetAudioCompletionRatioCopy()
139+
return data
140+
}
141+
70142
func FetchUpstreamRatios(c *gin.Context) {
71143
var req dto.UpstreamRequest
72144
if err := c.ShouldBindJSON(&req); err != nil {
@@ -293,7 +365,7 @@ func FetchUpstreamRatios(c *gin.Context) {
293365
if err := common.Unmarshal(body.Data, &type1Data); err == nil {
294366
// 如果包含至少一个 ratioTypes 字段,则认为是 type1
295367
isType1 := false
296-
for _, rt := range ratioTypes {
368+
for _, rt := range pricingSyncFields {
297369
if _, ok := type1Data[rt]; ok {
298370
isType1 = true
299371
break
@@ -307,11 +379,18 @@ func FetchUpstreamRatios(c *gin.Context) {
307379

308380
// 如果不是 type1,则尝试按 type2 (/api/pricing) 解析
309381
var pricingItems []struct {
310-
ModelName string `json:"model_name"`
311-
QuotaType int `json:"quota_type"`
312-
ModelRatio float64 `json:"model_ratio"`
313-
ModelPrice float64 `json:"model_price"`
314-
CompletionRatio float64 `json:"completion_ratio"`
382+
ModelName string `json:"model_name"`
383+
QuotaType int `json:"quota_type"`
384+
ModelRatio float64 `json:"model_ratio"`
385+
ModelPrice float64 `json:"model_price"`
386+
CompletionRatio float64 `json:"completion_ratio"`
387+
CacheRatio *float64 `json:"cache_ratio"`
388+
CreateCacheRatio *float64 `json:"create_cache_ratio"`
389+
ImageRatio *float64 `json:"image_ratio"`
390+
AudioRatio *float64 `json:"audio_ratio"`
391+
AudioCompletionRatio *float64 `json:"audio_completion_ratio"`
392+
BillingMode string `json:"billing_mode"`
393+
BillingExpr string `json:"billing_expr"`
315394
}
316395
if err := common.Unmarshal(body.Data, &pricingItems); err != nil {
317396
logger.LogWarn(c.Request.Context(), "unrecognized data format from "+chItem.Name+": "+err.Error())
@@ -321,16 +400,45 @@ func FetchUpstreamRatios(c *gin.Context) {
321400

322401
modelRatioMap := make(map[string]float64)
323402
completionRatioMap := make(map[string]float64)
403+
cacheRatioMap := make(map[string]float64)
404+
createCacheRatioMap := make(map[string]float64)
405+
imageRatioMap := make(map[string]float64)
406+
audioRatioMap := make(map[string]float64)
407+
audioCompletionRatioMap := make(map[string]float64)
324408
modelPriceMap := make(map[string]float64)
409+
billingModeMap := make(map[string]string)
410+
billingExprMap := make(map[string]string)
325411

326412
for _, item := range pricingItems {
413+
if item.ModelName == "" {
414+
continue
415+
}
416+
if item.BillingMode == billing_setting.BillingModeTieredExpr && strings.TrimSpace(item.BillingExpr) != "" {
417+
billingModeMap[item.ModelName] = billing_setting.BillingModeTieredExpr
418+
billingExprMap[item.ModelName] = item.BillingExpr
419+
}
327420
if item.QuotaType == 1 {
328421
modelPriceMap[item.ModelName] = item.ModelPrice
329422
} else {
330423
modelRatioMap[item.ModelName] = item.ModelRatio
331424
// completionRatio 可能为 0,此时也直接赋值,保持与上游一致
332425
completionRatioMap[item.ModelName] = item.CompletionRatio
333426
}
427+
if item.CacheRatio != nil {
428+
cacheRatioMap[item.ModelName] = *item.CacheRatio
429+
}
430+
if item.CreateCacheRatio != nil {
431+
createCacheRatioMap[item.ModelName] = *item.CreateCacheRatio
432+
}
433+
if item.ImageRatio != nil {
434+
imageRatioMap[item.ModelName] = *item.ImageRatio
435+
}
436+
if item.AudioRatio != nil {
437+
audioRatioMap[item.ModelName] = *item.AudioRatio
438+
}
439+
if item.AudioCompletionRatio != nil {
440+
audioCompletionRatioMap[item.ModelName] = *item.AudioCompletionRatio
441+
}
334442
}
335443

336444
converted := make(map[string]any)
@@ -350,6 +458,21 @@ func FetchUpstreamRatios(c *gin.Context) {
350458
}
351459
converted["completion_ratio"] = compAny
352460
}
461+
if len(cacheRatioMap) > 0 {
462+
converted["cache_ratio"] = valueMap(cacheRatioMap)
463+
}
464+
if len(createCacheRatioMap) > 0 {
465+
converted["create_cache_ratio"] = valueMap(createCacheRatioMap)
466+
}
467+
if len(imageRatioMap) > 0 {
468+
converted["image_ratio"] = valueMap(imageRatioMap)
469+
}
470+
if len(audioRatioMap) > 0 {
471+
converted["audio_ratio"] = valueMap(audioRatioMap)
472+
}
473+
if len(audioCompletionRatioMap) > 0 {
474+
converted["audio_completion_ratio"] = valueMap(audioCompletionRatioMap)
475+
}
353476

354477
if len(modelPriceMap) > 0 {
355478
priceAny := make(map[string]any, len(modelPriceMap))
@@ -358,6 +481,12 @@ func FetchUpstreamRatios(c *gin.Context) {
358481
}
359482
converted["model_price"] = priceAny
360483
}
484+
if len(billingModeMap) > 0 {
485+
converted[billing_setting.BillingModeField] = valueMap(billingModeMap)
486+
}
487+
if len(billingExprMap) > 0 {
488+
converted[billing_setting.BillingExprField] = valueMap(billingExprMap)
489+
}
361490

362491
ch <- upstreamResult{Name: uniqueName, Data: converted}
363492
}(chn)
@@ -366,7 +495,7 @@ func FetchUpstreamRatios(c *gin.Context) {
366495
wg.Wait()
367496
close(ch)
368497

369-
localData := ratio_setting.GetExposedData()
498+
localData := getLocalPricingSyncData()
370499

371500
var testResults []dto.TestResult
372501
var successfulChannels []struct {
@@ -412,22 +541,16 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
412541

413542
allModels := make(map[string]struct{})
414543

415-
for _, ratioType := range ratioTypes {
416-
if localRatioAny, ok := localData[ratioType]; ok {
417-
if localRatio, ok := localRatioAny.(map[string]float64); ok {
418-
for modelName := range localRatio {
419-
allModels[modelName] = struct{}{}
420-
}
421-
}
544+
for _, field := range pricingSyncFields {
545+
for modelName := range valueMap(localData[field]) {
546+
allModels[modelName] = struct{}{}
422547
}
423548
}
424549

425550
for _, channel := range successfulChannels {
426-
for _, ratioType := range ratioTypes {
427-
if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok {
428-
for modelName := range upstreamRatio {
429-
allModels[modelName] = struct{}{}
430-
}
551+
for _, field := range pricingSyncFields {
552+
for modelName := range valueMap(channel.data[field]) {
553+
allModels[modelName] = struct{}{}
431554
}
432555
}
433556
}
@@ -438,10 +561,10 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
438561
for _, channel := range successfulChannels {
439562
confidenceMap[channel.name] = make(map[string]bool)
440563

441-
modelRatios, hasModelRatio := channel.data["model_ratio"].(map[string]any)
442-
completionRatios, hasCompletionRatio := channel.data["completion_ratio"].(map[string]any)
564+
modelRatios := valueMap(channel.data["model_ratio"])
565+
completionRatios := valueMap(channel.data["completion_ratio"])
443566

444-
if hasModelRatio && hasCompletionRatio {
567+
if len(modelRatios) > 0 && len(completionRatios) > 0 {
445568
// 遍历所有模型,检查是否满足不可信条件
446569
for modelName := range allModels {
447570
// 默认为可信
@@ -451,12 +574,10 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
451574
if modelRatioVal, ok := modelRatios[modelName]; ok {
452575
if completionRatioVal, ok := completionRatios[modelName]; ok {
453576
// 转换为float64进行比较
454-
if modelRatioFloat, ok := modelRatioVal.(float64); ok {
455-
if completionRatioFloat, ok := completionRatioVal.(float64); ok {
456-
if modelRatioFloat == 37.5 && completionRatioFloat == 1.0 {
457-
confidenceMap[channel.name][modelName] = false
458-
}
459-
}
577+
modelRatioFloat, modelRatioOK := asFloat64(modelRatioVal)
578+
completionRatioFloat, completionRatioOK := asFloat64(completionRatioVal)
579+
if modelRatioOK && completionRatioOK && nearlyEqual(modelRatioFloat, 37.5) && nearlyEqual(completionRatioFloat, 1.0) {
580+
confidenceMap[channel.name][modelName] = false
460581
}
461582
}
462583
}
@@ -470,14 +591,10 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
470591
}
471592

472593
for modelName := range allModels {
473-
for _, ratioType := range ratioTypes {
594+
for _, ratioType := range pricingSyncFields {
474595
var localValue interface{} = nil
475-
if localRatioAny, ok := localData[ratioType]; ok {
476-
if localRatio, ok := localRatioAny.(map[string]float64); ok {
477-
if val, exists := localRatio[modelName]; exists {
478-
localValue = val
479-
}
480-
}
596+
if val, exists := valueMap(localData[ratioType])[modelName]; exists {
597+
localValue = normalizeSyncValue(ratioType, val)
481598
}
482599

483600
upstreamValues := make(map[string]interface{})
@@ -488,16 +605,14 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
488605
for _, channel := range successfulChannels {
489606
var upstreamValue interface{} = nil
490607

491-
if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok {
492-
if val, exists := upstreamRatio[modelName]; exists {
493-
upstreamValue = val
494-
hasUpstreamValue = true
608+
if val, exists := valueMap(channel.data[ratioType])[modelName]; exists {
609+
upstreamValue = normalizeSyncValue(ratioType, val)
610+
hasUpstreamValue = true
495611

496-
if localValue != nil && !valuesEqual(localValue, val) {
497-
hasDifference = true
498-
} else if valuesEqual(localValue, val) {
499-
upstreamValue = "same"
500-
}
612+
if localValue != nil && !valuesEqual(localValue, upstreamValue) {
613+
hasDifference = true
614+
} else if valuesEqual(localValue, upstreamValue) {
615+
upstreamValue = "same"
501616
}
502617
}
503618
if upstreamValue == nil && localValue == nil {

setting/billing_setting/tiered_billing.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ import (
55

66
"github.com/QuantumNous/new-api/pkg/billingexpr"
77
"github.com/QuantumNous/new-api/setting/config"
8+
"github.com/samber/lo"
89
)
910

1011
const (
1112
BillingModeRatio = "ratio"
1213
BillingModeTieredExpr = "tiered_expr"
14+
BillingModeField = "billing_mode"
15+
BillingExprField = "billing_expr"
1316
)
1417

1518
// BillingSetting is managed by config.GlobalConfig.Register.
@@ -44,6 +47,25 @@ func GetBillingExpr(model string) (string, bool) {
4447
return expr, ok
4548
}
4649

50+
func GetBillingModeCopy() map[string]string {
51+
return lo.Assign(billingSetting.BillingMode)
52+
}
53+
54+
func GetBillingExprCopy() map[string]string {
55+
return lo.Assign(billingSetting.BillingExpr)
56+
}
57+
58+
func GetPricingSyncData(base map[string]any) map[string]any {
59+
extra := make(map[string]any, 2)
60+
if modes := GetBillingModeCopy(); len(modes) > 0 {
61+
extra[BillingModeField] = modes
62+
}
63+
if exprs := GetBillingExprCopy(); len(exprs) > 0 {
64+
extra[BillingExprField] = exprs
65+
}
66+
return lo.Assign(base, extra)
67+
}
68+
4769
// ---------------------------------------------------------------------------
4870
// Smoke test (called externally for validation before save)
4971
// ---------------------------------------------------------------------------

setting/ratio_setting/model_ratio.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,18 @@ func GetCompletionRatioCopy() map[string]float64 {
709709
return completionRatioMap.ReadAll()
710710
}
711711

712+
func GetImageRatioCopy() map[string]float64 {
713+
return imageRatioMap.ReadAll()
714+
}
715+
716+
func GetAudioRatioCopy() map[string]float64 {
717+
return audioRatioMap.ReadAll()
718+
}
719+
720+
func GetAudioCompletionRatioCopy() map[string]float64 {
721+
return audioCompletionRatioMap.ReadAll()
722+
}
723+
712724
// 转换模型名,减少渠道必须配置各种带参数模型
713725
func FormatMatchingModelName(name string) string {
714726

web/src/components/settings/ChannelSelectorModal.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,8 @@ const ChannelSelectorModal = forwardRef(
155155
onChange={handleTypeChange}
156156
style={{ width: 120 }}
157157
optionList={[
158-
{ label: 'ratio_config', value: 'ratio_config' },
159158
{ label: 'pricing', value: 'pricing' },
159+
{ label: 'ratio_config', value: 'ratio_config' },
160160
{ label: 'OpenRouter', value: 'openrouter' },
161161
{ label: 'custom', value: 'custom' },
162162
]}

0 commit comments

Comments
 (0)