package service import ( "fmt" "math" "net/http/httptest" "os" "testing" "time" "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" relaycommon "github.com/QuantumNous/new-api/relay/common" relayconstant "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/relaykit/dto" "github.com/QuantumNous/new-api/relaykit/relayconvert" "github.com/QuantumNous/new-api/relaykit/types" "github.com/QuantumNous/new-api/setting/operation_setting" hosttypes "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/mysql" "gorm.io/driver/postgres" "gorm.io/gorm" ) // The configured DSNs must point at isolated test databases. Each dialect runs // the real reservation, settlement and log paths with the same billing cases. func TestFixedPriceBillingDatabaseMatrix(t *testing.T) { for _, dialect := range []struct { name common.DatabaseType env string logEnv string }{ {common.DatabaseTypeSQLite, "", ""}, {common.DatabaseTypeMySQL, "TEST_FIXED_MYSQL_DSN", "TEST_FIXED_MYSQL_LOG_DSN"}, {common.DatabaseTypePostgreSQL, "TEST_FIXED_POSTGRES_DSN", "TEST_FIXED_POSTGRES_LOG_DSN"}, } { t.Run(string(dialect.name), func(t *testing.T) { var driver gorm.Dialector = sqlite.Open(":memory:") if dialect.env != "" { dsn := os.Getenv(dialect.env) if dsn == "" { t.Skip(dialect.env + " is not configured") } if dialect.name == common.DatabaseTypeMySQL { driver = mysql.Open(dsn) } else { driver = postgres.New(postgres.Config{DSN: dsn, PreferSimpleProtocol: true}) } } db, err := gorm.Open(driver, &gorm.Config{}) require.NoError(t, err) sqlDB, err := db.DB() require.NoError(t, err) sqlDB.SetMaxOpenConns(1) t.Cleanup(func() { require.NoError(t, sqlDB.Close()) }) logDB := db if logDSN := os.Getenv(dialect.logEnv); logDSN != "" || dialect.name == common.DatabaseTypeSQLite { var logDriver gorm.Dialector = sqlite.Open(":memory:") if dialect.name == common.DatabaseTypeMySQL { logDriver = mysql.Open(logDSN) } else if dialect.name == common.DatabaseTypePostgreSQL { logDriver = postgres.New(postgres.Config{DSN: logDSN, PreferSimpleProtocol: true}) } logDB, err = gorm.Open(logDriver, &gorm.Config{}) require.NoError(t, err) logSQL, err := logDB.DB() require.NoError(t, err) logSQL.SetMaxOpenConns(1) t.Cleanup(func() { require.NoError(t, logSQL.Close()) }) } oldDB, oldLogDB := model.DB, model.LOG_DB oldMainType, oldLogType := common.MainDatabaseType(), common.LogDatabaseType() model.DB, model.LOG_DB = db, logDB common.SetDatabaseTypes(dialect.name, dialect.name) t.Cleanup(func() { model.DB, model.LOG_DB = oldDB, oldLogDB; common.SetDatabaseTypes(oldMainType, oldLogType) }) require.NoError(t, db.AutoMigrate(&model.User{}, &model.Token{}, &model.Channel{})) require.NoError(t, logDB.AutoMigrate(&model.Log{})) versionQuery := "select version()" if dialect.name == common.DatabaseTypeSQLite { versionQuery = "select sqlite_version()" } var version string require.NoError(t, db.Raw(versionQuery).Scan(&version).Error) t.Logf("database: %s", version) runFixedPriceAccountingCases(t, db, logDB) t.Run("claude cache read compatibility", func(t *testing.T) { runClaudeCacheReadAccountingCases(t, db, logDB) }) }) } } func claudeCacheReadTestInfo() *relaycommon.RelayInfo { return &relaycommon.RelayInfo{ OriginModelName: "deepseek-v4.1-flash", RelayFormat: types.RelayFormatClaude, ChannelMeta: &relaycommon.ChannelMeta{ ChannelId: 22, UpstreamModelName: "deepseek-v4.1-flash", ChannelOtherSettings: dto.ChannelOtherSettings{ClaudeInputIncludesCacheReadModels: []string{"deepseek-v4.1-flash"}}, }, PriceData: hosttypes.PriceData{ModelRatio: 1, CompletionRatio: 4, CacheRatio: 0.02, CacheCreationRatio: 1.25, GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 0.162}}, StartTime: time.Now(), } } func TestClaudeCacheReadCompatibility(t *testing.T) { for _, tc := range []struct { name string prompt, cached, output int wantQuota int }{ {"screenshot 1", 275773, 275200, 287, 1170}, {"screenshot 2", 275328, 274944, 2117, 2325}, {"screenshot 3", 275049, 270336, 3926, 4183}, {"screenshot 4", 270431, 270208, 58, 949}, } { t.Run(tc.name, func(t *testing.T) { info := claudeCacheReadTestInfo() original := &dto.Usage{BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{ InputTokens: tc.prompt, CacheReadInputTokens: tc.cached, OutputTokens: tc.output, })} before, err := common.Marshal(original) require.NoError(t, err) canonical := effectiveBillingUsage(original) adjusted, audit := normalizeClaudeCacheReadUsage(info, canonical) require.NotNil(t, audit) assert.Equal(t, "applied", audit.Status) assert.Equal(t, tc.prompt, audit.OriginalInputTokens) assert.Equal(t, tc.prompt-tc.cached, adjusted.PromptTokens) assert.Equal(t, tc.prompt, adjusted.InputTokens) assert.Equal(t, tc.prompt-tc.cached, adjusted.BillingUsage.ClaudeUsage.InputTokens) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) assert.Equal(t, tc.wantQuota, calculateTextQuotaSummary(ctx, info, adjusted).Quota) params := BuildTieredTokenParams(adjusted, true, billingexpr.UsedVars("p * 2 + cr * 0.04 + c * 8")) assert.Equal(t, float64(tc.prompt), params.Len) assert.Equal(t, float64(tc.prompt-tc.cached), params.P) cost, _, err := billingexpr.RunExprWithRequest("p * 2 + cr * 0.04 + c * 8", params, billingexpr.RequestInput{}) require.NoError(t, err) assert.Equal(t, tc.wantQuota, common.QuotaRound(cost/1_000_000*common.QuotaPerUnit*0.162)) withoutSeparateCache := BuildTieredTokenParams(adjusted, true, billingexpr.UsedVars("p * 2 + c * 8")) assert.Equal(t, float64(tc.prompt), withoutSeparateCache.P) again, _ := normalizeClaudeCacheReadUsage(info, effectiveBillingUsage(original)) assert.Equal(t, adjusted, again, "re-reading the same upstream snapshot must not accumulate corrections") after, err := common.Marshal(original) require.NoError(t, err) assert.Equal(t, before, after) assert.Equal(t, tc.prompt, canonical.PromptTokens) }) } } func TestClaudeCacheReadCompatibilityBoundaries(t *testing.T) { for _, tc := range []struct { name string prompt, cached, creation int estimated, disabled, openAI bool model, returnedModel, status string wantPrompt, wantInput int }{ {name: "disabled standard Anthropic", prompt: 100, cached: 80, disabled: true, wantPrompt: 100, wantInput: 180}, {name: "another model", prompt: 100, cached: 80, model: "deepseek-v4-pro", wantPrompt: 100, wantInput: 180}, {name: "exact names only", prompt: 100, cached: 80, model: "deepseek-v4.1-flash-extra", wantPrompt: 100, wantInput: 180}, {name: "returned model cannot opt in", prompt: 100, cached: 80, model: "other", returnedModel: "deepseek-v4.1-flash", wantPrompt: 100, wantInput: 180}, {name: "returned model cannot opt out", prompt: 100, cached: 80, returnedModel: "other", status: "applied", wantPrompt: 20, wantInput: 100}, {name: "OpenAI snapshot remains authoritative", prompt: 100, cached: 80, openAI: true, wantPrompt: 100, wantInput: 100}, {name: "estimated usage", prompt: 100, cached: 80, estimated: true, wantPrompt: 100, wantInput: 180}, {name: "zero cache", prompt: 100, wantPrompt: 100, wantInput: 100}, {name: "full cache", prompt: 100, cached: 100, status: "applied", wantInput: 100}, {name: "cache writes remain separate", prompt: 100, cached: 80, creation: 10, status: "applied", wantPrompt: 20, wantInput: 110}, {name: "negative prompt", prompt: -1, cached: 80, status: "invalid_usage", wantPrompt: -1, wantInput: 79}, {name: "negative cache", prompt: 100, cached: -1, status: "invalid_usage", wantPrompt: 100, wantInput: 99}, {name: "cache exceeds input", prompt: 100, cached: 101, status: "invalid_usage", wantPrompt: 100, wantInput: 201}, } { t.Run(tc.name, func(t *testing.T) { info := claudeCacheReadTestInfo() if tc.disabled { info.ChannelOtherSettings.ClaudeInputIncludesCacheReadModels = nil } if tc.model != "" { info.UpstreamModelName = tc.model } if tc.returnedModel != "" { info.ObserveResponseModel(tc.returnedModel) info.UpstreamModelName = tc.returnedModel } usage := &dto.Usage{BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{ InputTokens: tc.prompt, CacheReadInputTokens: tc.cached, CacheCreationInputTokens: tc.creation, OutputTokens: 1, })} if tc.openAI { usage.BillingUsage = dto.NewOpenAIChatBillingUsage(&dto.Usage{PromptTokens: tc.prompt, CompletionTokens: 1, PromptTokensDetails: dto.InputTokenDetails{CachedTokens: tc.cached}}) } usage.BillingUsage.Estimated = tc.estimated canonical := effectiveBillingUsage(usage) adjusted, audit := normalizeClaudeCacheReadUsage(info, canonical) assert.Equal(t, tc.wantPrompt, adjusted.PromptTokens) assert.Equal(t, tc.wantInput, adjusted.InputTokens) if tc.status == "" { assert.Nil(t, audit) assert.Same(t, canonical, adjusted) } else { require.NotNil(t, audit) assert.Equal(t, tc.status, audit.Status) } assert.Equal(t, tc.creation, adjusted.PromptTokensDetails.CacheCreationTokensTotal()) }) } } func runClaudeCacheReadAccountingCases(t *testing.T, db, logDB *gorm.DB) { t.Helper() for _, stream := range []bool{false, true} { for _, expression := range []bool{false, true} { t.Run(fmt.Sprintf("stream=%t expression=%t", stream, expression), func(t *testing.T) { const initialQuota = 1_000_000 info := claudeCacheReadTestInfo() channel := model.Channel{Name: "cache-read-test", Key: "unused", Status: common.ChannelStatusEnabled} channel.SetOtherSettings(info.ChannelOtherSettings) require.NoError(t, db.Create(&channel).Error) user := model.User{Username: fmt.Sprintf("cache_read_%t_%t", stream, expression), Quota: initialQuota, Status: common.UserStatusEnabled} require.NoError(t, db.Create(&user).Error) token := model.Token{UserId: user.Id, Key: fmt.Sprintf("cache-read-%t-%t", stream, expression), RemainQuota: initialQuota, Status: common.TokenStatusEnabled} require.NoError(t, db.Create(&token).Error) t.Cleanup(func() { require.NoError(t, logDB.Where("user_id = ?", user.Id).Delete(&model.Log{}).Error) require.NoError(t, db.Unscoped().Delete(&token).Error) require.NoError(t, db.Unscoped().Delete(&user).Error) require.NoError(t, db.Delete(&channel).Error) }) require.NoError(t, db.First(&channel, channel.Id).Error) settings := channel.GetOtherSettings() settings.AllowSpeed = true channel.SetOtherSettings(settings) require.NoError(t, db.Model(&channel).Update("settings", channel.OtherSettings).Error) require.NoError(t, db.First(&channel, channel.Id).Error) assert.True(t, channel.GetOtherSettings().AllowSpeed) assert.Equal(t, []string{"deepseek-v4.1-flash"}, channel.GetOtherSettings().ClaudeInputIncludesCacheReadModels) info.ChannelOtherSettings = channel.GetOtherSettings() info.ChannelId, info.UserId, info.TokenId, info.TokenKey = channel.Id, user.Id, token.Id, token.Key info.UserGroup, info.UsingGroup = "default", "default" info.UserSetting.BillingPreference = "wallet_only" info.ForcePreConsume, info.IsStream = true, stream if expression { const expr = `tier("base", p * 2 + cr * 0.04 + c * 8)` info.TieredBillingSnapshot = &billingexpr.BillingSnapshot{BillingMode: "tiered_expr", ExprString: expr, ExprHash: billingexpr.ExprHashString(expr), QuotaPerUnit: common.QuotaPerUnit, GroupRatio: 0.162} } ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) ctx.Request = httptest.NewRequest("POST", "/v1/messages", nil) require.Nil(t, PreConsumeBilling(ctx, 50_000, info)) wireUsage := &dto.ClaudeUsage{InputTokens: 275328, CacheReadInputTokens: 274944, OutputTokens: 2117} usage := &dto.Usage{BillingUsage: dto.NewClaudeMessagesBillingUsage(wireUsage)} if stream { state := &relayconvert.ClaudeResponseInfo{Usage: &dto.Usage{}} relayconvert.FormatClaudeResponseInfo(&dto.ClaudeResponse{Type: "message_start", Message: &dto.ClaudeMediaMessage{Usage: &dto.ClaudeUsage{InputTokens: 275328}}}, nil, state) relayconvert.FormatClaudeResponseInfo(&dto.ClaudeResponse{Type: "message_delta", Usage: wireUsage}, nil, state) relayconvert.FinalizeClaudeStreamBillingUsage(state) usage = state.Usage } PostTextConsumeQuota(ctx, info, usage, nil) require.NoError(t, db.First(&user, user.Id).Error) require.NoError(t, db.First(&token, token.Id).Error) require.NoError(t, db.First(&channel, channel.Id).Error) assert.Equal(t, initialQuota-2325, user.Quota) assert.Equal(t, initialQuota-2325, token.RemainQuota) assert.Equal(t, 2325, user.UsedQuota) assert.Equal(t, int64(2325), channel.UsedQuota) var log model.Log require.NoError(t, logDB.Where("user_id = ?", user.Id).Take(&log).Error) assert.Equal(t, 2325, log.Quota) assert.Equal(t, 384, log.PromptTokens) var other struct { Admin struct { Adjustment claudeCacheReadAdjustment `json:"cache_read_adjustment"` } `json:"admin_info"` } require.NoError(t, common.UnmarshalJsonStr(log.Other, &other)) assert.Equal(t, "applied", other.Admin.Adjustment.Status) assert.Equal(t, 275328, other.Admin.Adjustment.OriginalInputTokens) assert.Equal(t, 384, other.Admin.Adjustment.InputTokens) assert.Equal(t, 275328, wireUsage.InputTokens) }) } } } func runFixedPriceAccountingCases(t *testing.T, db, logDB *gorm.DB) { t.Helper() const mixed = `len <= 32000 ? tier("short", fixed(0.01)) : tier("long", p * 2)` const flat = `tier("request", fixed(0.01))` const startingQuota = 2_000_000 const imageExpression = `tier("standard", p * 5 + cr * 1.25 + img * 8 + img_cr * 2 + c * 30)` imageUsage := &dto.Usage{PromptTokens: 1000, CompletionTokens: 100, TotalTokens: 1100, PromptTokensDetails: dto.InputTokenDetails{CachedTokens: 300, ImageTokens: 600, CachedTokensDetails: &dto.CachedTokenDetails{ImageTokens: common.GetPointer(200)}}} operation_setting.SetToolPriceForTest("fixed_billing_tool", 4) t.Cleanup(func() { operation_setting.DeleteToolPriceForTest("fixed_billing_tool") }) for index, tc := range []struct { name, expression string estimate int usage *dto.Usage audio, stream, refund, insufficient, tool bool realtime, reserveInsufficient bool wallet, outboundImages int groupRatio float64 want int unit billingexpr.BillingUnit requestedImages, actualImages int }{ {name: "missing usage charges once", expression: flat, want: 5000, unit: billingexpr.BillingUnitRequest}, {name: "zero usage charges once", expression: flat, usage: &dto.Usage{}, want: 5000, unit: billingexpr.BillingUnitRequest}, {name: "stream charges once", expression: flat, stream: true, usage: &dto.Usage{PromptTokens: 100, CompletionTokens: 20, TotalTokens: 120}, want: 5000, unit: billingexpr.BillingUnitRequest}, {name: "audio zero usage charges once", expression: flat, audio: true, usage: &dto.Usage{}, want: 5000, unit: billingexpr.BillingUnitRequest}, {name: "audio missing usage charges once", expression: flat, audio: true, want: 5000, unit: billingexpr.BillingUnitRequest}, {name: "token reservation refunds to fixed price", expression: mixed, estimate: 50000, usage: &dto.Usage{PromptTokens: 100, TotalTokens: 100}, want: 5000, unit: billingexpr.BillingUnitRequest}, {name: "fixed reservation settles token fallback", expression: mixed, estimate: 100, usage: &dto.Usage{PromptTokens: 50000, TotalTokens: 50000}, want: 50000, unit: billingexpr.BillingUnitToken}, {name: "missing usage uses estimated token fallback", expression: mixed, estimate: 50000, want: 50000, unit: billingexpr.BillingUnitToken}, {name: "evaluation error retains fixed reservation metadata", expression: `p == 50 ? tier("error", param("missing") * p + img_cr * 2) : tier("request", fixed(0.01))`, estimate: 100, usage: &dto.Usage{PromptTokens: 50, TotalTokens: 50}, want: 5000, unit: billingexpr.BillingUnitRequest}, {name: "explicit zero remains free", expression: `tier("free", fixed(0))`, usage: &dto.Usage{PromptTokens: 100, TotalTokens: 100}, unit: billingexpr.BillingUnitRequest}, {name: "multipliers and separate tool surcharge", expression: flat + ` * (param("fast") == true ? 2 : 1)`, groupRatio: 1.5, tool: true, want: 18000, unit: billingexpr.BillingUnitRequest}, {name: "failed request refunds exactly once", expression: flat, refund: true}, {name: "insufficient wallet never reserves tokens", expression: flat, insufficient: true}, {name: "image cache stream settles usage and refunds unused reservation", expression: imageExpression, estimate: 10000, usage: imageUsage, stream: true, want: 4113, unit: billingexpr.BillingUnitToken}, {name: "audio settlement records image cache billing inputs", expression: imageExpression, estimate: 10000, usage: imageUsage, audio: true, want: 4113, unit: billingexpr.BillingUnitToken}, {name: "realtime records actual expression inputs", expression: imageExpression, estimate: 10000, usage: imageUsage, realtime: true, want: 4000, unit: billingexpr.BillingUnitToken}, {name: "image cache insufficient wallet never reserves tokens", expression: imageExpression, estimate: 10000, insufficient: true}, {name: "image quantity refunds missing images", expression: `tier("image", fixed(0.04)) * image_count`, requestedImages: 3, actualImages: 2, want: 40000, unit: billingexpr.BillingUnitRequest}, {name: "image quantity keeps request when actual missing", expression: `tier("image", fixed(0.04)) * image_count`, requestedImages: 3, want: 60000, unit: billingexpr.BillingUnitRequest}, {name: "image quantity zero price stays free", expression: `tier("image", fixed(0)) * image_count`, requestedImages: 3, actualImages: 2, unit: billingexpr.BillingUnitRequest}, {name: "image quantity failure refunds reservation", expression: `tier("image", fixed(0.04)) * image_count`, requestedImages: 3, refund: true}, {name: "image quantity exceeds one-image wallet before submission", expression: `tier("image", fixed(0.04)) * image_count`, requestedImages: 4, wallet: 20000, insufficient: true}, {name: "image override reserves extra quantity", expression: `tier("image", fixed(0.04)) * image_count`, requestedImages: 1, outboundImages: 4, want: 80000, unit: billingexpr.BillingUnitRequest}, {name: "image override cannot exceed remaining wallet", expression: `tier("image", fixed(0.04)) * image_count`, requestedImages: 1, outboundImages: 4, wallet: 40000, reserveInsufficient: true, refund: true}, } { t.Run(tc.name, func(t *testing.T) { quota := startingQuota if tc.insufficient { quota = 1 } if tc.wallet > 0 { quota = tc.wallet } user := model.User{Username: fmt.Sprintf("fixed_billing_%d", index), Quota: quota, Status: common.UserStatusEnabled} require.NoError(t, db.Create(&user).Error) token := model.Token{UserId: user.Id, Key: fmt.Sprintf("fixed-billing-test-%d", index), Name: "fixed-billing", RemainQuota: startingQuota, Status: common.TokenStatusEnabled} require.NoError(t, db.Create(&token).Error) channel := model.Channel{Name: "fixed-billing", Key: "unused", Status: common.ChannelStatusEnabled} require.NoError(t, db.Create(&channel).Error) t.Cleanup(func() { require.NoError(t, logDB.Where("user_id = ?", user.Id).Delete(&model.Log{}).Error) require.NoError(t, db.Unscoped().Delete(&token).Error) require.NoError(t, db.Unscoped().Delete(&user).Error) require.NoError(t, db.Unscoped().Delete(&channel).Error) }) group := tc.groupRatio if group == 0 { group = 1 } request := &billingexpr.RequestInput{Body: []byte(`{"fast":true}`)} if tc.requestedImages > 0 { request.ImageCount = &tc.requestedImages } cost, trace, err := billingexpr.RunExprWithRequest(tc.expression, billingexpr.TokenParams{P: float64(tc.estimate), Len: float64(tc.estimate)}, *request) require.NoError(t, err) reservation, err := billingexpr.QuotaRoundStrict(cost / 1_000_000 * common.QuotaPerUnit * group) require.NoError(t, err) snapshot := &billingexpr.BillingSnapshot{BillingMode: "tiered_expr", ExprString: tc.expression, ExprHash: billingexpr.ExprHashString(tc.expression), QuotaPerUnit: common.QuotaPerUnit, GroupRatio: group, EstimatedTier: trace.MatchedTier, EstimatedBillingUnit: trace.BillingUnit, EstimatedFixedPrice: trace.FixedPrice, EstimatedQuotaAfterGroup: reservation} snapshot.EstimatedImageCount = trace.ImageCount info := &relaycommon.RelayInfo{UserId: user.Id, TokenId: token.Id, TokenKey: token.Key, ChannelMeta: &relaycommon.ChannelMeta{ChannelId: channel.Id}, OriginModelName: "fixed-test", UsingGroup: "default", UserGroup: "default", UserSetting: dto.UserSetting{BillingPreference: "wallet_only"}, ForcePreConsume: true, StartTime: time.Now(), IsStream: tc.stream, RelayFormat: types.RelayFormatOpenAI, PriceData: hosttypes.PriceData{GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: group}}, TieredBillingSnapshot: snapshot, BillingRequestInput: request} info.SetEstimatePromptTokens(tc.estimate) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) ctx.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil) if tc.expression == imageExpression { ctx.Request.URL.Path = "/v1/images/generations" info.RelayMode = relayconstant.RelayModeImagesGenerations } apiErr := PreConsumeBilling(ctx, reservation, info) if tc.insufficient { require.NotNil(t, apiErr) assert.Equal(t, types.ErrorCodeInsufficientUserQuota, apiErr.GetErrorCode()) } else { require.Nil(t, apiErr) held, err := model.GetUserQuota(user.Id, true) require.NoError(t, err) assert.Equal(t, quota-reservation, held) if tc.outboundImages > 0 { reserveErr := PrepareImageBillingForRequest(ctx, info, tc.outboundImages) if tc.reserveInsufficient { require.NotNil(t, reserveErr) assert.Equal(t, types.ErrorCodeInsufficientUserQuota, reserveErr.GetErrorCode()) assert.Equal(t, reservation, info.Billing.GetPreConsumedQuota()) } else { require.Nil(t, reserveErr) assert.Equal(t, tc.want, info.Billing.GetPreConsumedQuota()) } } if tc.refund { refunded := make(chan struct{}, 1) const callback = "fixed_billing_refund_observed" require.NoError(t, db.Callback().Update().After("gorm:commit_or_rollback_transaction").Register(callback, func(tx *gorm.DB) { if tx.Statement.Table == "tokens" && tx.Error == nil { select { case refunded <- struct{}{}: default: } } })) t.Cleanup(func() { require.NoError(t, db.Callback().Update().Remove(callback)) }) info.Billing.Refund(ctx) info.Billing.Refund(ctx) select { case <-refunded: case <-time.After(5 * time.Second): t.Fatal("refund did not finish") } } else { info.UpdateImageCount(int64(tc.actualImages)) if tc.tool { info.ResponsesUsageInfo = &relaycommon.ResponsesUsageInfo{BuiltInTools: map[string]*relaycommon.BuildInToolInfo{"fixed_billing_tool": {CallCount: 1}}} } if tc.realtime { PostWssConsumeQuota(ctx, info, info.OriginModelName, &dto.RealtimeUsage{ InputTokens: tc.usage.PromptTokens, OutputTokens: tc.usage.CompletionTokens, TotalTokens: tc.usage.TotalTokens, }, nil) } else if tc.audio { PostAudioConsumeQuota(ctx, info, tc.usage, nil) } else { PostTextConsumeQuota(ctx, info, tc.usage, nil) } require.NoError(t, info.Billing.Settle(tc.want), "a repeated settlement must not charge again") var log model.Log require.NoError(t, logDB.Where("user_id = ?", user.Id).Take(&log).Error) assert.Equal(t, tc.want, log.Quota) assert.Equal(t, tc.stream, log.IsStream) assert.NotContains(t, log.Content, "nothing was charged") var other map[string]any require.NoError(t, common.UnmarshalJsonStr(log.Other, &other)) assert.Equal(t, string(tc.unit), other["billing_unit"]) if tc.requestedImages > 0 { count := tc.actualImages if count == 0 { count = tc.requestedImages if tc.outboundImages > 0 { count = tc.outboundImages } } assert.Equal(t, float64(count), other["image_count"]) assert.Equal(t, tc.requestedImages, *request.ImageCount, "actual count must not mutate the frozen request") } if tc.expression == imageExpression { billable, ok := other["billing_tokens"].(map[string]any) require.True(t, ok) if tc.realtime { assert.Equal(t, float64(0), other["image_cache_tokens"]) assert.Equal(t, float64(1000), billable["p"]) assert.Equal(t, float64(0), billable["cr"]) assert.Equal(t, float64(0), billable["img"]) } else { if !tc.audio { assert.Equal(t, float64(300), other["cache_tokens"]) } assert.Equal(t, float64(200), other["image_cache_tokens"]) assert.Equal(t, float64(300), billable["p"]) assert.Equal(t, float64(100), billable["cr"]) assert.Equal(t, float64(400), billable["img"]) } } else { assert.NotContains(t, other, "billing_tokens") assert.NotContains(t, other, "image_cache_tokens") } if tc.unit == billingexpr.BillingUnitRequest { assert.Contains(t, other, "fixed_price") } else { assert.NotContains(t, other, "fixed_price") } } } require.NoError(t, db.First(&user, user.Id).Error) require.NoError(t, db.First(&token, token.Id).Error) assert.Equal(t, quota-tc.want, user.Quota) assert.Equal(t, startingQuota-tc.want, token.RemainQuota) assert.Equal(t, tc.want, user.UsedQuota) assert.Equal(t, tc.want, token.UsedQuota) if !tc.refund && !tc.insufficient { assert.Equal(t, 1, user.RequestCount) } }) } } func TestCalculateTextQuotaSummaryUnifiedForClaudeSemantic(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(w) usage := &dto.Usage{ PromptTokens: 1000, CompletionTokens: 200, PromptTokensDetails: dto.InputTokenDetails{ CachedTokens: 100, CachedCreationTokens: 50, }, ClaudeCacheCreation5mTokens: 10, ClaudeCacheCreation1hTokens: 20, } priceData := hosttypes.PriceData{ ModelRatio: 1, CompletionRatio: 2, CacheRatio: 0.1, CacheCreationRatio: 1.25, CacheCreation5mRatio: 1.25, CacheCreation1hRatio: 2, GroupRatioInfo: hosttypes.GroupRatioInfo{ GroupRatio: 1, }, } chatRelayInfo := &relaycommon.RelayInfo{ RelayFormat: types.RelayFormatOpenAI, FinalRequestRelayFormat: types.RelayFormatClaude, OriginModelName: "claude-3-7-sonnet", PriceData: priceData, StartTime: time.Now(), } messageRelayInfo := &relaycommon.RelayInfo{ RelayFormat: types.RelayFormatClaude, FinalRequestRelayFormat: types.RelayFormatClaude, OriginModelName: "claude-3-7-sonnet", PriceData: priceData, StartTime: time.Now(), } chatSummary := calculateTextQuotaSummary(ctx, chatRelayInfo, usage) messageSummary := calculateTextQuotaSummary(ctx, messageRelayInfo, usage) require.Equal(t, messageSummary.Quota, chatSummary.Quota) require.Equal(t, messageSummary.CacheCreationTokens5m, chatSummary.CacheCreationTokens5m) require.Equal(t, messageSummary.CacheCreationTokens1h, chatSummary.CacheCreationTokens1h) require.True(t, chatSummary.IsClaudeUsageSemantic) require.Equal(t, 1488, chatSummary.Quota) } func TestCalculateTextQuotaSummaryUsesSplitClaudeCacheCreationRatios(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(w) relayInfo := &relaycommon.RelayInfo{ RelayFormat: types.RelayFormatOpenAI, FinalRequestRelayFormat: types.RelayFormatClaude, OriginModelName: "claude-3-7-sonnet", PriceData: hosttypes.PriceData{ ModelRatio: 1, CompletionRatio: 1, CacheRatio: 0, CacheCreationRatio: 1, CacheCreation5mRatio: 2, CacheCreation1hRatio: 3, GroupRatioInfo: hosttypes.GroupRatioInfo{ GroupRatio: 1, }, }, StartTime: time.Now(), } usage := &dto.Usage{ PromptTokens: 100, CompletionTokens: 0, PromptTokensDetails: dto.InputTokenDetails{ CachedCreationTokens: 10, }, ClaudeCacheCreation5mTokens: 2, ClaudeCacheCreation1hTokens: 3, } summary := calculateTextQuotaSummary(ctx, relayInfo, usage) // 100 + remaining(5)*1 + 2*2 + 3*3 = 118 require.Equal(t, 118, summary.Quota) } func TestCalculateTextQuotaSummaryUsesAnthropicUsageSemanticFromUpstreamUsage(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(w) relayInfo := &relaycommon.RelayInfo{ RelayFormat: types.RelayFormatOpenAI, OriginModelName: "claude-3-7-sonnet", PriceData: hosttypes.PriceData{ ModelRatio: 1, CompletionRatio: 2, CacheRatio: 0.1, CacheCreationRatio: 1.25, CacheCreation5mRatio: 1.25, CacheCreation1hRatio: 2, GroupRatioInfo: hosttypes.GroupRatioInfo{ GroupRatio: 1, }, }, StartTime: time.Now(), } usage := &dto.Usage{ PromptTokens: 1000, CompletionTokens: 200, UsageSemantic: "anthropic", PromptTokensDetails: dto.InputTokenDetails{ CachedTokens: 100, CachedCreationTokens: 50, }, ClaudeCacheCreation5mTokens: 10, ClaudeCacheCreation1hTokens: 20, } summary := calculateTextQuotaSummary(ctx, relayInfo, usage) require.True(t, summary.IsClaudeUsageSemantic) require.Equal(t, "anthropic", summary.UsageSemantic) require.Equal(t, 1488, summary.Quota) } func TestCalculateTextQuotaSummaryUsesClaudeBillingUsageBeforeTopLevelUsage(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(w) relayInfo := &relaycommon.RelayInfo{ RelayFormat: types.RelayFormatOpenAI, OriginModelName: "claude-3-7-sonnet", PriceData: hosttypes.PriceData{ ModelRatio: 1, CompletionRatio: 2, CacheRatio: 0.1, CacheCreationRatio: 1.25, CacheCreation5mRatio: 1.25, CacheCreation1hRatio: 2, GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1}, }, StartTime: time.Now(), } usage := &dto.Usage{ PromptTokens: 999, CompletionTokens: 999, TotalTokens: 1998, BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{ InputTokens: 70, CacheReadInputTokens: 30, CacheCreationInputTokens: 20, OutputTokens: 7, CacheCreation: &dto.ClaudeCacheCreationUsage{ Ephemeral5mInputTokens: 12, Ephemeral1hInputTokens: 8, }, }), } summary := calculateTextQuotaSummary(ctx, relayInfo, effectiveBillingUsage(usage)) require.True(t, summary.IsClaudeUsageSemantic) require.Equal(t, dto.BillingUsageSemanticAnthropic, summary.UsageSemantic) require.Equal(t, 70, summary.PromptTokens) require.Equal(t, 7, summary.CompletionTokens) require.Equal(t, 30, summary.CacheTokens) require.Equal(t, 20, summary.CacheCreationTokens) require.Equal(t, 12, summary.CacheCreationTokens5m) require.Equal(t, 8, summary.CacheCreationTokens1h) require.Equal(t, 118, summary.Quota) } func TestCalculateTextQuotaSummaryUsesGeminiBillingUsageBeforeTopLevelUsage(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(w) relayInfo := &relaycommon.RelayInfo{ RelayFormat: types.RelayFormatOpenAI, OriginModelName: "gemini-2.5-flash", PriceData: hosttypes.PriceData{ ModelRatio: 1, CompletionRatio: 2, CacheRatio: 0.1, GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1}, }, StartTime: time.Now(), } usage := &dto.Usage{ PromptTokens: 999, CompletionTokens: 999, TotalTokens: 1998, BillingUsage: dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{ PromptTokenCount: 100, ToolUsePromptTokenCount: 5, CandidatesTokenCount: 20, ThoughtsTokenCount: 3, TotalTokenCount: 128, CachedContentTokenCount: 7, }), } summary := calculateTextQuotaSummary(ctx, relayInfo, effectiveBillingUsage(usage)) require.False(t, summary.IsClaudeUsageSemantic) require.Equal(t, dto.BillingUsageSemanticGemini, summary.UsageSemantic) require.Equal(t, 105, summary.PromptTokens) require.Equal(t, 23, summary.CompletionTokens) require.Equal(t, 7, summary.CacheTokens) require.Equal(t, 128, summary.TotalTokens) require.Equal(t, 145, summary.Quota) } func TestCalculateTextQuotaSummaryUsesOpenAIBillingUsageBeforeTopLevelUsage(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(w) relayInfo := &relaycommon.RelayInfo{ RelayFormat: types.RelayFormatClaude, OriginModelName: "gpt-4o", PriceData: hosttypes.PriceData{ ModelRatio: 1, CompletionRatio: 2, GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1}, }, StartTime: time.Now(), } usage := &dto.Usage{ PromptTokens: 999, CompletionTokens: 999, TotalTokens: 1998, BillingUsage: dto.NewOpenAIChatBillingUsage(&dto.Usage{ PromptTokens: 80, CompletionTokens: 9, TotalTokens: 89, }), } summary := calculateTextQuotaSummary(ctx, relayInfo, effectiveBillingUsage(usage)) require.False(t, summary.IsClaudeUsageSemantic) require.Equal(t, dto.BillingUsageSemanticOpenAI, summary.UsageSemantic) require.Equal(t, 80, summary.PromptTokens) require.Equal(t, 9, summary.CompletionTokens) require.Equal(t, 89, summary.TotalTokens) require.Equal(t, 98, summary.Quota) } func TestCalculateTextQuotaSummaryUsesOpenAIResponsesInputTokenDetails(t *testing.T) { gin.SetMode(gin.TestMode) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) relayInfo := &relaycommon.RelayInfo{ RelayFormat: types.RelayFormatOpenAI, OriginModelName: "gpt-4o", PriceData: hosttypes.PriceData{ ModelRatio: 1, CompletionRatio: 2, CacheRatio: 0.25, GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1}, }, StartTime: time.Now(), } responsesUsage := &dto.Usage{ InputTokens: 100, OutputTokens: 10, TotalTokens: 110, InputTokensDetails: &dto.InputTokenDetails{ CachedTokens: 40, }, } convertedUsage := &dto.Usage{ PromptTokens: 100, CompletionTokens: 10, TotalTokens: 110, PromptTokensDetails: dto.InputTokenDetails{ CachedTokens: 40, }, BillingUsage: dto.NewOpenAIResponsesBillingUsage(responsesUsage), } effectiveUsage := effectiveBillingUsage(convertedUsage) require.Equal(t, 40, effectiveUsage.PromptTokensDetails.CachedTokens) require.Zero(t, convertedUsage.BillingUsage.OpenAIUsage.PromptTokensDetails.CachedTokens) summary := calculateTextQuotaSummary(ctx, relayInfo, effectiveUsage) require.Equal(t, 40, summary.CacheTokens) // 60 uncached input + 40*0.25 cached input + 10*2 output = 90. require.Equal(t, 90, summary.Quota) } func TestUsageFromOpenAIBillingUsageNormalizesCacheDetailsWithoutOverwritingCanonicalValues(t *testing.T) { responsesUsage := &dto.Usage{ InputTokens: 100, OutputTokens: 10, PromptCacheHitTokens: 55, PromptTokensDetails: dto.InputTokenDetails{ CachedTokens: 8, TextTokens: 12, }, InputTokensDetails: &dto.InputTokenDetails{ CachedTokens: 40, CachedCreationTokens: 5, CacheWriteTokens: 6, TextTokens: 60, ImageTokens: 7, AudioTokens: 9, }, } billingUsage := dto.NewOpenAIResponsesBillingUsage(responsesUsage) usage := effectiveBillingUsage(&dto.Usage{BillingUsage: billingUsage}) require.Equal(t, 8, usage.PromptTokensDetails.CachedTokens) require.Equal(t, 5, usage.PromptTokensDetails.CachedCreationTokens) require.Equal(t, 6, usage.PromptTokensDetails.CacheWriteTokens) require.Equal(t, 12, usage.PromptTokensDetails.TextTokens) require.Equal(t, 7, usage.PromptTokensDetails.ImageTokens) require.Equal(t, 9, usage.PromptTokensDetails.AudioTokens) require.Zero(t, billingUsage.OpenAIUsage.PromptTokensDetails.CachedCreationTokens) } func TestUsageFromOpenAIBillingUsageFallsBackToPromptCacheHitTokens(t *testing.T) { usage := effectiveBillingUsage(&dto.Usage{ BillingUsage: dto.NewOpenAIChatBillingUsage(&dto.Usage{ PromptTokens: 100, CompletionTokens: 10, PromptCacheHitTokens: 35, }), }) require.Equal(t, 35, usage.PromptTokensDetails.CachedTokens) } func TestCalculateTextQuotaSummaryNormalizesOpenAIResponsesBillingUsageDetails(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(w) relayInfo := &relaycommon.RelayInfo{ RelayFormat: types.RelayFormatClaude, OriginModelName: "gpt-5.6-sol", PriceData: hosttypes.PriceData{ ModelRatio: 1, CompletionRatio: 2, CacheRatio: 0.5, CacheCreationRatio: 2, GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1}, }, StartTime: time.Now(), } responsesDetails := dto.InputTokenDetails{ CachedTokens: 80, CacheWriteTokens: 10, TextTokens: 100, } usage := &dto.Usage{ PromptTokens: 999, CompletionTokens: 999, BillingUsage: dto.NewOpenAIResponsesBillingUsage(&dto.Usage{ InputTokens: 100, OutputTokens: 10, TotalTokens: 110, InputTokensDetails: &responsesDetails, }), } effectiveUsage := effectiveBillingUsage(usage) summary := calculateTextQuotaSummary(ctx, relayInfo, effectiveUsage) require.Equal(t, dto.BillingUsageSourceOAIResponses, effectiveUsage.UsageSource) require.Equal(t, responsesDetails, effectiveUsage.PromptTokensDetails) require.Equal(t, 100, summary.PromptTokens) require.Equal(t, 10, summary.CompletionTokens) require.Equal(t, 80, summary.CacheTokens) require.Equal(t, 10, summary.CacheCreationTokens) // (100-80-10) + 80*0.5 + 10*2 + 10*2 = 90 require.Equal(t, 90, summary.Quota) } func TestUsageBillingPathForLog(t *testing.T) { require.Equal(t, usageBillingPathAnthropic, usageBillingPathForLog(true, &dto.Usage{ BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{InputTokens: 1}), })) invalidBillingUsage := &dto.Usage{ PromptTokens: 1, BillingUsage: &dto.BillingUsage{ Source: dto.BillingUsageSourceClaudeMessages, Semantic: dto.BillingUsageSemanticAnthropic, }, } require.Equal(t, usageBillingPathLocal, usageBillingPathForLog(true, invalidBillingUsage)) require.Equal(t, usageBillingPathUpstream, usageBillingPathForLog(false, invalidBillingUsage)) require.Equal(t, usageBillingPathUpstream, usageBillingPathForLog(false, &dto.Usage{})) require.Equal(t, usageBillingPathOpenAI, usageBillingPathForLog(false, &dto.Usage{ BillingUsage: dto.NewOpenAIChatBillingUsage(&dto.Usage{PromptTokens: 1}), })) require.Equal(t, usageBillingPathAnthropic, usageBillingPathForLog(false, &dto.Usage{ BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{InputTokens: 1}), })) require.Equal(t, usageBillingPathGemini, usageBillingPathForLog(false, &dto.Usage{ BillingUsage: dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{PromptTokenCount: 1}), })) require.Equal(t, usageBillingPathGeminiEstimated, usageBillingPathForLog(true, &dto.Usage{ BillingUsage: dto.NewEstimatedGeminiChatBillingUsage(&dto.Usage{PromptTokens: 1}), })) } func TestAppendUsageBillingPathForLogWritesAdminInfo(t *testing.T) { other := model.NewLogOther() appendUsageBillingPathForLog(other, true, &dto.Usage{ BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{InputTokens: 1}), }) adminInfo, ok := other.Snapshot()["admin_info"].(map[string]any) require.True(t, ok) require.Equal(t, usageBillingPathAnthropic, adminInfo["usage_billing_path"]) other = model.NewLogOther() appendUsageBillingPathForLog(other, true, nil) adminInfo, ok = other.Snapshot()["admin_info"].(map[string]any) require.True(t, ok) require.Equal(t, usageBillingPathLocal, adminInfo["usage_billing_path"]) } func TestCacheWriteTokensTotal(t *testing.T) { t.Run("split cache creation", func(t *testing.T) { summary := textQuotaSummary{ CacheCreationTokens: 50, CacheCreationTokens5m: 10, CacheCreationTokens1h: 20, } require.Equal(t, 50, cacheWriteTokensTotal(summary)) }) t.Run("legacy cache creation", func(t *testing.T) { summary := textQuotaSummary{CacheCreationTokens: 50} require.Equal(t, 50, cacheWriteTokensTotal(summary)) }) t.Run("split cache creation without aggregate remainder", func(t *testing.T) { summary := textQuotaSummary{ CacheCreationTokens5m: 10, CacheCreationTokens1h: 20, } require.Equal(t, 30, cacheWriteTokensTotal(summary)) }) } func TestCalculateTextQuotaSummaryHandlesLegacyClaudeDerivedOpenAIUsage(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(w) relayInfo := &relaycommon.RelayInfo{ RelayFormat: types.RelayFormatOpenAI, OriginModelName: "claude-3-7-sonnet", PriceData: hosttypes.PriceData{ ModelRatio: 1, CompletionRatio: 5, CacheRatio: 0.1, CacheCreationRatio: 1.25, CacheCreation5mRatio: 1.25, CacheCreation1hRatio: 2, GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1}, }, StartTime: time.Now(), } usage := &dto.Usage{ PromptTokens: 62, CompletionTokens: 95, PromptTokensDetails: dto.InputTokenDetails{ CachedTokens: 3544, }, ClaudeCacheCreation5mTokens: 586, } summary := calculateTextQuotaSummary(ctx, relayInfo, usage) // 62 + 3544*0.1 + 586*1.25 + 95*5 = 1624.9 => 1624 require.Equal(t, 1624, summary.Quota) } func TestCalculateTextQuotaSummaryBillsOpenAICacheWriteTokens(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(w) relayInfo := &relaycommon.RelayInfo{ RelayFormat: types.RelayFormatOpenAI, OriginModelName: "gpt-5.1", PriceData: hosttypes.PriceData{ ModelRatio: 1, CompletionRatio: 2, CacheRatio: 0.1, CacheCreationRatio: 1.25, GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1}, }, StartTime: time.Now(), } t.Run("uncached remainder stays positive", func(t *testing.T) { usage := &dto.Usage{ PromptTokens: 1473, CompletionTokens: 19, PromptTokensDetails: dto.InputTokenDetails{ CacheWriteTokens: 1470, }, } summary := calculateTextQuotaSummary(ctx, relayInfo, usage) require.Equal(t, 1470, summary.CacheCreationTokens) // (1473-0-1470) + 1470*1.25 + 19*2 = 3 + 1837.5 + 38 = 1878.5 => 1879 require.Equal(t, 1879, summary.Quota) }) t.Run("uncached remainder clamps to zero", func(t *testing.T) { // Real OpenAI payload shape: cached_tokens + cache_write_tokens exceeds // prompt_tokens because both are unadjusted prefix counts. The negative // remainder must clamp to zero, never turn into a negative base charge. usage := &dto.Usage{ PromptTokens: 3619, CompletionTokens: 36, PromptTokensDetails: dto.InputTokenDetails{ CachedTokens: 2921, CacheWriteTokens: 3616, }, } summary := calculateTextQuotaSummary(ctx, relayInfo, usage) require.Equal(t, 3619, summary.PromptTokens) require.Equal(t, 3616, summary.CacheCreationTokens) // max(3619-2921-3616, 0) + 2921*0.1 + 3616*1.25 + 36*2 = 4884.1 => 4884 require.Equal(t, 4884, summary.Quota) }) } func TestCalculateTextQuotaSummarySeparatesOpenRouterCacheReadFromPromptBilling(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(w) relayInfo := &relaycommon.RelayInfo{ OriginModelName: "openai/gpt-4.1", ChannelMeta: &relaycommon.ChannelMeta{ ChannelType: constant.ChannelTypeOpenRouter, }, PriceData: hosttypes.PriceData{ ModelRatio: 1, CompletionRatio: 1, CacheRatio: 0.1, CacheCreationRatio: 1.25, GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1}, }, StartTime: time.Now(), } usage := &dto.Usage{ PromptTokens: 2604, CompletionTokens: 383, PromptTokensDetails: dto.InputTokenDetails{ CachedTokens: 2432, }, } summary := calculateTextQuotaSummary(ctx, relayInfo, usage) // OpenRouter OpenAI-format display keeps prompt_tokens as total input, // but billing still separates normal input from cache read tokens. // quota = (2604 - 2432) + 2432*0.1 + 383 = 798.2 => 798 require.Equal(t, 2604, summary.PromptTokens) require.Equal(t, 798, summary.Quota) } func TestCalculateTextQuotaSummarySeparatesOpenRouterCacheCreationFromPromptBilling(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(w) relayInfo := &relaycommon.RelayInfo{ OriginModelName: "openai/gpt-4.1", ChannelMeta: &relaycommon.ChannelMeta{ ChannelType: constant.ChannelTypeOpenRouter, }, PriceData: hosttypes.PriceData{ ModelRatio: 1, CompletionRatio: 1, CacheCreationRatio: 1.25, GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1}, }, StartTime: time.Now(), } usage := &dto.Usage{ PromptTokens: 2604, CompletionTokens: 383, PromptTokensDetails: dto.InputTokenDetails{ CachedCreationTokens: 100, }, } summary := calculateTextQuotaSummary(ctx, relayInfo, usage) // prompt_tokens is still logged as total input, but cache creation is billed separately. // quota = (2604 - 100) + 100*1.25 + 383 = 3012 require.Equal(t, 2604, summary.PromptTokens) require.Equal(t, 3012, summary.Quota) } func TestCalculateTextQuotaSummaryKeepsPrePRClaudeOpenRouterBilling(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(w) relayInfo := &relaycommon.RelayInfo{ FinalRequestRelayFormat: types.RelayFormatClaude, OriginModelName: "anthropic/claude-3.7-sonnet", ChannelMeta: &relaycommon.ChannelMeta{ ChannelType: constant.ChannelTypeOpenRouter, }, PriceData: hosttypes.PriceData{ ModelRatio: 1, CompletionRatio: 1, CacheRatio: 0.1, CacheCreationRatio: 1.25, GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1}, }, StartTime: time.Now(), } usage := &dto.Usage{ PromptTokens: 2604, CompletionTokens: 383, PromptTokensDetails: dto.InputTokenDetails{ CachedTokens: 2432, }, } summary := calculateTextQuotaSummary(ctx, relayInfo, usage) // Pre-PR PostClaudeConsumeQuota behavior for OpenRouter: // prompt = 2604 - 2432 = 172 // quota = 172 + 2432*0.1 + 383 = 798.2 => 798 require.True(t, summary.IsClaudeUsageSemantic) require.Equal(t, 172, summary.PromptTokens) require.Equal(t, 798, summary.Quota) } func TestComposeTieredTextQuotaKeepsToolCallSurcharges(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(w) // 11 $/1K => 0.011 per completed image output, matching the prior fixed low-tier charge. operation_setting.SetToolPriceForTest(dto.BuildInToolImageGeneration, 11.0) t.Cleanup(func() { operation_setting.DeleteToolPriceForTest(dto.BuildInToolImageGeneration) }) relayInfo := &relaycommon.RelayInfo{ OriginModelName: "o1", PriceData: hosttypes.PriceData{ ModelRatio: 1, CompletionRatio: 1, GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1}, }, ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{ BuiltInTools: map[string]*relaycommon.BuildInToolInfo{ dto.BuildInToolWebSearchPreview: { CallCount: 1, }, dto.BuildInToolFileSearch: { CallCount: 2, }, dto.BuildInToolImageGeneration: { CallCount: 1, }, }, }, TieredBillingSnapshot: &billingexpr.BillingSnapshot{ BillingMode: "tiered_expr", GroupRatio: 1, EstimatedQuotaBeforeGroup: 1000, }, StartTime: time.Now(), } usage := &dto.Usage{ PromptTokens: 100, CompletionTokens: 50, TotalTokens: 150, } summary := calculateTextQuotaSummary(ctx, relayInfo, usage) quota := composeTieredTextQuota(relayInfo, summary, 1000, &billingexpr.TieredResult{ ActualQuotaBeforeGroup: 1000, ActualQuotaAfterGroup: 1000, }) require.Equal(t, int64(13000), summary.ToolCallSurchargeQuota.Round(0).IntPart()) require.Equal(t, 14000, quota) } func TestComposeTieredTextQuotaFallbackKeepsToolCallSurcharges(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(w) relayInfo := &relaycommon.RelayInfo{ OriginModelName: "claude-3-7-sonnet", PriceData: hosttypes.PriceData{ ModelRatio: 1, CompletionRatio: 1, GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1.25}, }, ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{ BuiltInTools: map[string]*relaycommon.BuildInToolInfo{ dto.BuildInToolWebSearch: {ToolName: dto.BuildInToolWebSearch, CallCount: 2}, }, }, TieredBillingSnapshot: &billingexpr.BillingSnapshot{ BillingMode: "tiered_expr", GroupRatio: 1.25, EstimatedQuotaBeforeGroup: 1000, }, StartTime: time.Now(), } usage := &dto.Usage{ PromptTokens: 100, CompletionTokens: 50, TotalTokens: 150, } summary := calculateTextQuotaSummary(ctx, relayInfo, usage) quota := composeTieredTextQuota(relayInfo, summary, 1250, nil) require.Equal(t, int64(12500), summary.ToolCallSurchargeQuota.Round(0).IntPart()) require.Equal(t, 13750, quota) } func TestComposeTieredTextQuotaErrorFallbackUsesPreConsumedQuota(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(w) relayInfo := &relaycommon.RelayInfo{ OriginModelName: "claude-3-7-sonnet", PriceData: hosttypes.PriceData{ ModelRatio: 1, CompletionRatio: 1, GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1.25}, }, ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{ BuiltInTools: map[string]*relaycommon.BuildInToolInfo{ dto.BuildInToolWebSearch: {ToolName: dto.BuildInToolWebSearch, CallCount: 2}, }, }, TieredBillingSnapshot: &billingexpr.BillingSnapshot{ BillingMode: "tiered_expr", GroupRatio: 1.25, EstimatedQuotaBeforeGroup: 1000, }, StartTime: time.Now(), } usage := &dto.Usage{ PromptTokens: 100, CompletionTokens: 50, TotalTokens: 150, } summary := calculateTextQuotaSummary(ctx, relayInfo, usage) // tieredResult=nil simulates a settlement error where TryTieredSettle // falls back to FinalPreConsumedQuota (2000), which differs from // EstimatedQuotaBeforeGroup * GroupRatio (1250). preConsumedFallback := 2000 quota := composeTieredTextQuota(relayInfo, summary, preConsumedFallback, nil) require.Equal(t, int64(12500), summary.ToolCallSurchargeQuota.Round(0).IntPart()) require.Equal(t, 14500, quota) } // TestTryTieredSettleRecordsClampOnOverflow guards that an oversized tiered // settlement both saturates the quota and records the clamp on RelayInfo, so // every consume path (text, audio, WSS) can surface it under admin_info. func TestTryTieredSettleRecordsClampOnOverflow(t *testing.T) { // exprOutput = p * 1e12; quotaBeforeGroup = p*1e12 / 1e6 * 5e5 far exceeds // the supported single-request range and must saturate. exprStr := `tier("base", p * 1000000000000)` relayInfo := &relaycommon.RelayInfo{ OriginModelName: "overflow-model", TieredBillingSnapshot: &billingexpr.BillingSnapshot{ BillingMode: "tiered_expr", ExprString: exprStr, ExprHash: billingexpr.ExprHashString(exprStr), GroupRatio: 1, QuotaPerUnit: 500_000, }, } ok, quota, result := TryTieredSettle(relayInfo, billingexpr.TokenParams{P: 1_000_000_000}) require.True(t, ok) require.NotNil(t, result) require.Equal(t, common.MaxQuota, quota, "oversized settlement must clamp, never wrap negative") require.NotNil(t, relayInfo.QuotaClamp, "clamp must be recorded on RelayInfo for admin auditing") require.Equal(t, common.QuotaClampOverflow, relayInfo.QuotaClamp.Kind) } // TestTryTieredSettleNoClampInRange confirms an in-range settlement leaves // RelayInfo.QuotaClamp nil. func TestTryTieredSettleNoClampInRange(t *testing.T) { exprStr := `tier("base", p * 2 + c * 10)` relayInfo := &relaycommon.RelayInfo{ OriginModelName: "in-range-model", TieredBillingSnapshot: &billingexpr.BillingSnapshot{ BillingMode: "tiered_expr", ExprString: exprStr, ExprHash: billingexpr.ExprHashString(exprStr), GroupRatio: 1, QuotaPerUnit: 500_000, }, } ok, _, result := TryTieredSettle(relayInfo, billingexpr.TokenParams{P: 1000, C: 500}) require.True(t, ok) require.NotNil(t, result) require.Nil(t, relayInfo.QuotaClamp, "in-range settlement must not record a clamp") } func TestCalculateTextQuotaSummaryFixedPriceAppliesImageCountOnceAndAllowsOverride(t *testing.T) { gin.SetMode(gin.TestMode) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) priceData := hosttypes.PriceData{ ModelPrice: 0.12, UsePrice: true, GroupRatioInfo: hosttypes.GroupRatioInfo{ GroupRatio: 1, }, } priceData.AddOtherRatio("n", 3) relayInfo := &relaycommon.RelayInfo{ OriginModelName: "dall-e-3", PriceData: priceData, StartTime: time.Now(), } usage := &dto.Usage{PromptTokens: 1, TotalTokens: 1} summary := calculateTextQuotaSummary(ctx, relayInfo, usage) require.Equal(t, 180000, summary.Quota) // An adaptor-reported actual count replaces the requested count rather // than multiplying it a second time. relayInfo.PriceData.AddOtherRatio("n", 2) summary = calculateTextQuotaSummary(ctx, relayInfo, usage) require.Equal(t, 120000, summary.Quota) } func TestCalculateTextToolCallSurchargeGeneralizedBuiltInTools(t *testing.T) { gin.SetMode(gin.TestMode) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) operation_setting.SetToolPriceForTest("my_fn", 5.0) t.Cleanup(func() { operation_setting.DeleteToolPriceForTest("my_fn") }) relayInfo := &relaycommon.RelayInfo{ OriginModelName: "o1", ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{ BuiltInTools: map[string]*relaycommon.BuildInToolInfo{ dto.BuildInToolWebSearchPreview: {CallCount: 2}, "my_fn": {CallCount: 3}, "unpriced": {CallCount: 5}, }, }, } summary := &textQuotaSummary{ ModelName: "o1", GroupRatio: 1, } surcharge := calculateTextToolCallSurcharge(ctx, relayInfo, summary) expected := decimal.NewFromFloat((10.0*2 + 5.0*3) / 1000).Mul(decimal.NewFromFloat(common.QuotaPerUnit)) assert.True(t, expected.Equal(surcharge), "got %s want %s", surcharge, expected) require.Len(t, summary.ToolSurchargeItems, 2) assert.Equal(t, "my_fn", summary.ToolSurchargeItems[0].Name) assert.Equal(t, 3, summary.ToolSurchargeItems[0].Count) assert.Equal(t, 5.0, summary.ToolSurchargeItems[0].Price) assert.Equal(t, dto.BuildInToolWebSearchPreview, summary.ToolSurchargeItems[1].Name) assert.Equal(t, 2, summary.ToolSurchargeItems[1].Count) assert.Equal(t, 10.0, summary.ToolSurchargeItems[1].Price) } func TestCalculateTextToolCallSurchargeKeepsSearchPreviewFallbackWithCustomFunctions(t *testing.T) { gin.SetMode(gin.TestMode) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) operation_setting.SetToolPriceForTest("my_fn", 5) t.Cleanup(func() { operation_setting.DeleteToolPriceForTest("my_fn") }) relayInfo := &relaycommon.RelayInfo{ RelayMode: relayconstant.RelayModeChatCompletions, OriginModelName: "gpt-4o-search-preview", ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{ BuiltInTools: map[string]*relaycommon.BuildInToolInfo{ "my_fn": {CallCount: 1}, }, }, } summary := &textQuotaSummary{ ModelName: relayInfo.OriginModelName, GroupRatio: 1, } surcharge := calculateTextToolCallSurcharge(ctx, relayInfo, summary) require.Len(t, summary.ToolSurchargeItems, 2) assert.Equal(t, "my_fn", summary.ToolSurchargeItems[0].Name) assert.Equal(t, dto.BuildInToolWebSearchPreview, summary.ToolSurchargeItems[1].Name) expected := decimal.NewFromFloat((5.0 + 25.0) / 1000). Mul(decimal.NewFromFloat(common.QuotaPerUnit)) assert.True(t, expected.Equal(surcharge), "got %s want %s", surcharge, expected) } func TestCalculateTextToolCallSurchargeDoesNotInferSearchForResponses(t *testing.T) { gin.SetMode(gin.TestMode) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) relayInfo := &relaycommon.RelayInfo{ RelayMode: relayconstant.RelayModeResponses, OriginModelName: "gpt-4o-search-preview", ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{ BuiltInTools: map[string]*relaycommon.BuildInToolInfo{}, }, } summary := &textQuotaSummary{ ModelName: relayInfo.OriginModelName, GroupRatio: 1, } surcharge := calculateTextToolCallSurcharge(ctx, relayInfo, summary) assert.True(t, surcharge.IsZero()) assert.Empty(t, summary.ToolSurchargeItems) } // Every BuiltInTools count is bounded by MaxBillableToolCallCount at // settlement, whatever recorded it (upstream-reported counts and per-item // counts alike). func TestCalculateTextToolCallSurchargeClampsCallCount(t *testing.T) { gin.SetMode(gin.TestMode) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) for recorded, billed := range map[int]int{5: 5, 10_000: 10_000, 10_005: 10_000} { relayInfo := &relaycommon.RelayInfo{ OriginModelName: "claude-3-7-sonnet", ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{ BuiltInTools: map[string]*relaycommon.BuildInToolInfo{ dto.BuildInToolWebSearch: {CallCount: recorded}, }, }, } summary := &textQuotaSummary{ModelName: relayInfo.OriginModelName, GroupRatio: 1} surcharge := calculateTextToolCallSurcharge(ctx, relayInfo, summary) assert.Equal(t, []ToolSurchargeItem{{Name: dto.BuildInToolWebSearch, Count: billed, Price: 10}}, summary.ToolSurchargeItems, recorded) expected := decimal.NewFromFloat(10.0 * float64(billed) / 1000).Mul(decimal.NewFromFloat(common.QuotaPerUnit)) assert.True(t, expected.Equal(surcharge), "recorded %d: got %s want %s", recorded, surcharge, expected) } } func TestMergeToolSurchargeItemsSaturatesCountOverflow(t *testing.T) { items := []ToolSurchargeItem{ {Name: "custom_fn", Count: math.MaxInt, Price: 5}, {Name: "custom_fn", Count: 1, Price: 5}, } merged := mergeToolSurchargeItems(items) require.Len(t, merged, 1) assert.Equal(t, math.MaxInt, merged[0].Count) } // A zero-token request (e.g. /v1/alpha/search returns no usage) must still // bill a tool-call surcharge. Regression for the TotalTokens==0 gate zeroing // out the surcharge quota. func TestCalculateTextQuotaSummaryZeroTokensStillBillsToolSurcharge(t *testing.T) { gin.SetMode(gin.TestMode) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) relayInfo := &relaycommon.RelayInfo{ OriginModelName: "o1", ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{ BuiltInTools: map[string]*relaycommon.BuildInToolInfo{ dto.BuildInToolWebSearchPreview: {CallCount: 1}, }, }, } relayInfo.PriceData.GroupRatioInfo.GroupRatio = 1 usage := &dto.Usage{} // zero tokens, mirrors alpha search summary := calculateTextQuotaSummary(ctx, relayInfo, usage) require.Equal(t, 0, summary.TotalTokens) assert.False(t, summary.ToolCallSurchargeQuota.IsZero(), "surcharge should be computed") assert.Greater(t, summary.Quota, 0, "quota must not be zeroed out for a zero-token web search request") expected := common.QuotaFromDecimal(summary.ToolCallSurchargeQuota) assert.Equal(t, expected, summary.Quota) } func TestClaudeCacheOnlyUsageRemainsBillable(t *testing.T) { gin.SetMode(gin.TestMode) for _, tc := range []struct { name string usage dto.ClaudeUsage wantQuota int }{ {name: "one-hour cache write", usage: dto.ClaudeUsage{CacheCreationInputTokens: 20000, CacheCreation: &dto.ClaudeCacheCreationUsage{Ephemeral1hInputTokens: 20000}}, wantQuota: 100000}, {name: "five-minute cache write", usage: dto.ClaudeUsage{CacheCreationInputTokens: 100, CacheCreation: &dto.ClaudeCacheCreationUsage{Ephemeral5mInputTokens: 100}}, wantQuota: 313}, {name: "cache read", usage: dto.ClaudeUsage{CacheReadInputTokens: 100}, wantQuota: 25}, {name: "no usage", usage: dto.ClaudeUsage{}, wantQuota: 0}, } { t.Run(tc.name, func(t *testing.T) { ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) info := &relaycommon.RelayInfo{ RelayFormat: types.RelayFormatClaude, OriginModelName: "claude-opus-4-8", PriceData: hosttypes.PriceData{ ModelRatio: 2.5, CompletionRatio: 5, CacheRatio: 0.1, CacheCreationRatio: 1.25, CacheCreation5mRatio: 1.25, CacheCreation1hRatio: 2, GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1}, }, } usage := &dto.Usage{UsageSemantic: dto.BillingUsageSemanticAnthropic, BillingUsage: dto.NewClaudeMessagesBillingUsage(&tc.usage)} summary := calculateTextQuotaSummary(ctx, info, effectiveBillingUsage(usage)) assert.Zero(t, summary.PromptTokens) assert.Zero(t, summary.CompletionTokens) assert.Equal(t, tc.wantQuota, summary.Quota) assert.Equal(t, tc.wantQuota > 0, summary.hasBillableUsage()) t.Logf("input=%d output=%d quota=%d", summary.PromptTokens, summary.CompletionTokens, summary.Quota) }) } } func TestCalculateTextQuotaSummaryDoesNotApplyRequestMultipliersToToolSurcharge(t *testing.T) { gin.SetMode(gin.TestMode) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) relayInfo := &relaycommon.RelayInfo{ OriginModelName: "o1", PriceData: hosttypes.PriceData{ ModelRatio: 1, CompletionRatio: 1, GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1}, }, ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{ BuiltInTools: map[string]*relaycommon.BuildInToolInfo{ dto.BuildInToolWebSearchPreview: {CallCount: 1}, }, }, } relayInfo.PriceData.AddOtherRatio("n", 3) summary := calculateTextQuotaSummary(ctx, relayInfo, &dto.Usage{}) expected := decimal.NewFromFloat(10.0 / 1000).Mul(decimal.NewFromFloat(common.QuotaPerUnit)) assert.True(t, expected.Equal(summary.ToolCallSurchargeQuota)) assert.Equal(t, common.QuotaFromDecimal(expected), summary.Quota) } func TestCalculateTextToolCallSurchargeGeminiGoogleSearch(t *testing.T) { gin.SetMode(gin.TestMode) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) relayInfo := &relaycommon.RelayInfo{ OriginModelName: "gemini-3.7-flash", ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{ BuiltInTools: map[string]*relaycommon.BuildInToolInfo{ dto.BuildInToolGoogleSearch: {ToolName: dto.BuildInToolGoogleSearch, CallCount: 1}, }, }, } summary := &textQuotaSummary{ModelName: "gemini-3.7-flash", GroupRatio: 1} surcharge := calculateTextToolCallSurcharge(ctx, relayInfo, summary) expected := decimal.NewFromFloat(14.0 / 1000).Mul(decimal.NewFromFloat(common.QuotaPerUnit)) assert.True(t, expected.Equal(surcharge), "got %s want %s", surcharge, expected) require.Len(t, summary.ToolSurchargeItems, 1) assert.Equal(t, dto.BuildInToolGoogleSearch, summary.ToolSurchargeItems[0].Name) assert.Equal(t, 1, summary.ToolSurchargeItems[0].Count) assert.Equal(t, 14.0, summary.ToolSurchargeItems[0].Price) } func TestCalculateTextToolCallSurchargeGeminiFunctionCall(t *testing.T) { gin.SetMode(gin.TestMode) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) operation_setting.SetToolPriceForTest("gemini_surcharge_fn", 5.0) t.Cleanup(func() { operation_setting.DeleteToolPriceForTest("gemini_surcharge_fn") }) relayInfo := &relaycommon.RelayInfo{ OriginModelName: "gemini-2.5-flash", ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{ BuiltInTools: map[string]*relaycommon.BuildInToolInfo{ "gemini_surcharge_fn": {CallCount: 2}, }, }, } summary := &textQuotaSummary{ModelName: "gemini-2.5-flash", GroupRatio: 1} surcharge := calculateTextToolCallSurcharge(ctx, relayInfo, summary) expected := decimal.NewFromFloat(5.0 * 2 / 1000).Mul(decimal.NewFromFloat(common.QuotaPerUnit)) assert.True(t, expected.Equal(surcharge), "got %s want %s", surcharge, expected) require.Len(t, summary.ToolSurchargeItems, 1) assert.Equal(t, "gemini_surcharge_fn", summary.ToolSurchargeItems[0].Name) assert.Equal(t, 2, summary.ToolSurchargeItems[0].Count) assert.Equal(t, 5.0, summary.ToolSurchargeItems[0].Price) other := model.NewLogOther() appendToolSurchargeLogInfo(other, summary.ToolSurchargeItems) assert.Equal(t, summary.ToolSurchargeItems, other.Snapshot()["tool_surcharges"]) } func TestCalculateTextToolCallSurchargeImageGenerationDefaultPrice(t *testing.T) { gin.SetMode(gin.TestMode) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) t.Cleanup(func() { operation_setting.DeleteToolPriceForTest(dto.BuildInToolImageGeneration) }) relayInfo := &relaycommon.RelayInfo{ OriginModelName: "gpt-5.1", ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{ BuiltInTools: map[string]*relaycommon.BuildInToolInfo{ dto.BuildInToolImageGeneration: {CallCount: 2}, }, }, } summary := &textQuotaSummary{ModelName: "gpt-5.1", GroupRatio: 1.5} surcharge := calculateTextToolCallSurcharge(ctx, relayInfo, summary) expected := decimal.NewFromFloat(150.0). Mul(decimal.NewFromInt(2)). Div(decimal.NewFromInt(1000)). Mul(decimal.NewFromFloat(1.5)). Mul(decimal.NewFromFloat(common.QuotaPerUnit)) assert.True(t, expected.Equal(surcharge), "got %s want %s", surcharge, expected) require.Len(t, summary.ToolSurchargeItems, 1) assert.Equal(t, dto.BuildInToolImageGeneration, summary.ToolSurchargeItems[0].Name) assert.Equal(t, 2, summary.ToolSurchargeItems[0].Count) assert.Equal(t, 150.0, summary.ToolSurchargeItems[0].Price) } func TestCalculateTextToolCallSurchargeImageGenerationExplicitZeroDisables(t *testing.T) { gin.SetMode(gin.TestMode) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) operation_setting.SetToolPriceForTest(dto.BuildInToolImageGeneration, 0) t.Cleanup(func() { operation_setting.DeleteToolPriceForTest(dto.BuildInToolImageGeneration) }) relayInfo := &relaycommon.RelayInfo{ OriginModelName: "gpt-5.1", ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{ BuiltInTools: map[string]*relaycommon.BuildInToolInfo{ dto.BuildInToolImageGeneration: {CallCount: 3}, }, }, } summary := &textQuotaSummary{ModelName: "gpt-5.1", GroupRatio: 1} surcharge := calculateTextToolCallSurcharge(ctx, relayInfo, summary) assert.True(t, surcharge.IsZero()) assert.Empty(t, summary.ToolSurchargeItems) } func TestCalculateTextQuotaSummaryImageGenerationUsesStructuredSurcharge(t *testing.T) { gin.SetMode(gin.TestMode) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) t.Cleanup(func() { operation_setting.DeleteToolPriceForTest(dto.BuildInToolImageGeneration) }) relayInfo := &relaycommon.RelayInfo{ OriginModelName: "gpt-5.1", ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{ BuiltInTools: map[string]*relaycommon.BuildInToolInfo{ dto.BuildInToolImageGeneration: {CallCount: 1}, }, }, } relayInfo.PriceData.GroupRatioInfo.GroupRatio = 1 relayInfo.PriceData.ModelRatio = 1 relayInfo.PriceData.CompletionRatio = 1 usage := &dto.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15} summary := calculateTextQuotaSummary(ctx, relayInfo, usage) require.Len(t, summary.ToolSurchargeItems, 1) assert.Equal(t, dto.BuildInToolImageGeneration, summary.ToolSurchargeItems[0].Name) assert.Equal(t, 1, summary.ToolSurchargeItems[0].Count) assert.Equal(t, 150.0, summary.ToolSurchargeItems[0].Price) expectedSurcharge := decimal.NewFromFloat(150.0 / 1000).Mul(decimal.NewFromFloat(common.QuotaPerUnit)) assert.True(t, expectedSurcharge.Equal(summary.ToolCallSurchargeQuota), "got %s want %s", summary.ToolCallSurchargeQuota, expectedSurcharge) assert.Greater(t, summary.Quota, 0) } func TestAppendToolSurchargeLogInfoWritesOnlyStructuredFields(t *testing.T) { items := []ToolSurchargeItem{ {Name: dto.BuildInToolWebSearch, Count: 2, Price: 10}, {Name: dto.BuildInToolImageGeneration, Count: 1, Price: 150}, } other := model.NewLogOther() appendToolSurchargeLogInfo(other, items) fields := other.Snapshot() assert.Equal(t, items, fields["tool_surcharges"]) assert.NotContains(t, fields, "web_search") assert.NotContains(t, fields, "web_search_call_count") assert.NotContains(t, fields, "web_search_price") assert.NotContains(t, fields, "file_search") assert.NotContains(t, fields, "image_generation_call") assert.NotContains(t, fields, "image_generation_call_price") } // A request whose upstream returns no usage is logged without a charge. The // reason is stored as content_parts, which the web console renders in the // viewer's language; content keeps the English text. func TestPostTextConsumeQuotaLogsMissingUsageAsStructuredContent(t *testing.T) { truncate(t) const userID, channelID = 62, 62 seedUser(t, userID, 10_000) seedChannel(t, channelID) gin.SetMode(gin.TestMode) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) ctx.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil) info := &relaycommon.RelayInfo{ UserId: userID, OriginModelName: "missing-usage-model", UsingGroup: "default", StartTime: time.Now(), ChannelMeta: &relaycommon.ChannelMeta{ChannelId: channelID}, PriceData: hosttypes.PriceData{ModelRatio: 1, CompletionRatio: 1, GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1}}, } PostTextConsumeQuota(ctx, info, &dto.Usage{}, nil) log := getLastLog(t) require.NotNil(t, log) assert.Zero(t, log.Quota) assert.Equal(t, "Upstream returned no usage, so nothing was charged (possibly an upstream timeout)", log.Content) var other map[string]any require.NoError(t, common.UnmarshalJsonStr(log.Other, &other)) assert.Equal(t, []any{ map[string]any{"key": "Upstream returned no usage, so nothing was charged (possibly an upstream timeout)"}, }, other["content_parts"]) }