mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 08:35:33 +08:00
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:
8 files changed
+880
-33
No files matched your search
@@ -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"}}};
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user