diff --git a/controller/model_management_test.go b/controller/model_management_test.go index e973759147..d414e63373 100644 --- a/controller/model_management_test.go +++ b/controller/model_management_test.go @@ -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"}}}; diff --git a/controller/model_pricing_config.go b/controller/model_pricing_config.go index 7627fe9d34..a9511604bc 100644 --- a/controller/model_pricing_config.go +++ b/controller/model_pricing_config.go @@ -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"` diff --git a/model/legacy_dalle_pricing.go b/model/legacy_dalle_pricing.go new file mode 100644 index 0000000000..58bfd9b9e9 --- /dev/null +++ b/model/legacy_dalle_pricing.go @@ -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 +} diff --git a/model/model_pricing_config.go b/model/model_pricing_config.go index 79d72023c3..784c2e868a 100644 --- a/model/model_pricing_config.go +++ b/model/model_pricing_config.go @@ -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 { diff --git a/model/model_pricing_conversion.go b/model/model_pricing_conversion.go new file mode 100644 index 0000000000..2d241c1017 --- /dev/null +++ b/model/model_pricing_conversion.go @@ -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 +} diff --git a/router/api-router.go b/router/api-router.go index 432fab5598..18d37f92be 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -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) diff --git a/setting/ratio_setting/cache_ratio.go b/setting/ratio_setting/cache_ratio.go index 6e874b5bc5..179f76abdd 100644 --- a/setting/ratio_setting/cache_ratio.go +++ b/setting/ratio_setting/cache_ratio.go @@ -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 } diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 4d1846c79a..9f1ac744b1 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -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 }