feat(pricing): convert legacy prices into expression drafts

Add read-only conversion and preview APIs using shared effective price resolution, provider metadata, and legacy DALL-E multipliers. Standardize preview payloads under data.effective with billing metadata.

Validated conversion paths on real SQLite 3.50.4, MySQL 8.0.46, and PostgreSQL 16.15 instances.
This commit is contained in:
CaIon committed 2026-09-11 21:44:54 +08:00
1 parent f256e40bcb
commit 25ec832faa
8 files changed
+880 -33

No files matched your search

+368
View File
@@ -14,8 +14,13 @@ import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/config"
"github.com/QuantumNous/new-api/setting/ratio_setting"
@@ -104,6 +109,369 @@ func modelManagementRequest(t *testing.T, handler gin.HandlerFunc, method, path
return recorder
}
func TestModelPricingConversionDatabaseMatrix(t *testing.T) {
previousQuota := common.QuotaPerUnit
common.QuotaPerUnit = 500000
t.Cleanup(func() { common.QuotaPerUnit = previousQuota })
for _, dialect := range []struct{ kind, env string }{{"sqlite", ""}, {"mysql", "TEST_MYSQL_DSN"}, {"postgres", "TEST_POSTGRES_DSN"}} {
t.Run(dialect.kind, func(t *testing.T) {
if dialect.env != "" && os.Getenv(dialect.env) == "" {
t.Skip("set " + dialect.env + " to run this database")
}
db := modelManagementDB(t, dialect.kind, os.Getenv(dialect.env))
contract, err := os.ReadFile("../pkg/billingexpr/testdata/frontend_simulation.json")
require.NoError(t, err)
var fixtures []struct {
Name, Expression string
Conversion *struct {
ModelName string `json:"model_name"`
Pricing model.PricingValues `json:"pricing"`
}
}
require.NoError(t, common.Unmarshal(contract, &fixtures))
for _, fixture := range fixtures {
if fixture.Conversion == nil {
continue
}
t.Run("frontend_contract/"+fixture.Name, func(t *testing.T) {
var response struct {
Success bool
Data model.ModelPricingConversion
}
modelManagementRequest(t, PreviewModelPricingConversion, http.MethodPost, "/api/option/model_pricing/convert", fixture.Conversion, &response)
require.True(t, response.Success)
assert.Equal(t, fixture.Expression, response.Data.Expression)
})
}
// The preview must resolve the draft, even when the running process
// still has a different saved output multiplier for that model.
require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{"conversion-defaults":99}`))
for _, tc := range []struct {
name string
draft model.PricingValues
expression, reason string
}{
{"conversion-defaults", model.PricingValues{"ModelRatio": float64(2)}, `tier("base", p * 4 + c * 4)`, ""},
{"gpt-4-32k", model.PricingValues{"ModelRatio": 30.0, "CacheRatio": 1.0}, `tier("base", p * 60 + c * 120)`, ""},
{"conversion-free-cache", model.PricingValues{"ModelRatio": 0.0, "CacheRatio": 0.0}, `tier("base", p * 0 + c * 0 + cr * 0)`, ""},
{"claude-3-7-sonnet-20250219", model.PricingValues{"ModelRatio": float64(1.5), "CacheRatio": float64(0.1), "CreateCacheRatio": float64(1.25)}, `tier("base", p * 3 + c * 15 + cr * 0.3 + cc * 3.75 + cc1h * 6)`, ""},
{"conversion-image-default", model.PricingValues{"ModelRatio": float64(2), "ImageRatio": float64(1)}, `tier("base", p * 4 + c * 4)`, ""},
{"conversion-image-free", model.PricingValues{"ModelRatio": float64(2), "ImageRatio": float64(0)}, `tier("base", p * 4 + c * 4 + cr * 4 + img * 0)`, ""},
{"deepseek-chat", model.PricingValues{"ModelRatio": float64(0.135), "CacheRatio": float64(0.25)}, `tier("base", p * 0.27 + c * 0.27 + cr * 0.0675)`, ""},
{"gpt-4o-custom", model.PricingValues{"ModelRatio": float64(2)}, `tier("base", p * 4 + c * 16)`, ""},
{"gemini-2.5-pro-custom", model.PricingValues{"ModelRatio": float64(2)}, `tier("base", p * 4 + c * 32)`, ""},
{"vendor/claude-sonnet-4", model.PricingValues{"ModelRatio": float64(2), "CompletionRatio": float64(0)}, `tier("base", p * 4 + c * 0 + cr * 4 + cc * 5 + cc1h * 8)`, ""},
{"conversion-custom", model.PricingValues{"ModelRatio": float64(2), "CompletionRatio": float64(3), "CacheRatio": float64(0), "CreateCacheRatio": float64(1.5), "ImageRatio": float64(2)}, `tier("base", p * 4 + c * 12 + cr * 0 + cc * 6 + img * 8)`, ""},
{"deepseek-reasoner", model.PricingValues{"ModelRatio": 0.275, "CacheRatio": 0.25}, `tier("base", p * 0.55 + c * 0.55 + cr * 0.1375)`, ""},
{"gpt-5.6-sol", model.PricingValues{"ModelRatio": float64(2), "CompletionRatio": float64(2)}, `tier("base", p * 4 + c * 8)`, ""},
{"gpt-5.5", model.PricingValues{"ModelRatio": float64(2), "CompletionRatio": float64(2)}, `tier("base", p * 4 + c * 8)`, ""},
{"gpt-6-astra", model.PricingValues{"ModelRatio": float64(2), "CompletionRatio": float64(2)}, `tier("base", p * 4 + c * 8)`, ""},
{"conversion-free", model.PricingValues{"ModelPrice": float64(0)}, `tier("request", fixed(0))`, ""},
{"conversion-fixed", model.PricingValues{"ModelPrice": float64(0.25), "ModelRatio": float64(7)}, `tier("request", fixed(0.25))`, ""},
{"gpt-4o-2024-05-13", model.PricingValues{"ModelRatio": float64(2), "CompletionRatio": float64(99)}, `tier("base", p * 4 + c * 12)`, ""},
{"gpt-image-2", model.PricingValues{"ModelPrice": float64(1)}, `tier("image", fixed(1)) * image_count`, ""},
{"qwen-image-3.0-pro", model.PricingValues{"ModelPrice": float64(1)}, `tier("image", fixed(1)) * image_count`, ""},
{"wan2.7-image-pro", model.PricingValues{"ModelPrice": float64(1)}, "", "Task pricing must be converted manually using the task usage schema."},
{"video-summarizer", model.PricingValues{"ModelPrice": float64(1)}, `tier("request", fixed(1))`, ""},
{"sora-transcript-helper", model.PricingValues{"ModelPrice": float64(1)}, `tier("request", fixed(1))`, ""},
{"gpt-realtime", model.PricingValues{"ModelRatio": float64(1)}, "", "Realtime pricing must be converted manually."},
{"conversion-audio", model.PricingValues{"ModelRatio": float64(1), "AudioRatio": float64(2)}, `tier("base", p * 2 + c * 2 + ai * 4 + ao * 4)`, ""},
} {
t.Run(tc.name, func(t *testing.T) {
before, err := model.GetModelPricingSnapshot([]string{tc.name})
require.NoError(t, err)
var response struct {
Success bool
Data model.ModelPricingConversion
}
modelManagementRequest(t, PreviewModelPricingConversion, http.MethodPost, "/api/option/model_pricing/convert", map[string]any{"model_name": tc.name, "pricing": tc.draft}, &response)
require.True(t, response.Success)
assert.Equal(t, tc.expression, response.Data.Expression)
assert.Equal(t, tc.reason, response.Data.UnsupportedReason)
if tc.name == "claude-3-7-sonnet-20250219" {
// Both providers include images in input; Claude reports cache
// separately and does not report an image token breakdown.
for _, usage := range []dto.Usage{
{PromptTokens: 1000, CompletionTokens: 100, UsageSemantic: "openai", PromptTokensDetails: dto.InputTokenDetails{CachedTokens: 200, CachedCreationTokens: 50, ImageTokens: 300}},
{PromptTokens: 750, CompletionTokens: 100, UsageSemantic: "anthropic", ClaudeCacheCreation5mTokens: 50, PromptTokensDetails: dto.InputTokenDetails{CachedTokens: 200, CachedCreationTokens: 50}},
} {
tokens := service.BuildTieredTokenParams(&usage, usage.UsageSemantic == "anthropic", billingexpr.UsedVars(response.Data.Expression))
cost, _, err := billingexpr.RunExpr(response.Data.Expression, tokens)
require.NoError(t, err)
assert.Equal(t, float64(750), tokens.P)
assert.Equal(t, 3997.5, cost, "image input must remain billed exactly once")
}
usage := dto.Usage{PromptTokens: 750, CompletionTokens: 100, UsageSemantic: "anthropic", ClaudeCacheCreation5mTokens: 30, ClaudeCacheCreation1hTokens: 20, PromptTokensDetails: dto.InputTokenDetails{CachedTokens: 200, CachedCreationTokens: 50}}
tokens := service.BuildTieredTokenParams(&usage, true, billingexpr.UsedVars(response.Data.Expression))
cost, _, err := billingexpr.RunExpr(response.Data.Expression, tokens)
require.NoError(t, err)
assert.Equal(t, 4042.5, cost, "mixed TTLs preserve both legacy cache-write prices")
}
if tc.name == "deepseek-chat" {
for _, usage := range []dto.Usage{
{PromptTokens: 1000, CompletionTokens: 100, PromptTokensDetails: dto.InputTokenDetails{CachedTokens: 200}},
{PromptTokens: 800, CompletionTokens: 100, UsageSemantic: "anthropic", PromptTokensDetails: dto.InputTokenDetails{CachedTokens: 200}},
} {
tokens := service.BuildTieredTokenParams(&usage, usage.UsageSemantic == "anthropic", billingexpr.UsedVars(response.Data.Expression))
cost, _, err := billingexpr.RunExpr(response.Data.Expression, tokens)
require.NoError(t, err)
assert.Equal(t, 256.5, cost, "cache reads remain billed across both response formats")
}
}
after, err := model.GetModelPricingSnapshot([]string{tc.name})
require.NoError(t, err)
assert.Equal(t, before.Entries, after.Entries, "preview must not write pricing")
if tc.expression == "" {
return
}
pricing := tc.draft
pricing["billing_setting.billing_mode"] = "tiered_expr"
pricing["billing_setting.billing_expr"] = tc.expression
change := model.ModelPricingChange{ModelName: tc.name, ExpectedVersion: before.Entries[0].Version, Pricing: pricing}
require.NoError(t, model.UpdateModelPricing([]model.ModelPricingChange{change}))
after, err = model.GetModelPricingSnapshot([]string{tc.name})
require.NoError(t, err)
assert.Equal(t, pricing, after.Entries[0].Configured)
assert.ErrorIs(t, model.UpdateModelPricing([]model.ModelPricingChange{change}), model.ErrModelPricingConflict)
change.ExpectedVersion = after.Entries[0].Version
change.Pricing["billing_setting.billing_mode"] = "ratio"
require.NoError(t, model.UpdateModelPricing([]model.ModelPricingChange{change}))
after, err = model.GetModelPricingSnapshot([]string{tc.name})
require.NoError(t, err)
assert.Equal(t, "ratio", after.Entries[0].Effective["billing_setting.billing_mode"])
if _, fixed := pricing["ModelPrice"]; !fixed {
var preview struct {
Success bool
Data model.ModelPricingDescription
}
modelManagementRequest(t, PreviewModelPricing, http.MethodPost, "/api/option/model_pricing/preview", map[string]any{"model_name": tc.name, "pricing": pricing}, &preview)
require.True(t, preview.Success)
assert.Equal(t, after.Entries[0].Effective, preview.Data.Effective)
require.NotEmpty(t, response.Data.Effective)
for field, value := range response.Data.Effective {
assert.Equal(t, value, preview.Data.Effective[field], "conversion preview field %s", field)
}
cache, _ := ratio_setting.GetCacheRatio(tc.name)
createCache, _ := ratio_setting.GetCreateCacheRatio(tc.name)
image, _ := ratio_setting.GetImageRatio(tc.name)
assert.Equal(t, ratio_setting.GetCompletionRatio(tc.name), preview.Data.Effective["CompletionRatio"])
assert.Equal(t, cache, preview.Data.Effective["CacheRatio"])
assert.Equal(t, createCache, preview.Data.Effective["CreateCacheRatio"])
assert.Equal(t, image, preview.Data.Effective["ImageRatio"])
}
})
}
require.NoError(t, (&model.Channel{Name: "Mapped image", Models: "conversion-alias", Type: 1, ModelMapping: common.GetPointer(`{"conversion-alias":"conversion-hop","conversion-hop":"gpt-image-2"}`)}).Insert())
preview, err := model.PreviewModelPricingConversion("conversion-alias", model.PricingValues{"ModelPrice": float64(1)})
require.NoError(t, err)
assert.Equal(t, `tier("image", fixed(1)) * image_count`, preview.Expression)
assert.Empty(t, preview.UnsupportedReason)
t.Run("cache_writes_follow_configured_prices_and_claude_names", func(t *testing.T) {
for _, tc := range []struct {
name string
configured *float64
mode model.CacheWriteMode
}{
{"unrecognized-model", nil, model.CacheWriteNone},
{"gpt-5.6-terra", nil, model.CacheWriteNone},
{"gpt-5.6-terra", common.GetPointer(1.25), model.CacheWriteStandard},
{"vendor/claude-sonnet-4-6-high", nil, model.CacheWriteClaudeTTL},
{"custom-CLAUDE-alias", common.GetPointer(0.0), model.CacheWriteClaudeTTL},
{"deepseek-reasoner", common.GetPointer(0.0), model.CacheWriteStandard},
{"custom-cache", common.GetPointer(1.5), model.CacheWriteStandard},
} {
t.Run(tc.name+"/"+string(tc.mode), func(t *testing.T) {
draft := model.PricingValues{"ModelRatio": 2.0, "CompletionRatio": 2.0, "CacheRatio": 0.25, "billing_setting.billing_mode": "ratio"}
if tc.configured != nil {
draft["CreateCacheRatio"] = *tc.configured
}
var response struct {
Success bool
Data model.ModelPricingConversion
}
modelManagementRequest(t, PreviewModelPricingConversion, http.MethodPost, "/api/option/model_pricing/convert", map[string]any{"model_name": tc.name, "pricing": draft}, &response)
require.True(t, response.Success)
require.NotEmpty(t, response.Data.Expression)
assert.Equal(t, tc.mode, response.Data.CacheWriteMode)
var prices struct {
Success bool
Data model.ModelPricingDescription
}
modelManagementRequest(t, PreviewModelPricing, http.MethodPost, "/api/option/model_pricing/preview", map[string]any{"model_name": tc.name, "pricing": draft}, &prices)
require.True(t, prices.Success)
assert.Equal(t, response.Data.Effective, prices.Data.Effective)
assert.Equal(t, response.Data.CacheWriteMode, prices.Data.CacheWriteMode)
vars := billingexpr.UsedVars(response.Data.Expression)
assert.Equal(t, tc.mode != model.CacheWriteNone, vars["cc"])
assert.Equal(t, tc.mode == model.CacheWriteClaudeTTL, vars["cc1h"])
if tc.configured != nil && *tc.configured == 0 {
cost, _, err := billingexpr.RunExpr(response.Data.Expression, billingexpr.TokenParams{CC: 100, CC1h: 100})
require.NoError(t, err)
assert.Zero(t, cost, "explicit zero cache prices must stay free")
}
})
}
// The billing name remains authoritative for cache terms.
require.NoError(t, (&model.Channel{Name: "Cache alias", Type: 1, Models: "opaque-cache-alias", ModelMapping: common.GetPointer(`{"opaque-cache-alias":"claude-sonnet-4-6"}`)}).Insert())
alias, err := model.PreviewModelPricingConversion("opaque-cache-alias", model.PricingValues{"ModelRatio": 2.0})
require.NoError(t, err)
assert.Equal(t, `tier("base", p * 4 + c * 4)`, alias.Expression)
})
t.Run("same_price_cache_reads_preserve_overlapping_categories", func(t *testing.T) {
for _, tc := range []struct {
field, term string
ratio float64
details dto.InputTokenDetails
cost, unpricedCacheCost float64
}{
{"CreateCacheRatio", "cc * 75", 1.25, dto.InputTokenDetails{CachedTokens: 800, CacheWriteTokens: 500}, 97500, 79500},
{"ImageRatio", "img * 120", 2, dto.InputTokenDetails{CachedTokens: 600, ImageTokens: 500}, 108000, 102000},
} {
t.Run(tc.field, func(t *testing.T) {
converted, err := model.PreviewModelPricingConversion("overlapping-cache", model.PricingValues{"ModelRatio": 30.0, "CompletionRatio": 2.0, "CacheRatio": 1.0, tc.field: tc.ratio})
require.NoError(t, err)
assert.Equal(t, `tier("base", p * 60 + c * 120 + cr * 60 + `+tc.term+`)`, converted.Expression)
usage := dto.Usage{PromptTokens: 1000, CompletionTokens: 100, PromptTokensDetails: tc.details}
params := service.BuildTieredTokenParams(&usage, false, billingexpr.UsedVars(converted.Expression))
cost, _, err := billingexpr.RunExpr(converted.Expression, params)
require.NoError(t, err)
assert.Equal(t, tc.cost, cost, "preserve the existing overlapping-category charge")
unpriced := `tier("base", p * 60 + c * 120 + ` + tc.term + `)`
params = service.BuildTieredTokenParams(&usage, false, billingexpr.UsedVars(unpriced))
cost, _, err = billingexpr.RunExpr(unpriced, params)
require.NoError(t, err)
assert.Equal(t, tc.unpricedCacheCost, cost, "existing expressions without cr retain their OpenAI normalization")
})
}
})
t.Run("media_prices_and_request_adjustments", func(t *testing.T) {
for _, quantity := range []string{"129", "-1", "0.5", "18446744073709551615"} {
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", strings.NewReader(`{"model":"z-image","parameters":{"n":`+quantity+`}}`))
ctx.Request.Header.Set("Content-Type", "application/json")
Relay(ctx, types.RelayFormatOpenAIImage)
assert.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
}
conflicting, err := model.PreviewModelPricingConversion("gemini-2.5-flash-conflict", model.PricingValues{"ModelRatio": 0.15, "AudioRatio": 10.0})
require.NoError(t, err)
assert.Contains(t, conflicting.UnsupportedReason, "Gemini and OpenAI audio prices differ")
aliName := "z-image-media-conversion"
require.NoError(t, (&model.Channel{Name: aliName, Models: aliName, Type: 17, Status: common.ChannelStatusEnabled}).Insert())
aliConversion, err := model.PreviewModelPricingConversion(aliName, model.PricingValues{"ModelPrice": 0.04})
require.NoError(t, err)
require.Empty(t, aliConversion.UnsupportedReason)
quantity := 3
aliCost, _, err := billingexpr.RunExprWithRequest(aliConversion.Expression, billingexpr.TokenParams{}, billingexpr.RequestInput{ImageCount: &quantity, Body: []byte(`{"parameters":{"prompt_extend":true}}`)})
require.NoError(t, err)
assert.Equal(t, 240000.0, aliCost)
require.NoError(t, (&model.Channel{Name: "Different image rules", Models: aliName, Type: 1, Status: common.ChannelStatusEnabled}).Insert())
conflicting, err = model.PreviewModelPricingConversion(aliName, model.PricingValues{"ModelPrice": 0.04})
require.NoError(t, err)
assert.Contains(t, conflicting.UnsupportedReason, "different image request multipliers")
for _, tc := range []struct {
name string
pricing model.PricingValues
usage dto.Usage
want float64
}{
{"gemini-2.5-flash", model.PricingValues{"ModelRatio": 0.15, "CompletionRatio": 2.0, "CacheRatio": 0.1}, dto.Usage{PromptTokens: 1000, CompletionTokens: 100, PromptTokensDetails: dto.InputTokenDetails{CachedTokens: 100, AudioTokens: 200}}, 473},
{"gemini-2.5-flash-zero", model.PricingValues{"ModelRatio": 0.0}, dto.Usage{PromptTokens: 1000, PromptTokensDetails: dto.InputTokenDetails{AudioTokens: 200}}, 200},
{"media-audio", model.PricingValues{"ModelRatio": 1.0, "CompletionRatio": 3.0, "AudioRatio": 2.0, "AudioCompletionRatio": 4.0}, dto.Usage{PromptTokens: 1000, CompletionTokens: 100, PromptTokensDetails: dto.InputTokenDetails{TextTokens: 800, AudioTokens: 200}, CompletionTokenDetails: dto.OutputTokenDetails{TextTokens: 50, AudioTokens: 50}}, 3500},
{"media-free-audio", model.PricingValues{"ModelRatio": 1.0, "AudioRatio": 0.0}, dto.Usage{PromptTokens: 1000, CompletionTokens: 100, PromptTokensDetails: dto.InputTokenDetails{AudioTokens: 200}, CompletionTokenDetails: dto.OutputTokenDetails{AudioTokens: 100}}, 1600},
{"media-audio-cache", model.PricingValues{"ModelRatio": 1.0, "CompletionRatio": 3.0, "AudioRatio": 2.0, "CacheRatio": 0.1}, dto.Usage{PromptTokens: 1000, CompletionTokens: 100, PromptTokensDetails: dto.InputTokenDetails{CachedTokens: 400, AudioTokens: 200}, CompletionTokenDetails: dto.OutputTokenDetails{AudioTokens: 50}}, 2900},
{"media-text-cache", model.PricingValues{"ModelRatio": 1.0, "CompletionRatio": 3.0, "AudioRatio": 2.0, "CacheRatio": 0.1}, dto.Usage{PromptTokens: 1000, CompletionTokens: 100, PromptTokensDetails: dto.InputTokenDetails{CachedTokens: 400}}, 1880},
{"gemini-2.5-flash-overlap", model.PricingValues{"ModelRatio": 1.0}, dto.Usage{PromptTokens: 1000, PromptTokensDetails: dto.InputTokenDetails{CachedTokens: 800, AudioTokens: 500}}, 2100},
} {
t.Run(tc.name, func(t *testing.T) {
converted, err := model.PreviewModelPricingConversion(tc.name, tc.pricing)
require.NoError(t, err)
require.Empty(t, converted.UnsupportedReason)
require.NotNil(t, converted.BillingDetails.AudioInputPrice)
params := service.BuildTieredTokenParams(&tc.usage, false, billingexpr.UsedVars(converted.Expression))
cost, _, err := billingexpr.RunExpr(converted.Expression, params)
require.NoError(t, err)
assert.InDelta(t, tc.want, cost, 1e-9)
})
}
converted, err := model.PreviewModelPricingConversion("dall-e-3", model.PricingValues{"ModelPrice": 0.04})
require.NoError(t, err)
require.Empty(t, converted.UnsupportedReason)
assert.NotContains(t, converted.Expression, "256x256")
assert.NotContains(t, converted.Expression, "512x512")
smallImage, err := model.PreviewModelPricingConversion("dall-e-2", model.PricingValues{"ModelPrice": 0.02})
require.NoError(t, err)
require.Empty(t, smallImage.UnsupportedReason)
assert.NotContains(t, smallImage.Expression, "1792")
smallCost, _, err := billingexpr.RunExprWithRequest(smallImage.Expression, billingexpr.TokenParams{}, billingexpr.RequestInput{Body: []byte(`{"size":"256x256"}`), ImageCount: &quantity})
require.NoError(t, err)
assert.Equal(t, 24000.0, smallCost)
for _, tc := range []struct {
size, quality string
count int
cost float64
}{
{"1024x1024", "standard", 3, 120000},
{"1024x1024", "hd", 2, 160000},
{"1024x1792", "hd", 3, 360000},
} {
body, err := common.Marshal(map[string]any{"size": tc.size, "quality": tc.quality})
require.NoError(t, err)
cost, _, err := billingexpr.RunExprWithRequest(converted.Expression, billingexpr.TokenParams{}, billingexpr.RequestInput{Body: body, ImageCount: &tc.count})
require.NoError(t, err)
assert.InDelta(t, tc.cost, cost, 1e-9)
}
for _, channelType := range []int{20, 58} {
name := fmt.Sprintf("gemini-3.5-flash-media-%d", channelType)
require.NoError(t, (&model.Channel{Name: name, Models: name, Type: channelType}).Insert())
require.NoError(t, db.Create(&model.Model{ModelName: name, Endpoints: `{"custom-text":"/v1/custom"}`}).Error)
converted, err := model.PreviewModelPricingConversion(name, model.PricingValues{"ModelRatio": 0.75, "CompletionRatio": 6.0, "CacheRatio": 0.1})
require.NoError(t, err)
assert.Empty(t, converted.UnsupportedReason)
assert.Equal(t, `tier("base", p * 1.5 + c * 9 + cr * 0.15)`, converted.Expression)
assert.Nil(t, converted.BillingDetails.AudioInputPrice)
}
})
require.NoError(t, db.Create(&model.Model{ModelName: "conversion-metadata", Endpoints: `{"image-generation":"/v1/images/generations"}`}).Error)
preview, err = model.PreviewModelPricingConversion("conversion-metadata", model.PricingValues{"ModelPrice": float64(1)})
require.NoError(t, err)
assert.Equal(t, `tier("image", fixed(1)) * image_count`, preview.Expression)
assert.Empty(t, preview.UnsupportedReason)
for _, tc := range []struct {
name, rule string
nameRule int
endpoint constant.EndpointType
}{
{"catalog-image-prefix-opaque", "catalog-image-prefix-", model.NameRulePrefix, constant.EndpointTypeImageGeneration},
{"opaque-catalog-image-suffix", "-catalog-image-suffix", model.NameRuleSuffix, constant.EndpointTypeImageGeneration},
{"before-catalog-image-middle-after", "catalog-image-middle", model.NameRuleContains, constant.EndpointTypeImageGeneration},
{"opaque-catalog-video", "opaque-catalog-video", model.NameRuleExact, constant.EndpointTypeOpenAIVideo},
} {
t.Run("endpoint_metadata/"+tc.name, func(t *testing.T) {
endpoints, err := common.Marshal(map[string]string{string(tc.endpoint): "/v1/fixture"})
require.NoError(t, err)
require.NoError(t, db.Create(&model.Model{ModelName: tc.rule, NameRule: tc.nameRule, Endpoints: string(endpoints)}).Error)
converted, err := model.PreviewModelPricingConversion(tc.name, model.PricingValues{"ModelPrice": 1.0})
require.NoError(t, err)
if tc.endpoint == constant.EndpointTypeOpenAIVideo {
assert.Empty(t, converted.Expression)
assert.Equal(t, "Video pricing must be converted manually.", converted.UnsupportedReason)
} else {
assert.Equal(t, `tier("image", fixed(1)) * image_count`, converted.Expression)
}
})
}
sora := &model.Channel{Name: "Opaque video route", Models: "opaque-video-route", Type: constant.ChannelTypeSora, Group: "a,b"}
require.NoError(t, sora.Insert())
video, err := model.PreviewModelPricingConversion("opaque-video-route", model.PricingValues{"ModelPrice": 1.0})
require.NoError(t, err)
assert.Equal(t, "Video pricing must be converted manually.", video.UnsupportedReason)
})
}
}
func TestModelManagementDatabaseMatrix(t *testing.T) {
_, err := jsplugin.DefaultRegistry.Register(`
export const meta = {apiVersion: 1, key: "model-management-task", name: "Management task fixture", version: "1.0.0", author: {name: "Test"}, models: ["matrix-task"], fetchMode: "per_task", usageSchema: {seconds: {type: "number", unit: "second"}}};
+38
View File
@@ -18,6 +18,44 @@ func GetModelPricingConfig(c *gin.Context) {
common.ApiSuccess(c, snapshot)
}
func PreviewModelPricingConversion(c *gin.Context) {
var request struct {
ModelName string `json:"model_name"`
Pricing model.PricingValues `json:"pricing"`
}
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
preview, err := model.PreviewModelPricingConversion(request.ModelName, request.Pricing)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, preview)
}
func PreviewModelPricing(c *gin.Context) {
var request struct {
ModelName string `json:"model_name"`
Pricing model.PricingValues `json:"pricing"`
}
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
preview, err := model.PreviewModelPricing(request.ModelName, request.Pricing)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, model.ModelPricingDescription{
Effective: preview,
CacheWriteMode: model.ResolveCacheWriteMode(request.ModelName, request.Pricing),
BillingDetails: model.ResolveLegacyBillingDetails(request.ModelName, preview, request.Pricing),
})
}
func UpdateModelPricingConfig(c *gin.Context) {
var request struct {
Changes []model.ModelPricingChange `json:"changes"`
+37
View File
@@ -0,0 +1,37 @@
package model
// Deprecated model compatibility: OpenAI removed DALL·E 2 and DALL·E 3 on
// 2026-05-12. Keep conversion of existing prices available for compatible
// upstreams, deriving every multiplier from the legacy DTO billing logic.
// https://developers.openai.com/api/docs/deprecations#2025-11-14-dalle-model-snapshots
import (
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
)
func legacyDallePricingRules(name string) []LegacyPricingRule {
if !strings.HasPrefix(name, "dall-e") {
return nil
}
var rules []LegacyPricingRule
if name != "dall-e-3" {
for _, size := range []string{"256x256", "512x512"} {
request := dto.ImageRequest{Model: name, Size: size}
rules = append(rules, LegacyPricingRule{`param("size") == "` + size + `"`, request.GetTokenCountMeta().ImagePriceRatio})
}
}
rectangle := dto.ImageRequest{Model: name, Size: "1024x1792"}
if name != "dall-e-2" && name != "dall-e" {
rules = append(rules, LegacyPricingRule{`param("size") == "1024x1792" || param("size") == "1792x1024"`, rectangle.GetTokenCountMeta().ImagePriceRatio})
}
if name == "dall-e-3" {
squareHD := dto.ImageRequest{Model: name, Size: "1024x1024", Quality: "hd"}
rectangleHD := dto.ImageRequest{Model: name, Size: rectangle.Size, Quality: "hd"}
rules = append(rules,
LegacyPricingRule{`param("quality") == "hd" && param("size") != "1024x1792" && param("size") != "1792x1024"`, squareHD.GetTokenCountMeta().ImagePriceRatio},
LegacyPricingRule{`param("quality") == "hd" && (param("size") == "1024x1792" || param("size") == "1792x1024")`, rectangleHD.GetTokenCountMeta().ImagePriceRatio / rectangle.GetTokenCountMeta().ImagePriceRatio})
}
return rules
}
+42 -6
View File
@@ -31,10 +31,10 @@ type ModelPricingChange struct {
}
type ModelPricingEntry struct {
ModelPricingDescription
ModelName string `json:"model_name"`
Version string `json:"version"`
Configured PricingValues `json:"configured"`
Effective PricingValues `json:"effective"`
UsageSchema map[string]jsplugin.UsageFieldSchema `json:"usage_schema,omitempty"`
}
@@ -121,7 +121,8 @@ func effectiveModelPricing(values map[string]map[string]any, name string) Pricin
result := modelPricingValues(values, name)
// Legacy wildcard aliases are resolved by the same normalization as relay.
alias := ratio_setting.FormatMatchingModelName(name)
for _, key := range modelPricingOptionKeys[:8] {
for _, key := range []string{"ModelPrice", "ModelRatio", "CompletionRatio", "AudioRatio", "AudioCompletionRatio"} {
delete(result, key)
if value, exists := values[key][alias]; exists {
result[key] = value
}
@@ -151,13 +152,45 @@ func effectiveModelPricing(values map[string]map[string]any, name string) Pricin
}
// Completion ratios include engine-enforced model defaults. Expose their
// effective value without persisting them into the editable configuration.
completion := ratio_setting.GetCompletionRatioInfo(name)
if _, exists := result["CompletionRatio"]; !exists || completion.Locked {
result["CompletionRatio"] = completion.Ratio
var configuredCompletion *float64
if ratio, exists := result["CompletionRatio"].(float64); exists {
configuredCompletion = &ratio
}
result["CompletionRatio"] = ratio_setting.ResolveCompletionRatio(name, configuredCompletion).Ratio
for key, fallback := range map[string]float64{
"CacheRatio": ratio_setting.DefaultCacheRatio,
"CreateCacheRatio": ratio_setting.DefaultCreateCacheRatio,
"ImageRatio": ratio_setting.DefaultImageRatio,
} {
if _, exists := result[key]; !exists {
result[key] = fallback
}
}
return result
}
// PreviewModelPricing resolves a complete editable draft using the same defaults
// as the saved-price display and conversion. It has no write side effects.
func PreviewModelPricing(name string, draft PricingValues) (PricingValues, error) {
if draft == nil {
return nil, errors.New("pricing draft is required")
}
if err := ValidateModelPricing(name, draft); err != nil {
return nil, err
}
values, _, _, err := readModelPricingMaps(DB)
if err != nil {
return nil, err
}
for _, key := range modelPricingOptionKeys {
delete(values[key], name)
if value, exists := draft[key]; exists {
values[key][name] = value
}
}
return effectiveModelPricing(values, name), nil
}
func GetModelPricingSnapshot(names []string) (*ModelPricingSnapshot, error) {
values, _, _, err := readModelPricingMaps(DB)
if err != nil {
@@ -182,7 +215,10 @@ func GetModelPricingSnapshot(names []string) (*ModelPricingSnapshot, error) {
generation := jsplugin.DefaultRegistry.Generation()
for _, name := range names {
configured := modelPricingValues(values, name)
entry := ModelPricingEntry{ModelName: name, Version: ModelPricingVersion(configured), Configured: configured, Effective: effectiveModelPricing(values, name)}
entry := ModelPricingEntry{ModelName: name, Version: ModelPricingVersion(configured), Configured: configured,
ModelPricingDescription: ModelPricingDescription{Effective: effectiveModelPricing(values, name)}}
entry.CacheWriteMode = ResolveCacheWriteMode(name, configured)
entry.BillingDetails = ResolveLegacyBillingDetails(name, entry.Effective, configured)
if plugin, ok := generation.GetByModel(name); ok {
entry.UsageSchema = plugin.Meta.UsageSchema
} else if target, ok := ResolveTaskModelAlias(generation, name); ok {
+368
View File
@@ -0,0 +1,368 @@
package model
import (
"errors"
"maps"
"math"
"slices"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
hostreasoning "github.com/QuantumNous/new-api/setting/reasoning"
"github.com/shopspring/decimal"
)
// ModelPricingConversion is a preview only. Saving still requires a versioned
// ModelPricingChange through the existing pricing transaction.
type ModelPricingConversion struct {
ModelPricingDescription
Expression string `json:"expression,omitempty"`
UnsupportedReason string `json:"unsupported_reason,omitempty"`
}
// ModelPricingDescription is shared by previews, conversions and snapshot
// entries. It describes effective prices without adding persisted settings.
type ModelPricingDescription struct {
Effective PricingValues `json:"effective,omitempty"`
CacheWriteMode CacheWriteMode `json:"cache_write_mode,omitempty"`
BillingDetails LegacyBillingDetails `json:"billing_details"`
}
func legacyInputPricePerMillion(ratio float64) (decimal.Decimal, error) {
if common.QuotaPerUnit <= 0 || math.IsInf(common.QuotaPerUnit, 0) || math.IsNaN(common.QuotaPerUnit) {
return decimal.Zero, errors.New("invalid quota unit")
}
if ratio < 0 || math.IsInf(ratio, 0) || math.IsNaN(ratio) {
return decimal.Zero, errors.New("input ratio must be finite and non-negative")
}
return decimal.NewFromFloat(ratio).Mul(decimal.NewFromInt(1_000_000)).Div(decimal.NewFromFloat(common.QuotaPerUnit)), nil
}
type LegacyPricingRule struct {
Condition string `json:"condition"`
Multiplier float64 `json:"multiplier"`
}
// LegacyBillingDetails is display-only. Absolute audio prices cannot always be
// represented by a ratio: Gemini audio can be billable with a zero text price.
type LegacyBillingDetails struct {
AudioInputPrice *float64 `json:"audio_input_price,omitempty"`
AudioOutputPrice *float64 `json:"audio_output_price,omitempty"`
ImageCount bool `json:"image_count,omitempty"`
RequestRules []LegacyPricingRule `json:"request_rules,omitempty"`
AudioTextBranches bool `json:"audio_text_branches,omitempty"`
InvalidAudioPrice bool `json:"-"`
ConflictingAudioPrices bool `json:"-"`
}
func ResolveLegacyBillingDetails(name string, effective, configured PricingValues) LegacyBillingDetails {
details := LegacyBillingDetails{}
if effective["billing_setting.billing_mode"] == billing_setting.BillingModeTieredExpr {
return details
}
if _, fixed := effective["ModelPrice"]; fixed {
details.ImageCount = common.IsImageGenerationModel(name)
details.RequestRules = legacyDallePricingRules(name)
return details
}
ratio, priced := effective["ModelRatio"].(float64)
if !priced {
return details
}
base, err := legacyInputPricePerMillion(ratio)
if err != nil {
return details
}
if price := operation_setting.GetGeminiInputAudioPricePerMillionTokens(name); price > 0 {
details.AudioInputPrice = &price
// Native Gemini settles with its dedicated input price; compatible
// chat enters the audio-ratio path when those ratios are configured.
// Do not silently replace one price with the other during migration.
audio, hasInput := effective["AudioRatio"].(float64)
output, hasOutput := effective["AudioCompletionRatio"].(float64)
if hasInput || hasOutput {
if !hasInput {
audio = 1
}
if !hasOutput {
output = 1
}
audioPrice := base.Mul(decimal.NewFromFloat(audio))
textOutput := base.Mul(decimal.NewFromFloat(effective["CompletionRatio"].(float64)))
details.ConflictingAudioPrices = !audioPrice.Equal(decimal.NewFromFloat(price)) || !audioPrice.Mul(decimal.NewFromFloat(output)).Equal(textOutput)
}
return details
}
audioRatio, hasAudio := effective["AudioRatio"].(float64)
audioCompletionRatio, hasAudioCompletion := effective["AudioCompletionRatio"].(float64)
if !hasAudio && !hasAudioCompletion {
return details
}
if !hasAudio {
audioRatio = 1
}
if !hasAudioCompletion {
audioCompletionRatio = 1
}
input := base.Mul(decimal.NewFromFloat(audioRatio))
inPrice, outPrice := input.InexactFloat64(), input.Mul(decimal.NewFromFloat(audioCompletionRatio)).InexactFloat64()
if math.IsInf(inPrice, 0) || math.IsInf(outPrice, 0) {
details.InvalidAudioPrice = true
return details
}
details.AudioInputPrice, details.AudioOutputPrice = &inPrice, &outPrice
details.AudioTextBranches = effective["CacheRatio"] != float64(1) || effective["ImageRatio"] != float64(1) || ResolveCacheWriteMode(name, configured) != CacheWriteNone
return details
}
type CacheWriteMode string
const (
CacheWriteNone CacheWriteMode = "none"
CacheWriteStandard CacheWriteMode = "standard"
CacheWriteClaudeTTL CacheWriteMode = "claude_ttl"
)
// ResolveCacheWriteMode describes display and migration only. A generic engine
// fallback is not configured cache-write pricing. Only Claude names inherit the
// legacy dual-TTL rule; other models retain their configured write price.
func ResolveCacheWriteMode(name string, configured PricingValues) CacheWriteMode {
if strings.Contains(strings.ToLower(name), "claude") {
return CacheWriteClaudeTTL
}
if _, exists := configured["CreateCacheRatio"]; exists {
return CacheWriteStandard
}
return CacheWriteNone
}
func PreviewModelPricingConversion(name string, draft PricingValues) (*ModelPricingConversion, error) {
if draft == nil {
return nil, errors.New("pricing draft is required")
}
if err := ValidateModelPricing(name, draft); err != nil {
return nil, err
}
if draft["billing_setting.billing_mode"] == billing_setting.BillingModeTieredExpr {
return &ModelPricingConversion{UnsupportedReason: "This model already uses an expression."}, nil
}
// The draft is the complete editable configuration: omitted fields inherit
// relay defaults rather than silently retaining discarded saved values.
legacyDraft := make(PricingValues, len(draft)+1)
maps.Copy(legacyDraft, draft)
legacyDraft["billing_setting.billing_mode"] = billing_setting.BillingModeRatio
effective, err := PreviewModelPricing(name, legacyDraft)
if err != nil {
return nil, err
}
if err := ValidateModelPricing(name, effective); err != nil {
return nil, err
}
_, fixedPrice := effective["ModelPrice"]
preview := &ModelPricingConversion{ModelPricingDescription: ModelPricingDescription{Effective: effective, BillingDetails: ResolveLegacyBillingDetails(name, effective, draft)}}
if preview.BillingDetails.InvalidAudioPrice {
return nil, errors.New("audio prices must be finite")
}
if preview.BillingDetails.ConflictingAudioPrices {
return &ModelPricingConversion{UnsupportedReason: "Gemini and OpenAI audio prices differ for this model. Use separate billing model names to convert them."}, nil
}
generation := jsplugin.DefaultRegistry.Generation()
if _, task := generation.GetByModel(name); task {
return &ModelPricingConversion{UnsupportedReason: "Task pricing must be converted manually using the task usage schema."}, nil
}
if _, task := ResolveTaskModelAlias(generation, name); task {
return &ModelPricingConversion{UnsupportedReason: "Task pricing must be converted manually using the task usage schema."}, nil
}
// Inspect non-secret routing metadata so aliases cannot disguise a special
// settlement path as an ordinary text model.
var channels []Channel
candidates := DB.Model(&Ability{}).Select("channel_id").Where("model = ?", name)
if err := DB.Select("type", "status", "models", "model_mapping").Where("id IN (?)", candidates).Find(&channels).Error; err != nil {
return nil, err
}
names := []string{name}
aliPromptExtend, otherImageRoute := false, false
for _, channel := range channels {
if !slices.Contains(channel.GetModels(), name) {
continue
}
if channel.Type == constant.ChannelTypeTaskPlugin {
return &ModelPricingConversion{UnsupportedReason: "Task pricing must be converted manually using the task usage schema."}, nil
}
if slices.Contains(common.GetEndpointTypesByChannelType(channel.Type, name), constant.EndpointTypeOpenAIVideo) {
return &ModelPricingConversion{UnsupportedReason: "Video pricing must be converted manually."}, nil
}
if channel.Status == common.ChannelStatusEnabled && channel.Type == constant.ChannelTypeOpenRouter && strings.Contains(strings.ToLower(name), "claude") && !fixedPrice {
defaultRatio, hasDefault := ratio_setting.GetDefaultModelRatioMap()[name]
if hasDefault && effective["ModelRatio"] == defaultRatio && effective["CreateCacheRatio"] != float64(1) {
return &ModelPricingConversion{UnsupportedReason: "This OpenRouter Claude price derives cache-write usage from upstream cost and must be converted manually."}, nil
}
}
upstream := name
if channel.ModelMapping != nil && *channel.ModelMapping != "" {
var mapping map[string]string
if err := common.UnmarshalJsonStr(*channel.ModelMapping, &mapping); err != nil {
return &ModelPricingConversion{UnsupportedReason: "The model routing configuration could not be verified."}, nil
}
// Relay also resolves reasoning-suffixed names at any mapping hop.
candidates := []string{name}
for _, target := range mapping {
candidates = append(candidates, target)
}
for _, candidate := range candidates {
if mapping[candidate] == "" {
mapping[candidate] = mapping[hostreasoning.BaseModelName(candidate)]
}
}
var cycle bool
upstream, cycle = followChannelModelMapping(mapping, name)
if cycle {
return &ModelPricingConversion{UnsupportedReason: "The model routing configuration could not be verified."}, nil
}
if upstream != name {
names = append(names, upstream)
}
}
if channel.Status == common.ChannelStatusEnabled {
if channel.Type == constant.ChannelTypeAli && strings.Contains(upstream, "z-image") {
aliPromptExtend = true
} else {
otherImageRoute = true
}
}
}
for _, modelName := range names {
lower := strings.ToLower(modelName)
if strings.Contains(lower, "realtime") {
return &ModelPricingConversion{UnsupportedReason: "Realtime pricing must be converted manually."}, nil
}
if fixedPrice && ResolveLegacyBillingDetails(modelName, effective, draft).ImageCount {
preview.BillingDetails.ImageCount = true
}
}
var metadata []Model
if err := DB.Select("model_name", "name_rule", "endpoints").
Where("model_name IN ? OR name_rule <> ?", names, NameRuleExact).
Where("endpoints <> ?", "").Find(&metadata).Error; err != nil {
return nil, err
}
for _, entry := range metadata {
if entry.Endpoints == "" || !slices.ContainsFunc(names, entry.MatchesName) {
continue
}
var endpoints map[string]any
if err := common.UnmarshalJsonStr(entry.Endpoints, &endpoints); err != nil {
return &ModelPricingConversion{UnsupportedReason: "The model routing configuration could not be verified."}, nil
}
for endpoint := range endpoints {
switch constant.EndpointType(endpoint) {
case constant.EndpointTypeImageGeneration:
if fixedPrice {
preview.BillingDetails.ImageCount = true
}
case constant.EndpointTypeOpenAIVideo:
return &ModelPricingConversion{UnsupportedReason: "Video pricing must be converted manually."}, nil
case constant.EndpointTypeOpenAI, constant.EndpointTypeOpenAIResponse, constant.EndpointTypeAnthropic, constant.EndpointTypeGemini, constant.EndpointTypeEmbeddings, constant.EndpointTypeJinaRerank:
// These endpoints use the ordinary token/fixed-request settlement.
}
}
}
var expression string
if fixedPrice {
price := effective["ModelPrice"].(float64)
expression = `tier("request", fixed(` + decimal.NewFromFloat(price).String() + `))`
if preview.BillingDetails.ImageCount {
if aliPromptExtend && otherImageRoute {
return &ModelPricingConversion{UnsupportedReason: "This model has different image request multipliers across channels. Use separate billing model names to convert them."}, nil
}
expression = `tier("image", fixed(` + decimal.NewFromFloat(price).String() + `)) * image_count`
if aliPromptExtend {
preview.BillingDetails.RequestRules = append(preview.BillingDetails.RequestRules, LegacyPricingRule{`param("parameters.prompt_extend") == true`, common.ZImagePromptExtendMultiplier})
}
for _, rule := range preview.BillingDetails.RequestRules {
expression += ` * (` + rule.Condition + ` ? ` + decimal.NewFromFloat(rule.Multiplier).String() + ` : 1)`
}
}
} else {
ratio, exists := effective["ModelRatio"].(float64)
if !exists {
return &ModelPricingConversion{UnsupportedReason: "Configure an input price before converting this model."}, nil
}
preview.CacheWriteMode = ResolveCacheWriteMode(name, draft)
base, err := legacyInputPricePerMillion(ratio)
if err != nil {
return nil, err
}
// Cache reads can overlap other separately priced input categories.
// Keep cr in that case: clamping the input remainder makes folding it
// into p non-equivalent. A zero base price is equivalent either way.
ordinaryAudio := preview.BillingDetails.AudioOutputPrice != nil
if ordinaryAudio && preview.CacheWriteMode != CacheWriteNone {
preview.BillingDetails.AudioTextBranches = true
}
mergeCacheRead := base.IsZero() || preview.CacheWriteMode == CacheWriteNone && effective["ImageRatio"].(float64) == 1 && (preview.BillingDetails.AudioInputPrice == nil || ordinaryAudio)
var expressionBody strings.Builder
expressionBody.WriteString(`tier("base", p * `)
expressionBody.WriteString(base.String())
// Retain explicit zero coefficients for included categories. Only
// Claude names inherit the relay's 1h / 5m factor of 6 / 3.75.
for _, lane := range []struct {
variable, key string
multiplier float64
}{
{"c", "CompletionRatio", 1},
{"cr", "CacheRatio", 1},
{"cc", "CreateCacheRatio", 1},
{"cc1h", "CreateCacheRatio", 6 / 3.75},
{"img", "ImageRatio", 1},
} {
if ordinaryAudio && !preview.BillingDetails.AudioTextBranches && lane.variable != "c" {
continue
}
if lane.variable == "cc" && preview.CacheWriteMode == CacheWriteNone ||
lane.variable == "cc1h" && preview.CacheWriteMode != CacheWriteClaudeTTL {
continue
}
multiplier := effective[lane.key].(float64)
// Same-price input categories stay in p. Keep distinct prices,
// including explicit zero, as independently priced terms.
if (lane.variable == "img" || lane.variable == "cr" && mergeCacheRead) && multiplier == 1 {
continue
}
expressionBody.WriteString(" + " + lane.variable + " * ")
expressionBody.WriteString(base.Mul(decimal.NewFromFloat(multiplier)).Mul(decimal.NewFromFloat(lane.multiplier)).String())
}
for _, lane := range []struct {
variable string
price *float64
}{
{"ai", preview.BillingDetails.AudioInputPrice},
{"ao", preview.BillingDetails.AudioOutputPrice},
} {
if lane.price != nil && !preview.BillingDetails.AudioTextBranches {
expressionBody.WriteString(" + " + lane.variable + " * " + decimal.NewFromFloat(*lane.price).String())
}
}
expressionBody.WriteString(")")
expression = expressionBody.String()
if ordinaryAudio && preview.BillingDetails.AudioTextBranches {
completion := base.Mul(decimal.NewFromFloat(effective["CompletionRatio"].(float64)))
// The audio settlement ignores cache/image ratios. Use the full
// input total here because variables in the text-only branch still
// participate in AST-based token normalization.
expression = `(ai > 0 || ao > 0) ? tier("audio", max(len - ai, 0) * ` + base.String() + ` + c * ` + completion.String() + ` + ai * ` + decimal.NewFromFloat(*preview.BillingDetails.AudioInputPrice).String() + ` + ao * ` + decimal.NewFromFloat(*preview.BillingDetails.AudioOutputPrice).String() + `) : ` + expression
}
}
if err := billing_setting.SmokeTestExpr(expression); err != nil {
return nil, err
}
preview.Expression = expression
return preview, nil
}
+2
View File
@@ -207,6 +207,8 @@ func SetApiRouter(router *gin.Engine) {
optionRoute.PUT("/", controller.UpdateOption)
optionRoute.GET("/model_pricing", controller.GetModelPricingConfig)
optionRoute.PATCH("/model_pricing", controller.UpdateModelPricingConfig)
optionRoute.POST("/model_pricing/convert", controller.PreviewModelPricingConversion)
optionRoute.POST("/model_pricing/preview", controller.PreviewModelPricing)
optionRoute.POST("/payment_compliance", controller.ConfirmPaymentCompliance)
optionRoute.GET("/channel_affinity_cache", controller.GetChannelAffinityCacheStats)
optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache)
+7 -2
View File
@@ -4,6 +4,11 @@ import (
"github.com/QuantumNous/new-api/types"
)
const (
DefaultCacheRatio = 1.0
DefaultCreateCacheRatio = 1.25
)
var defaultCacheRatio = map[string]float64{
"gemini-3-flash-preview": 0.1,
"gemini-3-pro-preview": 0.1,
@@ -159,7 +164,7 @@ func UpdateCreateCacheRatioByJSONString(jsonStr string) error {
func GetCacheRatio(name string) (float64, bool) {
ratio, ok := cacheRatioMap.Get(name)
if !ok {
return 1, false // Default to 1 if not found
return DefaultCacheRatio, false
}
return ratio, true
}
@@ -167,7 +172,7 @@ func GetCacheRatio(name string) (float64, bool) {
func GetCreateCacheRatio(name string) (float64, bool) {
ratio, ok := createCacheRatioMap.Get(name)
if !ok {
return 1.25, false // Default to 1.25 if not found
return DefaultCreateCacheRatio, false
}
return ratio, true
}
+18 -25
View File
@@ -434,21 +434,7 @@ func UpdateCompletionRatioByJSONString(jsonStr string) error {
}
func GetCompletionRatio(name string) float64 {
name = FormatMatchingModelName(name)
if strings.Contains(name, "/") {
if ratio, ok := completionRatioMap.Get(name); ok {
return ratio
}
}
hardCodedRatio, contain := getHardcodedCompletionModelRatio(name)
if contain {
return hardCodedRatio
}
if ratio, ok := completionRatioMap.Get(name); ok {
return ratio
}
return hardCodedRatio
return GetCompletionRatioInfo(name).Ratio
}
type CompletionRatioInfo struct {
@@ -458,14 +444,19 @@ type CompletionRatioInfo struct {
func GetCompletionRatioInfo(name string) CompletionRatioInfo {
name = FormatMatchingModelName(name)
var configured *float64
if ratio, ok := completionRatioMap.Get(name); ok {
configured = &ratio
}
return ResolveCompletionRatio(name, configured)
}
if strings.Contains(name, "/") {
if ratio, ok := completionRatioMap.Get(name); ok {
return CompletionRatioInfo{
Ratio: ratio,
Locked: false,
}
}
// ResolveCompletionRatio applies relay's enforced and fallback ratios to a
// configuration snapshot or draft without consulting mutable saved settings.
func ResolveCompletionRatio(name string, configured *float64) CompletionRatioInfo {
name = FormatMatchingModelName(name)
if strings.Contains(name, "/") && configured != nil {
return CompletionRatioInfo{Ratio: *configured}
}
hardCodedRatio, locked := getHardcodedCompletionModelRatio(name)
@@ -476,9 +467,9 @@ func GetCompletionRatioInfo(name string) CompletionRatioInfo {
}
}
if ratio, ok := completionRatioMap.Get(name); ok {
if configured != nil {
return CompletionRatioInfo{
Ratio: ratio,
Ratio: *configured,
Locked: false,
}
}
@@ -664,10 +655,12 @@ func UpdateImageRatioByJSONString(jsonStr string) error {
return types.LoadFromJsonString(imageRatioMap, jsonStr)
}
const DefaultImageRatio = 1.0
func GetImageRatio(name string) (float64, bool) {
ratio, ok := imageRatioMap.Get(name)
if !ok {
return 1, false // Default to 1 if not found
return DefaultImageRatio, false
}
return ratio, true
}