diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index 3b65ac97a8..376e08f88e 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -57,22 +57,22 @@ type updateChannelRequest struct { } type channelModelPricingRequest struct { - Platform string `json:"platform" binding:"omitempty,max=50"` - Models []string `json:"models" binding:"required,min=1,max=100"` - BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"` - InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"` - OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"` - CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price" binding:"omitempty,min=0"` - CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"` - FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"` - FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"` - MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier" binding:"omitempty,gt=0"` - ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"` - ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"` - PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"` - Intervals []pricingIntervalRequest `json:"intervals"` - TimePricing *channelTimePricingRequest `json:"time_pricing"` + Platform string `json:"platform" binding:"omitempty,max=50"` + Models []string `json:"models" binding:"required,min=1,max=100"` + BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"` + InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"` + OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"` + CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price" binding:"omitempty,min=0"` + CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"` + FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"` + FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"` + ReasoningEffortMultipliers map[string]float64 `json:"reasoning_effort_multipliers"` + ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"` + ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"` + PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"` + Intervals []pricingIntervalRequest `json:"intervals"` + TimePricing *channelTimePricingRequest `json:"time_pricing"` } type channelTimePricingRequest struct { @@ -130,23 +130,23 @@ type channelResponse struct { } type channelModelPricingResponse struct { - ID int64 `json:"id"` - Platform string `json:"platform"` - Models []string `json:"models"` - BillingMode string `json:"billing_mode"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - FastMultiplier *float64 `json:"fast_multiplier"` - FlexMultiplier *float64 `json:"flex_multiplier"` - MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"` - ImageInputPrice *float64 `json:"image_input_price"` - ImageOutputPrice *float64 `json:"image_output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - Intervals []pricingIntervalResponse `json:"intervals"` - TimePricing *channelTimePricingResponse `json:"time_pricing"` + ID int64 `json:"id"` + Platform string `json:"platform"` + Models []string `json:"models"` + BillingMode string `json:"billing_mode"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + FastMultiplier *float64 `json:"fast_multiplier"` + FlexMultiplier *float64 `json:"flex_multiplier"` + ReasoningEffortMultipliers map[string]float64 `json:"reasoning_effort_multipliers"` + ImageInputPrice *float64 `json:"image_input_price"` + ImageOutputPrice *float64 `json:"image_output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + Intervals []pricingIntervalResponse `json:"intervals"` + TimePricing *channelTimePricingResponse `json:"time_pricing"` } type channelTimePricingResponse struct { @@ -260,23 +260,23 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon intervals = append(intervals, intervalToResponse(iv)) } return channelModelPricingResponse{ - ID: p.ID, - Platform: platform, - Models: models, - BillingMode: billingMode, - InputPrice: p.InputPrice, - OutputPrice: p.OutputPrice, - CacheWritePrice: p.CacheWritePrice, - CacheWrite1hPrice: p.CacheWrite1hPrice, - CacheReadPrice: p.CacheReadPrice, - FastMultiplier: p.FastMultiplier, - FlexMultiplier: p.FlexMultiplier, - MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier, - ImageInputPrice: p.ImageInputPrice, - ImageOutputPrice: p.ImageOutputPrice, - PerRequestPrice: p.PerRequestPrice, - Intervals: intervals, - TimePricing: timePricingToResponse(p.TimePricing), + ID: p.ID, + Platform: platform, + Models: models, + BillingMode: billingMode, + InputPrice: p.InputPrice, + OutputPrice: p.OutputPrice, + CacheWritePrice: p.CacheWritePrice, + CacheWrite1hPrice: p.CacheWrite1hPrice, + CacheReadPrice: p.CacheReadPrice, + FastMultiplier: p.FastMultiplier, + FlexMultiplier: p.FlexMultiplier, + ReasoningEffortMultipliers: p.ReasoningEffortMultipliers, + ImageInputPrice: p.ImageInputPrice, + ImageOutputPrice: p.ImageOutputPrice, + PerRequestPrice: p.PerRequestPrice, + Intervals: intervals, + TimePricing: timePricingToResponse(p.TimePricing), } } @@ -353,29 +353,28 @@ func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMult SortOrder: iv.SortOrder, }) } - var fastMultiplier, flexMultiplier, maxReasoningEffortMultiplier *float64 + var fastMultiplier, flexMultiplier *float64 if allowChannelMultipliers { fastMultiplier = r.FastMultiplier flexMultiplier = r.FlexMultiplier - maxReasoningEffortMultiplier = r.MaxReasoningEffortMultiplier } result = append(result, service.ChannelModelPricing{ - Platform: platform, - Models: r.Models, - BillingMode: billingMode, - InputPrice: r.InputPrice, - OutputPrice: r.OutputPrice, - CacheWritePrice: r.CacheWritePrice, - CacheWrite1hPrice: r.CacheWrite1hPrice, - CacheReadPrice: r.CacheReadPrice, - FastMultiplier: fastMultiplier, - FlexMultiplier: flexMultiplier, - MaxReasoningEffortMultiplier: maxReasoningEffortMultiplier, - ImageInputPrice: r.ImageInputPrice, - ImageOutputPrice: r.ImageOutputPrice, - PerRequestPrice: r.PerRequestPrice, - Intervals: intervals, - TimePricing: timePricingRequestToService(r.TimePricing), + Platform: platform, + Models: r.Models, + BillingMode: billingMode, + InputPrice: r.InputPrice, + OutputPrice: r.OutputPrice, + CacheWritePrice: r.CacheWritePrice, + CacheWrite1hPrice: r.CacheWrite1hPrice, + CacheReadPrice: r.CacheReadPrice, + FastMultiplier: fastMultiplier, + FlexMultiplier: flexMultiplier, + ReasoningEffortMultipliers: r.ReasoningEffortMultipliers, + ImageInputPrice: r.ImageInputPrice, + ImageOutputPrice: r.ImageOutputPrice, + PerRequestPrice: r.PerRequestPrice, + Intervals: intervals, + TimePricing: timePricingRequestToService(r.TimePricing), }) } return result @@ -620,15 +619,15 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) { } response.Success(c, gin.H{ - "found": true, - "input_price": pricing.InputPricePerToken, - "output_price": pricing.OutputPricePerToken, - "cache_write_price": cacheWritePrice, - "cache_write_1h_price": cacheWrite1hPrice, - "cache_read_price": pricing.CacheReadPricePerToken, - "max_reasoning_effort_multiplier": pricing.MaxReasoningEffortMultiplier, - "image_input_price": pricing.ImageInputPricePerToken, - "image_output_price": pricing.ImageOutputPricePerToken, + "found": true, + "input_price": pricing.InputPricePerToken, + "output_price": pricing.OutputPricePerToken, + "cache_write_price": cacheWritePrice, + "cache_write_1h_price": cacheWrite1hPrice, + "cache_read_price": pricing.CacheReadPricePerToken, + "reasoning_effort_multipliers": pricing.ReasoningEffortMultipliers, + "image_input_price": pricing.ImageInputPricePerToken, + "image_output_price": pricing.ImageOutputPricePerToken, }) } diff --git a/backend/internal/handler/admin/channel_handler_test.go b/backend/internal/handler/admin/channel_handler_test.go index d735c54707..5678a9499b 100644 --- a/backend/internal/handler/admin/channel_handler_test.go +++ b/backend/internal/handler/admin/channel_handler_test.go @@ -449,15 +449,14 @@ func TestPricingRequestToService_TimePricingNil(t *testing.T) { require.Nil(t, got[0].TimePricing) } -// 账号成本统计规则不支持倍率:allowChannelMultipliers=false 时必须丢弃, -// 避免渠道倍率意外污染账号成本口径。 +// Fast/Flex 和区间倍率只用于渠道售价;思考等级倍率可在账号统计规则中独立配置。 func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) { req := channelModelPricingRequest{ - Models: []string{"gpt-5"}, - BillingMode: "token", - FastMultiplier: float64Ptr(2.5), - FlexMultiplier: float64Ptr(0.5), - MaxReasoningEffortMultiplier: float64Ptr(3), + Models: []string{"gpt-5"}, + BillingMode: "token", + FastMultiplier: float64Ptr(2.5), + FlexMultiplier: float64Ptr(0.5), + ReasoningEffortMultipliers: map[string]float64{"high": 1.5, "max": 3}, Intervals: []pricingIntervalRequest{{ MinTokens: 272000, InputMultiplier: float64Ptr(2), @@ -470,7 +469,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) { allowed := pricingRequestToService([]channelModelPricingRequest{req}, true) require.Equal(t, float64Ptr(2.5), allowed[0].FastMultiplier) require.Equal(t, float64Ptr(0.5), allowed[0].FlexMultiplier) - require.Equal(t, float64Ptr(3), allowed[0].MaxReasoningEffortMultiplier) + require.Equal(t, req.ReasoningEffortMultipliers, allowed[0].ReasoningEffortMultipliers) require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].InputMultiplier) require.Equal(t, float64Ptr(1.5), allowed[0].Intervals[0].OutputMultiplier) require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheWriteMultiplier) @@ -479,7 +478,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) { dropped := pricingRequestToService([]channelModelPricingRequest{req}, false) require.Nil(t, dropped[0].FastMultiplier) require.Nil(t, dropped[0].FlexMultiplier) - require.Nil(t, dropped[0].MaxReasoningEffortMultiplier) + require.Equal(t, req.ReasoningEffortMultipliers, dropped[0].ReasoningEffortMultipliers) require.Nil(t, dropped[0].Intervals[0].InputMultiplier) require.Nil(t, dropped[0].Intervals[0].OutputMultiplier) require.Nil(t, dropped[0].Intervals[0].CacheWriteMultiplier) @@ -511,6 +510,23 @@ func TestPricingToResponse_TimePricingNil(t *testing.T) { require.Nil(t, got.TimePricing) } +func TestPricingRequestAndResponse_ReasoningEffortMultipliers(t *testing.T) { + var req channelModelPricingRequest + require.NoError(t, json.Unmarshal([]byte(`{"models":["custom-model"],"reasoning_effort_multipliers":{"none":0.5,"high":1.5,"max":3}}`), &req)) + pricing := pricingRequestToService([]channelModelPricingRequest{req}, true) + got := pricingToResponse(&pricing[0]) + require.Equal(t, map[string]float64{"none": 0.5, "high": 1.5, "max": 3}, got.ReasoningEffortMultipliers) + data, err := json.Marshal(got) + require.NoError(t, err) + require.Contains(t, string(data), `"reasoning_effort_multipliers":{"high":1.5,"max":3,"none":0.5}`) + require.NotContains(t, string(data), "max_reasoning_effort_multiplier") + + req = channelModelPricingRequest{} + require.NoError(t, json.Unmarshal([]byte(`{"models":["custom-model"],"reasoning_effort_multipliers":{}}`), &req)) + pricing = pricingRequestToService([]channelModelPricingRequest{req}, true) + require.Empty(t, pricing[0].ReasoningEffortMultipliers) +} + // --------------------------------------------------------------------------- // 3. SyncPricingModels handler // --------------------------------------------------------------------------- @@ -583,10 +599,10 @@ func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) { require.Equal(t, http.StatusOK, w.Code) var body struct { Data struct { - Found bool `json:"found"` - CacheWritePrice float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"` + Found bool `json:"found"` + CacheWritePrice float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + ReasoningEffortMultipliers map[string]float64 `json:"reasoning_effort_multipliers"` } `json:"data"` } require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) @@ -594,8 +610,7 @@ func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) { require.InDelta(t, 12.5e-6, body.Data.CacheWritePrice, 1e-12) require.NotNil(t, body.Data.CacheWrite1hPrice) require.InDelta(t, 20e-6, *body.Data.CacheWrite1hPrice, 1e-12) - require.NotNil(t, body.Data.MaxReasoningEffortMultiplier) - require.Equal(t, 3.0, *body.Data.MaxReasoningEffortMultiplier) + require.Empty(t, body.Data.ReasoningEffortMultipliers) } func TestGetModelDefaultPricing_OmitsUnsupportedCache1hPrice(t *testing.T) { diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index ae01687bdb..46e871a0c3 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -66,17 +66,17 @@ type userAvailableGroup struct { // userSupportedModelPricing 用户可见的定价字段白名单。 type userSupportedModelPricing struct { - BillingMode string `json:"billing_mode"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier,omitempty"` - ImageInputPrice *float64 `json:"image_input_price"` - ImageOutputPrice *float64 `json:"image_output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - Intervals []userPricingIntervalDTO `json:"intervals"` + BillingMode string `json:"billing_mode"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + ReasoningEffortMultipliers map[string]float64 `json:"reasoning_effort_multipliers,omitempty"` + ImageInputPrice *float64 `json:"image_input_price"` + ImageOutputPrice *float64 `json:"image_output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + Intervals []userPricingIntervalDTO `json:"intervals"` } // userPricingIntervalDTO 定价区间白名单(去掉内部 ID、SortOrder 等前端不渲染的字段)。 @@ -332,16 +332,16 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing { billingMode = string(service.BillingModeToken) } return &userSupportedModelPricing{ - BillingMode: billingMode, - InputPrice: p.InputPrice, - OutputPrice: p.OutputPrice, - CacheWritePrice: p.CacheWritePrice, - CacheWrite1hPrice: p.CacheWrite1hPrice, - CacheReadPrice: p.CacheReadPrice, - MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier, - ImageInputPrice: p.ImageInputPrice, - ImageOutputPrice: p.ImageOutputPrice, - PerRequestPrice: p.PerRequestPrice, - Intervals: intervals, + BillingMode: billingMode, + InputPrice: p.InputPrice, + OutputPrice: p.OutputPrice, + CacheWritePrice: p.CacheWritePrice, + CacheWrite1hPrice: p.CacheWrite1hPrice, + CacheReadPrice: p.CacheReadPrice, + ReasoningEffortMultipliers: p.ReasoningEffortMultipliers, + ImageInputPrice: p.ImageInputPrice, + ImageOutputPrice: p.ImageOutputPrice, + PerRequestPrice: p.PerRequestPrice, + Intervals: intervals, } } diff --git a/backend/internal/handler/available_channel_handler_test.go b/backend/internal/handler/available_channel_handler_test.go index 325f10133b..41b70022e7 100644 --- a/backend/internal/handler/available_channel_handler_test.go +++ b/backend/internal/handler/available_channel_handler_test.go @@ -118,7 +118,8 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) { cacheWriteMultiplier := 2.0 cacheReadMultiplier := 2.0 pricing := toUserPricing(&service.ChannelModelPricing{ - BillingMode: service.BillingModeToken, + BillingMode: service.BillingModeToken, + ReasoningEffortMultipliers: map[string]float64{"high": 1.5, "max": 3}, Intervals: []service.PricingInterval{ { ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3, @@ -128,6 +129,7 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) { }, }) require.NotNil(t, pricing) + require.Equal(t, map[string]float64{"high": 1.5, "max": 3}, pricing.ReasoningEffortMultipliers) require.Len(t, pricing.Intervals, 1) rawIv, err := json.Marshal(pricing.Intervals[0]) require.NoError(t, err) diff --git a/backend/internal/repository/channel_reasoning_effort_migration_integration_test.go b/backend/internal/repository/channel_reasoning_effort_migration_integration_test.go new file mode 100644 index 0000000000..ff92dd2fc8 --- /dev/null +++ b/backend/internal/repository/channel_reasoning_effort_migration_integration_test.go @@ -0,0 +1,85 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + + dbmigrations "github.com/Wei-Shaw/sub2api/migrations" + "github.com/stretchr/testify/require" +) + +func TestMigration239ReasoningEffortMultipliers(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + _, err := tx.ExecContext(ctx, `ALTER TABLE channel_model_pricing DROP COLUMN reasoning_effort_multipliers`) + require.NoError(t, err) + _, err = tx.ExecContext(ctx, `ALTER TABLE channel_account_stats_model_pricing DROP COLUMN reasoning_effort_multipliers`) + require.NoError(t, err) + + var channelID, configuredID, unsetID int64 + require.NoError(t, tx.QueryRowContext(ctx, `INSERT INTO channels (name) VALUES ('migration-reasoning-multipliers') RETURNING id`).Scan(&channelID)) + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO channel_model_pricing (channel_id, models, max_reasoning_effort_multiplier) +VALUES ($1, '["custom-model"]', 2.5) RETURNING id`, channelID).Scan(&configuredID)) + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO channel_model_pricing (channel_id, models) +VALUES ($1, '["claude-fable-5-1"]') RETURNING id`, channelID).Scan(&unsetID)) + + migrationSQL, err := dbmigrations.FS.ReadFile("239_channel_reasoning_effort_multipliers.sql") + require.NoError(t, err) + _, err = tx.ExecContext(ctx, string(migrationSQL)) + require.NoError(t, err) + var configured, unset string + require.NoError(t, tx.QueryRowContext(ctx, `SELECT reasoning_effort_multipliers::text FROM channel_model_pricing WHERE id = $1`, configuredID).Scan(&configured)) + require.JSONEq(t, `{"max":2.5}`, configured) + require.NoError(t, tx.QueryRowContext(ctx, `SELECT reasoning_effort_multipliers::text FROM channel_model_pricing WHERE id = $1`, unsetID).Scan(&unset)) + require.JSONEq(t, `{}`, unset) + + // Changing or clearing the generic configuration must survive a migration replay. + for _, current := range []string{`{"high":1.5,"max":4}`, `{"low":0.75}`, `{}`} { + _, err = tx.ExecContext(ctx, `UPDATE channel_model_pricing SET reasoning_effort_multipliers = $1 WHERE id = $2`, current, configuredID) + require.NoError(t, err) + _, err = tx.ExecContext(ctx, string(migrationSQL)) + require.NoError(t, err) + require.NoError(t, tx.QueryRowContext(ctx, `SELECT reasoning_effort_multipliers::text FROM channel_model_pricing WHERE id = $1`, configuredID).Scan(&configured)) + require.JSONEq(t, current, configured) + } + + // Account statistics use the same empty-by-default shape. + var ruleID int64 + require.NoError(t, tx.QueryRowContext(ctx, `INSERT INTO channel_account_stats_pricing_rules (channel_id) VALUES ($1) RETURNING id`, channelID).Scan(&ruleID)) + var accountStatsDefault string + require.NoError(t, tx.QueryRowContext(ctx, `INSERT INTO channel_account_stats_model_pricing (rule_id) VALUES ($1) RETURNING reasoning_effort_multipliers::text`, ruleID).Scan(&accountStatsDefault)) + require.JSONEq(t, `{}`, accountStatsDefault) +} + +func TestMigration239GroupReasoningEffortMultipliers(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + var groupID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO groups (name, platform, model_pricing) +VALUES ('migration-group-reasoning', 'anthropic', '[ + {"models":["custom-model"],"input_price":2,"max_reasoning_effort_multiplier":2.5}, + {"models":["existing-map"],"max_reasoning_effort_multiplier":3,"reasoning_effort_multipliers":{"high":1.5,"max":4}}, + {"models":["cleared-map"],"max_reasoning_effort_multiplier":3,"reasoning_effort_multipliers":{}}, + {"models":["claude-fable-5-1"],"max_reasoning_effort_multiplier":null} +]'::jsonb) RETURNING id`).Scan(&groupID)) + + migrationSQL, err := dbmigrations.FS.ReadFile("239_channel_reasoning_effort_multipliers.sql") + require.NoError(t, err) + for range 2 { + _, err = tx.ExecContext(ctx, string(migrationSQL)) + require.NoError(t, err) + var actual string + require.NoError(t, tx.QueryRowContext(ctx, `SELECT model_pricing::text FROM groups WHERE id = $1`, groupID).Scan(&actual)) + require.JSONEq(t, `[ + {"models":["custom-model"],"input_price":2,"reasoning_effort_multipliers":{"max":2.5}}, + {"models":["existing-map"],"reasoning_effort_multipliers":{"high":1.5,"max":4}}, + {"models":["cleared-map"],"reasoning_effort_multipliers":{}}, + {"models":["claude-fable-5-1"]} + ]`, actual) + } +} diff --git a/backend/internal/repository/channel_reasoning_pricing_integration_test.go b/backend/internal/repository/channel_reasoning_pricing_integration_test.go new file mode 100644 index 0000000000..325bdba2b4 --- /dev/null +++ b/backend/internal/repository/channel_reasoning_pricing_integration_test.go @@ -0,0 +1,181 @@ +//go:build integration + +package repository + +import ( + "context" + "math" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func newReasoningPricingIntegrationChannel(t *testing.T, channel *service.Channel) *channelRepository { + t.Helper() + repo := &channelRepository{db: integrationDB} + channel.Name = t.Name() + channel.Status = service.StatusActive + channel.BillingModelSource = service.BillingModelSourceRequested + require.NoError(t, repo.Create(context.Background(), channel)) + // Channel writes manage their own transactions, so clean up committed fixtures. + t.Cleanup(func() { + require.NoError(t, repo.Delete(context.Background(), channel.ID)) + }) + return repo +} + +func requireReasoningPricingIntegrationMaps(t *testing.T, pricing []service.ChannelModelPricing, expected map[string]map[string]float64) { + t.Helper() + require.Len(t, pricing, len(expected)) + for _, entry := range pricing { + require.Len(t, entry.Models, 1) + multipliers, ok := expected[entry.Models[0]] + require.True(t, ok, "unexpected model %s", entry.Models[0]) + if len(multipliers) == 0 { + require.Empty(t, entry.ReasoningEffortMultipliers, entry.Models[0]) + } else { + require.Equal(t, multipliers, entry.ReasoningEffortMultipliers, entry.Models[0]) + } + } +} + +func TestChannelReasoningPricingCreateUpdateReplace(t *testing.T) { + ctx := context.Background() + channel := &service.Channel{} + repo := newReasoningPricingIntegrationChannel(t, channel) + allLevels := map[string]float64{"none": 0.5, "minimal": 0.75, "low": 0.9, "medium": 1.25, "high": 1.5, "xhigh": 2, "max": 3} + pricing := service.ChannelModelPricing{ + ChannelID: channel.ID, Platform: service.PlatformOpenAI, Models: []string{"custom-reasoning-model"}, + ReasoningEffortMultipliers: allLevels, + } + require.NoError(t, repo.CreateModelPricing(ctx, &pricing)) + require.NotZero(t, pricing.ID) + assertStored := func(expected map[string]map[string]float64) { + t.Helper() + listed, err := repo.ListModelPricing(ctx, channel.ID) + require.NoError(t, err) + requireReasoningPricingIntegrationMaps(t, listed, expected) + // Cache refresh uses this batch loading path instead of ListModelPricing. + batch, err := repo.batchLoadModelPricing(ctx, []int64{channel.ID}) + require.NoError(t, err) + requireReasoningPricingIntegrationMaps(t, batch[channel.ID], expected) + } + assertStored(map[string]map[string]float64{"custom-reasoning-model": allLevels}) + + pricing.ReasoningEffortMultipliers = map[string]float64{"high": 2.25} + require.NoError(t, repo.UpdateModelPricing(ctx, &pricing)) + assertStored(map[string]map[string]float64{"custom-reasoning-model": {"high": 2.25}}) + + pricing.ReasoningEffortMultipliers = nil + require.NoError(t, repo.UpdateModelPricing(ctx, &pricing)) + assertStored(map[string]map[string]float64{"custom-reasoning-model": nil}) + var stored string + require.NoError(t, integrationDB.QueryRowContext(ctx, + `SELECT reasoning_effort_multipliers::text FROM channel_model_pricing WHERE id = $1`, pricing.ID).Scan(&stored)) + require.JSONEq(t, `{}`, stored) + + replacement := []service.ChannelModelPricing{ + {Platform: service.PlatformOpenAI, Models: []string{"custom-reasoning-model"}, ReasoningEffortMultipliers: map[string]float64{"low": 0.8, "max": 4}}, + {Platform: service.PlatformAnthropic, Models: []string{"claude-fable-5-1"}, ReasoningEffortMultipliers: map[string]float64{}}, + } + require.NoError(t, repo.ReplaceModelPricing(ctx, channel.ID, replacement)) + expected := map[string]map[string]float64{"custom-reasoning-model": {"low": 0.8, "max": 4}, "claude-fable-5-1": nil} + assertStored(expected) + + // A serialization failure after deleting the old rows must roll back the replacement. + require.Error(t, repo.ReplaceModelPricing(ctx, channel.ID, []service.ChannelModelPricing{{ + Models: []string{"invalid"}, ReasoningEffortMultipliers: map[string]float64{"max": math.Inf(1)}, + }})) + assertStored(expected) +} + +func TestAccountStatsReasoningPricingRoundTrip(t *testing.T) { + ctx := context.Background() + channel := &service.Channel{ + ModelPricing: []service.ChannelModelPricing{{ + Platform: service.PlatformOpenAI, Models: []string{"custom-reasoning-model"}, + ReasoningEffortMultipliers: map[string]float64{"max": 4}, + }}, + AccountStatsPricingRules: []service.AccountStatsPricingRule{{ + Name: "independent account statistics", GroupIDs: []int64{}, AccountIDs: []int64{}, + Pricing: []service.ChannelModelPricing{ + {Platform: service.PlatformOpenAI, Models: []string{"custom-reasoning-model"}, ReasoningEffortMultipliers: map[string]float64{"none": 0.5, "high": 1.25, "max": 2.5}}, + {Platform: service.PlatformAnthropic, Models: []string{"claude-fable-5-1"}}, + }, + }}, + } + repo := newReasoningPricingIntegrationChannel(t, channel) + assertStored := func(multipliers map[string]float64) *service.Channel { + t.Helper() + loaded, err := repo.GetByID(ctx, channel.ID) + require.NoError(t, err) + require.Len(t, loaded.AccountStatsPricingRules, 1) + expected := map[string]map[string]float64{"custom-reasoning-model": multipliers, "claude-fable-5-1": nil} + requireReasoningPricingIntegrationMaps(t, loaded.AccountStatsPricingRules[0].Pricing, expected) + batch, err := repo.batchLoadAccountStatsPricingRules(ctx, []int64{channel.ID}) + require.NoError(t, err) + require.Len(t, batch[channel.ID], 1) + requireReasoningPricingIntegrationMaps(t, batch[channel.ID][0].Pricing, expected) + // Custom statistics prices must not alter the user-facing channel prices. + requireReasoningPricingIntegrationMaps(t, loaded.ModelPricing, map[string]map[string]float64{"custom-reasoning-model": {"max": 4}}) + return loaded + } + loaded := assertStored(map[string]float64{"none": 0.5, "high": 1.25, "max": 2.5}) + for _, multipliers := range []map[string]float64{{"low": 0.75, "max": 1.5}, nil, {"high": 2}, {}} { + loaded.AccountStatsPricingRules[0].Pricing[0].ReasoningEffortMultipliers = multipliers + require.NoError(t, repo.Update(ctx, loaded)) + loaded = assertStored(multipliers) + } + var stored string + require.NoError(t, integrationDB.QueryRowContext(ctx, + `SELECT reasoning_effort_multipliers::text FROM channel_account_stats_model_pricing WHERE id = $1`, loaded.AccountStatsPricingRules[0].Pricing[0].ID).Scan(&stored)) + require.JSONEq(t, `{}`, stored) +} + +func TestGroupReasoningPricingRoundTripAndBilling(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newGroupRepositoryWithSQL(tx.Client(), tx) + inputPrice, outputPrice := 1e-6, 2e-6 + allLevels := map[string]float64{"none": 0.5, "minimal": 0.75, "low": 0.9, "medium": 1.25, "high": 1.5, "xhigh": 2, "max": 3} + group := &service.Group{ + Name: t.Name(), Platform: service.PlatformAnthropic, RateMultiplier: 0.8, + Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard, + ModelPricing: []service.ChannelModelPricing{{ + Platform: service.PlatformAnthropic, Models: []string{"claude-fable-5-1"}, BillingMode: service.BillingModeToken, + InputPrice: &inputPrice, OutputPrice: &outputPrice, ReasoningEffortMultipliers: allLevels, + }}, + } + require.NoError(t, repo.Create(ctx, group)) + billing := service.NewBillingService(&config.Config{}, nil) + resolver := service.NewModelPricingResolver(nil, billing) + assertStoredAndBilled := func(expected map[string]float64) *service.Group { + t.Helper() + loaded, err := repo.GetByID(ctx, group.ID) + require.NoError(t, err) + requireReasoningPricingIntegrationMaps(t, loaded.ModelPricing, map[string]map[string]float64{"claude-fable-5-1": expected}) + for _, effort := range []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", ""} { + multiplier := 1.0 + if configured, ok := expected[effort]; ok { + multiplier = configured + } + cost, err := billing.CalculateTokenCostForRequest(service.TokenCostRequest{ + Ctx: ctx, Model: "claude-fable-5-1", Group: loaded, Resolver: resolver, + Tokens: service.UsageTokens{InputTokens: 1000, OutputTokens: 200}, + RateMultiplier: loaded.RateMultiplier, ReasoningEffort: effort, + }) + require.NoError(t, err) + require.InDelta(t, 0.0014*multiplier, cost.TotalCost, 1e-12, "effort=%q", effort) + require.InDelta(t, 0.0014*multiplier*0.8, cost.ActualCost, 1e-12, "effort=%q", effort) + } + return loaded + } + loaded := assertStoredAndBilled(allLevels) + for _, multipliers := range []map[string]float64{{"high": 2.25, "max": 4}, nil, {"high": 1.75}, {}} { + loaded.ModelPricing[0].ReasoningEffortMultipliers = multipliers + require.NoError(t, repo.Update(ctx, loaded)) + loaded = assertStoredAndBilled(multipliers) + } +} diff --git a/backend/internal/repository/channel_repo_account_stats_pricing.go b/backend/internal/repository/channel_repo_account_stats_pricing.go index 22922c689e..f2a4786ce1 100644 --- a/backend/internal/repository/channel_repo_account_stats_pricing.go +++ b/backend/internal/repository/channel_repo_account_stats_pricing.go @@ -67,7 +67,7 @@ func (r *channelRepository) batchLoadAccountStatsModelPricing(ctx context.Contex rows, err := r.db.QueryContext(ctx, `SELECT id, rule_id, platform, models, billing_mode, input_price, output_price, - cache_write_price, cache_write_1h_price, cache_read_price, image_output_price, per_request_price, created_at, updated_at + cache_write_price, cache_write_1h_price, cache_read_price, reasoning_effort_multipliers, image_output_price, per_request_price, created_at, updated_at FROM channel_account_stats_model_pricing WHERE rule_id = ANY($1) ORDER BY rule_id, id`, pq.Array(ruleIDs), ) @@ -81,16 +81,22 @@ func (r *channelRepository) batchLoadAccountStatsModelPricing(ctx context.Contex var p service.ChannelModelPricing var ruleID int64 var modelsJSON []byte + var reasoningEffortMultipliersJSON []byte if err := rows.Scan( &p.ID, &ruleID, &p.Platform, &modelsJSON, &p.BillingMode, &p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheWrite1hPrice, &p.CacheReadPrice, - &p.ImageOutputPrice, &p.PerRequestPrice, &p.CreatedAt, &p.UpdatedAt, + &reasoningEffortMultipliersJSON, &p.ImageOutputPrice, &p.PerRequestPrice, &p.CreatedAt, &p.UpdatedAt, ); err != nil { return nil, fmt.Errorf("scan account stats model pricing: %w", err) } if err := json.Unmarshal(modelsJSON, &p.Models); err != nil { p.Models = []string{} } + multipliers, err := unmarshalReasoningEffortMultipliers(reasoningEffortMultipliersJSON) + if err != nil { + return nil, err + } + p.ReasoningEffortMultipliers = multipliers pricingMap[ruleID] = append(pricingMap[ruleID], p) } if err := rows.Err(); err != nil { @@ -172,17 +178,21 @@ func createAccountStatsModelPricingTx(ctx context.Context, tx *sql.Tx, ruleID in if err != nil { return fmt.Errorf("marshal models: %w", err) } + reasoningEffortMultipliersJSON, err := marshalReasoningEffortMultipliers(pricing.ReasoningEffortMultipliers) + if err != nil { + return err + } billingMode := pricing.BillingMode if billingMode == "" { billingMode = service.BillingModeToken } platform := pricing.Platform err = tx.QueryRowContext(ctx, - `INSERT INTO channel_account_stats_model_pricing (rule_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, image_output_price, per_request_price) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at`, + `INSERT INTO channel_account_stats_model_pricing (rule_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, reasoning_effort_multipliers, image_output_price, per_request_price) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, updated_at`, ruleID, platform, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice, - pricing.ImageOutputPrice, pricing.PerRequestPrice, + reasoningEffortMultipliersJSON, pricing.ImageOutputPrice, pricing.PerRequestPrice, ).Scan(&pricing.ID, &pricing.CreatedAt, &pricing.UpdatedAt) if err != nil { return fmt.Errorf("insert account stats model pricing: %w", err) diff --git a/backend/internal/repository/channel_repo_pricing.go b/backend/internal/repository/channel_repo_pricing.go index 8053d4453f..e800c223dc 100644 --- a/backend/internal/repository/channel_repo_pricing.go +++ b/backend/internal/repository/channel_repo_pricing.go @@ -16,7 +16,7 @@ import ( func (r *channelRepository) ListModelPricing(ctx context.Context, channelID int64) ([]service.ChannelModelPricing, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at + `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, reasoning_effort_multipliers, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at FROM channel_model_pricing WHERE channel_id = $1 ORDER BY id`, channelID, ) if err != nil { @@ -51,6 +51,10 @@ func (r *channelRepository) UpdateModelPricing(ctx context.Context, pricing *ser if err != nil { return fmt.Errorf("marshal models: %w", err) } + reasoningEffortMultipliersJSON, err := marshalReasoningEffortMultipliers(pricing.ReasoningEffortMultipliers) + if err != nil { + return err + } timePricingJSON, err := marshalChannelTimePricing(pricing.TimePricing) if err != nil { return err @@ -61,10 +65,10 @@ func (r *channelRepository) UpdateModelPricing(ctx context.Context, pricing *ser } result, err := r.db.ExecContext(ctx, `UPDATE channel_model_pricing - SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_write_1h_price = $6, cache_read_price = $7, fast_multiplier = $8, flex_multiplier = $9, max_reasoning_effort_multiplier = $10, image_input_price = $11, image_output_price = $12, per_request_price = $13, time_pricing = $14, platform = $15, updated_at = NOW() + SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_write_1h_price = $6, cache_read_price = $7, fast_multiplier = $8, flex_multiplier = $9, reasoning_effort_multipliers = $10, image_input_price = $11, image_output_price = $12, per_request_price = $13, time_pricing = $14, platform = $15, updated_at = NOW() WHERE id = $16`, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice, - pricing.FastMultiplier, pricing.FlexMultiplier, pricing.MaxReasoningEffortMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, + pricing.FastMultiplier, pricing.FlexMultiplier, reasoningEffortMultipliersJSON, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, timePricingJSON, pricing.Platform, pricing.ID, ) if err != nil { @@ -96,7 +100,7 @@ func (r *channelRepository) ReplaceModelPricing(ctx context.Context, channelID i // batchLoadModelPricing 批量加载多个渠道的模型定价(含区间) func (r *channelRepository) batchLoadModelPricing(ctx context.Context, channelIDs []int64) (map[int64][]service.ChannelModelPricing, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at + `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, reasoning_effort_multipliers, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at FROM channel_model_pricing WHERE channel_id = ANY($1) ORDER BY channel_id, id`, pq.Array(channelIDs), ) @@ -177,10 +181,11 @@ func scanModelPricingRows(rows *sql.Rows) ([]service.ChannelModelPricing, []int6 var p service.ChannelModelPricing var modelsJSON []byte var timePricingJSON []byte + var reasoningEffortMultipliersJSON []byte if err := rows.Scan( &p.ID, &p.ChannelID, &p.Platform, &modelsJSON, &p.BillingMode, &p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheWrite1hPrice, &p.CacheReadPrice, - &p.FastMultiplier, &p.FlexMultiplier, &p.MaxReasoningEffortMultiplier, + &p.FastMultiplier, &p.FlexMultiplier, &reasoningEffortMultipliersJSON, &p.ImageInputPrice, &p.ImageOutputPrice, &p.PerRequestPrice, &timePricingJSON, &p.CreatedAt, &p.UpdatedAt, ); err != nil { return nil, nil, fmt.Errorf("scan model pricing: %w", err) @@ -188,6 +193,11 @@ func scanModelPricingRows(rows *sql.Rows) ([]service.ChannelModelPricing, []int6 if err := json.Unmarshal(modelsJSON, &p.Models); err != nil { p.Models = []string{} } + multipliers, err := unmarshalReasoningEffortMultipliers(reasoningEffortMultipliersJSON) + if err != nil { + return nil, nil, err + } + p.ReasoningEffortMultipliers = multipliers timePricing, err := unmarshalChannelTimePricing(timePricingJSON) if err != nil { return nil, nil, err @@ -234,6 +244,10 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C if err != nil { return fmt.Errorf("marshal models: %w", err) } + reasoningEffortMultipliersJSON, err := marshalReasoningEffortMultipliers(pricing.ReasoningEffortMultipliers) + if err != nil { + return err + } timePricingJSON, err := marshalChannelTimePricing(pricing.TimePricing) if err != nil { return err @@ -247,11 +261,11 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C platform = "anthropic" } err = exec.QueryRowContext(ctx, - `INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing) + `INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, reasoning_effort_multipliers, image_input_price, image_output_price, per_request_price, time_pricing) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id, created_at, updated_at`, pricing.ChannelID, platform, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice, - pricing.FastMultiplier, pricing.FlexMultiplier, pricing.MaxReasoningEffortMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, + pricing.FastMultiplier, pricing.FlexMultiplier, reasoningEffortMultipliersJSON, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, timePricingJSON, ).Scan(&pricing.ID, &pricing.CreatedAt, &pricing.UpdatedAt) if err != nil { @@ -268,6 +282,31 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C return nil } +func marshalReasoningEffortMultipliers(multipliers map[string]float64) (string, error) { + if len(multipliers) == 0 { + return "{}", nil + } + data, err := json.Marshal(multipliers) + if err != nil { + return "", fmt.Errorf("marshal reasoning effort multipliers: %w", err) + } + return string(data), nil +} + +func unmarshalReasoningEffortMultipliers(data []byte) (map[string]float64, error) { + multipliers := make(map[string]float64) + if len(data) == 0 { + return multipliers, nil + } + if err := json.Unmarshal(data, &multipliers); err != nil { + return nil, fmt.Errorf("unmarshal reasoning effort multipliers: %w", err) + } + if multipliers == nil { + multipliers = make(map[string]float64) + } + return multipliers, nil +} + func marshalChannelTimePricing(config *service.ChannelTimePricing) (any, error) { if config == nil || len(config.Periods) == 0 { return nil, nil diff --git a/backend/internal/repository/channel_repo_pricing_reasoning_test.go b/backend/internal/repository/channel_repo_pricing_reasoning_test.go new file mode 100644 index 0000000000..2cf412c670 --- /dev/null +++ b/backend/internal/repository/channel_repo_pricing_reasoning_test.go @@ -0,0 +1,175 @@ +//go:build unit + +package repository + +import ( + "context" + "database/sql/driver" + "math" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +type capturedReasoningMultipliersJSON struct { + value string +} + +func (c *capturedReasoningMultipliersJSON) Match(value driver.Value) bool { + data, ok := value.(string) + if ok { + c.value = data + } + return ok +} + +func reasoningPricingRow(multipliers any) *sqlmock.Rows { + return sqlmock.NewRows(channelModelPricingTimePricingColumns).AddRow( + int64(11), int64(7), "openai", `["custom-model"]`, service.BillingModeToken, + nil, nil, nil, nil, nil, nil, nil, multipliers, nil, nil, nil, nil, + time.Time{}, time.Time{}, + ) +} + +func TestChannelReasoningEffortMultipliersRoundTrip(t *testing.T) { + tests := []struct { + name string + multipliers map[string]float64 + wantJSON string + }{ + {"custom levels", map[string]float64{"low": 0.75, "high": 1.5, "max": 4}, `{"low":0.75,"high":1.5,"max":4}`}, + {"nil clears configuration", nil, `{}`}, + {"empty clears configuration", map[string]float64{}, `{}`}, + } + for _, tt := range tests { + for _, operation := range []string{"create", "update", "replace"} { + t.Run(tt.name+"/"+operation, func(t *testing.T) { + repo, mock := newChannelModelPricingTimePricingRepo(t) + ctx := context.Background() + pricing := &service.ChannelModelPricing{ + ID: 11, ChannelID: 7, Platform: "openai", Models: []string{"custom-model"}, + ReasoningEffortMultipliers: tt.multipliers, + } + stored := &capturedReasoningMultipliersJSON{} + if operation == "update" { + mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*reasoning_effort_multipliers = \$10.*WHERE id = \$16`). + WithArgs([]byte(`["custom-model"]`), service.BillingModeToken, + nil, nil, nil, nil, nil, nil, nil, stored, nil, nil, nil, nil, "openai", int64(11)). + WillReturnResult(sqlmock.NewResult(0, 1)) + require.NoError(t, repo.UpdateModelPricing(ctx, pricing)) + } else { + if operation == "replace" { + mock.ExpectBegin() + mock.ExpectExec(`DELETE FROM channel_model_pricing WHERE channel_id = \$1`). + WithArgs(int64(7)).WillReturnResult(sqlmock.NewResult(0, 1)) + } + mock.ExpectQuery(`INSERT INTO channel_model_pricing .*reasoning_effort_multipliers`). + WithArgs(int64(7), "openai", []byte(`["custom-model"]`), service.BillingModeToken, + nil, nil, nil, nil, nil, nil, nil, stored, nil, nil, nil, nil). + WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{})) + if operation == "replace" { + mock.ExpectCommit() + require.NoError(t, repo.ReplaceModelPricing(ctx, 7, []service.ChannelModelPricing{*pricing})) + } else { + require.NoError(t, repo.CreateModelPricing(ctx, pricing)) + } + } + require.JSONEq(t, tt.wantJSON, stored.value) + + mock.ExpectQuery(`(?s)SELECT .*reasoning_effort_multipliers.*FROM channel_model_pricing.*channel_id = \$1`). + WithArgs(int64(7)).WillReturnRows(reasoningPricingRow(stored.value)) + expectEmptyModelPricingIntervals(mock) + loaded, err := repo.ListModelPricing(ctx, 7) + require.NoError(t, err) + require.Len(t, loaded, 1) + if len(tt.multipliers) == 0 { + require.Empty(t, loaded[0].ReasoningEffortMultipliers) + } else { + require.Equal(t, tt.multipliers, loaded[0].ReasoningEffortMultipliers) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } + } +} + +func TestChannelReasoningEffortMultipliersBatchLoad(t *testing.T) { + repo, mock := newChannelModelPricingTimePricingRepo(t) + mock.ExpectQuery(`(?s)SELECT .*reasoning_effort_multipliers.*FROM channel_model_pricing.*channel_id = ANY`). + WithArgs(pq.Array([]int64{7})).WillReturnRows(reasoningPricingRow(`{"medium":1.25,"high":2}`)) + expectEmptyModelPricingIntervals(mock) + pricing, err := repo.batchLoadModelPricing(context.Background(), []int64{7}) + require.NoError(t, err) + require.Len(t, pricing[7], 1) + require.Equal(t, map[string]float64{"medium": 1.25, "high": 2}, pricing[7][0].ReasoningEffortMultipliers) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestChannelReasoningEffortMultipliersInvalidJSON(t *testing.T) { + for _, data := range []string{`{"high":`, `{"high":"2"}`, `[]`} { + t.Run(data, func(t *testing.T) { + repo, mock := newChannelModelPricingTimePricingRepo(t) + mock.ExpectQuery(`SELECT .*reasoning_effort_multipliers`).WithArgs(int64(7)).WillReturnRows(reasoningPricingRow(data)) + _, err := repo.ListModelPricing(context.Background(), 7) + require.ErrorContains(t, err, "unmarshal reasoning effort multipliers") + require.NoError(t, mock.ExpectationsWereMet()) + }) + } + for _, operation := range []string{"create", "update"} { + t.Run(operation+" invalid number", func(t *testing.T) { + repo, mock := newChannelModelPricingTimePricingRepo(t) + pricing := &service.ChannelModelPricing{ReasoningEffortMultipliers: map[string]float64{"max": math.NaN()}} + var err error + if operation == "create" { + err = repo.CreateModelPricing(context.Background(), pricing) + } else { + err = repo.UpdateModelPricing(context.Background(), pricing) + } + require.ErrorContains(t, err, "marshal reasoning effort multipliers") + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestAccountStatsReasoningEffortMultipliersRoundTrip(t *testing.T) { + repo, mock := newChannelModelPricingTimePricingRepo(t) + ctx := context.Background() + pricing := &service.ChannelModelPricing{ + Platform: "anthropic", Models: []string{"custom-model"}, + ReasoningEffortMultipliers: map[string]float64{"medium": 0.5, "high": 2}, + } + stored := &capturedReasoningMultipliersJSON{} + mock.ExpectBegin() + tx, err := repo.db.BeginTx(ctx, nil) + require.NoError(t, err) + mock.ExpectQuery(`INSERT INTO channel_account_stats_model_pricing .*reasoning_effort_multipliers`). + WithArgs(int64(9), "anthropic", []byte(`["custom-model"]`), service.BillingModeToken, + nil, nil, nil, nil, nil, stored, nil, nil). + WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{})) + require.NoError(t, createAccountStatsModelPricingTx(ctx, tx, 9, pricing)) + mock.ExpectCommit() + require.NoError(t, tx.Commit()) + require.JSONEq(t, `{"medium":0.5,"high":2}`, stored.value) + + columns := []string{ + "id", "rule_id", "platform", "models", "billing_mode", "input_price", "output_price", + "cache_write_price", "cache_write_1h_price", "cache_read_price", "reasoning_effort_multipliers", + "image_output_price", "per_request_price", "created_at", "updated_at", + } + mock.ExpectQuery(`(?s)SELECT .*reasoning_effort_multipliers.*FROM channel_account_stats_model_pricing`). + WithArgs(pq.Array([]int64{9})).WillReturnRows(sqlmock.NewRows(columns).AddRow( + int64(11), int64(9), "anthropic", `["custom-model"]`, service.BillingModeToken, + nil, nil, nil, nil, nil, stored.value, nil, nil, time.Time{}, time.Time{}, + )) + mock.ExpectQuery(`(?s)SELECT .*FROM channel_account_stats_pricing_intervals`). + WithArgs(pq.Array([]int64{11})).WillReturnRows(sqlmock.NewRows([]string{"id"})) + loaded, err := repo.batchLoadAccountStatsModelPricing(ctx, []int64{9}) + require.NoError(t, err) + require.Len(t, loaded[9], 1) + require.Equal(t, pricing.ReasoningEffortMultipliers, loaded[9][0].ReasoningEffortMultipliers) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/backend/internal/repository/channel_repo_pricing_time_test.go b/backend/internal/repository/channel_repo_pricing_time_test.go index c22630abcf..a58cb4e5d9 100644 --- a/backend/internal/repository/channel_repo_pricing_time_test.go +++ b/backend/internal/repository/channel_repo_pricing_time_test.go @@ -16,7 +16,7 @@ import ( var channelModelPricingTimePricingColumns = []string{ "id", "channel_id", "platform", "models", "billing_mode", "input_price", "output_price", - "cache_write_price", "cache_write_1h_price", "cache_read_price", "fast_multiplier", "flex_multiplier", "max_reasoning_effort_multiplier", "image_input_price", "image_output_price", + "cache_write_price", "cache_write_1h_price", "cache_read_price", "fast_multiplier", "flex_multiplier", "reasoning_effort_multipliers", "image_input_price", "image_output_price", "per_request_price", "time_pricing", "created_at", "updated_at", } @@ -33,7 +33,7 @@ func newChannelModelPricingTimePricingRepo(t *testing.T) (*channelRepository, sq func modelPricingTimePricingRow(timePricing any) *sqlmock.Rows { return sqlmock.NewRows(channelModelPricingTimePricingColumns).AddRow( int64(11), int64(7), "openai", `["gpt-5"]`, service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, timePricing, + nil, nil, nil, nil, nil, nil, nil, "{}", nil, nil, nil, timePricing, time.Date(2026, 8, 17, 0, 0, 0, 0, time.UTC), time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC), ) } @@ -105,10 +105,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateRoundTrip(t *testing.T) { t.Run("create writes JSON", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). + mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, reasoning_effort_multipliers, image_input_price, image_output_price, per_request_price, time_pricing)")). WithArgs( int64(7), "openai", []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, + nil, nil, nil, nil, nil, nil, nil, "{}", nil, nil, nil, channelModelPricingTimePricingJSON, ). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{})) @@ -121,7 +121,7 @@ func TestChannelModelPricingTimePricingCreateAndUpdateRoundTrip(t *testing.T) { mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$13, time_pricing = \$14, platform = \$15.*WHERE id = \$16`). WithArgs( []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11), + nil, nil, nil, nil, nil, nil, nil, "{}", nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11), ). WillReturnResult(sqlmock.NewResult(0, 1)) @@ -153,10 +153,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateWriteNullWhenDisabled(t *t t.Run("create writes SQL NULL", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). + mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, reasoning_effort_multipliers, image_input_price, image_output_price, per_request_price, time_pricing)")). WithArgs( int64(7), "openai", []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + nil, nil, nil, nil, nil, nil, nil, "{}", nil, nil, nil, nil, ). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{})) @@ -169,7 +169,7 @@ func TestChannelModelPricingTimePricingCreateAndUpdateWriteNullWhenDisabled(t *t mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$13, time_pricing = \$14, platform = \$15.*WHERE id = \$16`). WithArgs( []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11), + nil, nil, nil, nil, nil, nil, nil, "{}", nil, nil, nil, nil, "openai", int64(11), ). WillReturnResult(sqlmock.NewResult(0, 1)) diff --git a/backend/internal/service/account_stats_pricing.go b/backend/internal/service/account_stats_pricing.go index f27d5bb367..4a738ccf77 100644 --- a/backend/internal/service/account_stats_pricing.go +++ b/backend/internal/service/account_stats_pricing.go @@ -19,7 +19,7 @@ import ( // totalCost 是本次请求的客户计费(倍率前),用于优先级 2。 // serviceTier 是最终参与用户计费的 OpenAI 服务层级,用于优先级 3。 // pricingAt 与本次客户计费使用同一时刻,避免跨峰谷请求的成本与售价错位。 -// reasoningEffort 是最终转发等级;Fable 5.1 max 默认按 3 倍额度消耗。 +// reasoningEffort 是最终转发等级;按账号统计定价中配置的等级倍率计费。 func resolveAccountStatsCost( ctx context.Context, channelService *ChannelService, @@ -116,7 +116,7 @@ func tryCustomRules( } cost := calculateStatsCost(pricing, tokens, requestCount) if cost != nil { - *cost *= maxReasoningEffortBillingMultiplier(model, reasoningEffort, nil) + *cost *= reasoningEffortBillingMultiplier(reasoningEffort, pricing.ReasoningEffortMultipliers) } return cost } @@ -192,7 +192,7 @@ func calculateStatsCost(pricing *ChannelModelPricing, tokens UsageTokens, reques return nil } switch pricing.BillingMode { - case BillingModePerRequest, BillingModeImage: + case BillingModePerRequest, BillingModeImage, BillingModeVideo: return calculatePerRequestStatsCost(pricing, requestCount) default: return calculateTokenStatsCost(pricing, tokens) diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index e53810b279..622fc73f3c 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -478,7 +478,7 @@ func TestTryModelFilePricing_Success(t *testing.T) { require.InDelta(t, 0.2, *result, 1e-12) } -func TestTryModelFilePricing_Fable51MaxEffortUsesTripleQuota(t *testing.T) { +func TestTryModelFilePricing_Fable51HasNoImplicitReasoningMultiplier(t *testing.T) { bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{ "claude-fable-5-1": {InputPricePerToken: 0.001}, }) @@ -487,7 +487,7 @@ func TestTryModelFilePricing_Fable51MaxEffortUsesTripleQuota(t *testing.T) { max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, "max") require.NotNil(t, standard) require.NotNil(t, max) - require.InDelta(t, *standard*3, *max, 1e-12) + require.Equal(t, *standard, *max) } func TestTryModelFilePricing_AppliesLongContextPricing(t *testing.T) { diff --git a/backend/internal/service/antigravity_gateway_gemini.go b/backend/internal/service/antigravity_gateway_gemini.go index 777c1de9f1..0cede4c0e8 100644 --- a/backend/internal/service/antigravity_gateway_gemini.go +++ b/backend/internal/service/antigravity_gateway_gemini.go @@ -467,6 +467,7 @@ handleSuccess: UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), Stream: stream, + ReasoningEffort: extractGeminiReasoningEffortFromBody(injectedBody), Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, ClientDisconnect: clientDisconnect, diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 55e4f08d9b..29161ce338 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log" + "maps" "math" "strings" "sync" @@ -93,29 +94,29 @@ type BillingCache interface { // ModelPricing 模型价格配置(per-token价格,与LiteLLM格式一致) type ModelPricing struct { - InputPricePerToken float64 // 每token输入价格 (USD) - InputPricePerTokenPriority float64 // priority service tier 下每token输入价格 (USD) - ImageInputPricePerToken float64 // 图片输入 token 价格 (USD),用于多模态 embedding 等图文不同价场景;为 0 时回退到 InputPricePerToken - ImageCacheReadPricePerToken float64 // 图片缓存输入价格;无独立价格时沿用缓存读取价 - OutputPricePerToken float64 // 每token输出价格 (USD) - OutputPricePerTokenPriority float64 // priority service tier 下每token输出价格 (USD) - CacheCreationPricePerToken float64 // 缓存创建每token价格 (USD) - CacheCreationPricePerTokenPriority float64 // priority service tier 下缓存创建每token价格 (USD) - CacheCreationPriceExplicit bool // 是否由渠道/区间定价显式设定(为 true 时即使 == 0 也不回退) - CacheReadPricePerToken float64 // 缓存读取每token价格 (USD) - CacheReadPricePerTokenPriority float64 // priority service tier 下缓存读取每token价格 (USD) - FastMultiplier *float64 // 渠道显式 Fast/priority 倍率;nil 时沿用模型目录行为 - FlexMultiplier *float64 // 渠道显式 Flex 倍率;nil 时沿用默认行为 - MaxReasoningEffortMultiplier *float64 // max 推理等级的额度/计费倍率;nil 时沿用模型默认行为 - CacheCreation5mPrice float64 // 5分钟缓存创建每token价格 (USD) - CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD) - SupportsCacheBreakdown bool // 是否支持详细的缓存分类 - LongContextInputThreshold int // 超过阈值后按整次会话提升输入价格 - LongContextThresholdInclusive bool // 达到阈值即应用(xAI);默认保持严格大于以兼容既有模型 - LongContextInputMultiplier float64 // 长上下文整次会话输入倍率 - LongContextOutputMultiplier float64 // 长上下文整次会话输出倍率 - ImageOutputPricePerToken float64 // 图片输出 token 价格 (USD) - ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退) + InputPricePerToken float64 // 每token输入价格 (USD) + InputPricePerTokenPriority float64 // priority service tier 下每token输入价格 (USD) + ImageInputPricePerToken float64 // 图片输入 token 价格 (USD),用于多模态 embedding 等图文不同价场景;为 0 时回退到 InputPricePerToken + ImageCacheReadPricePerToken float64 // 图片缓存输入价格;无独立价格时沿用缓存读取价 + OutputPricePerToken float64 // 每token输出价格 (USD) + OutputPricePerTokenPriority float64 // priority service tier 下每token输出价格 (USD) + CacheCreationPricePerToken float64 // 缓存创建每token价格 (USD) + CacheCreationPricePerTokenPriority float64 // priority service tier 下缓存创建每token价格 (USD) + CacheCreationPriceExplicit bool // 是否由渠道/区间定价显式设定(为 true 时即使 == 0 也不回退) + CacheReadPricePerToken float64 // 缓存读取每token价格 (USD) + CacheReadPricePerTokenPriority float64 // priority service tier 下缓存读取每token价格 (USD) + FastMultiplier *float64 // 渠道显式 Fast/priority 倍率;nil 时沿用模型目录行为 + FlexMultiplier *float64 // 渠道显式 Flex 倍率;nil 时沿用默认行为 + ReasoningEffortMultipliers map[string]float64 // 最终转发的推理等级对应的计费倍率;未配置的等级按 1 倍计费 + CacheCreation5mPrice float64 // 5分钟缓存创建每token价格 (USD) + CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD) + SupportsCacheBreakdown bool // 是否支持详细的缓存分类 + LongContextInputThreshold int // 超过阈值后按整次会话提升输入价格 + LongContextThresholdInclusive bool // 达到阈值即应用(xAI);默认保持严格大于以兼容既有模型 + LongContextInputMultiplier float64 // 长上下文整次会话输入倍率 + LongContextOutputMultiplier float64 // 长上下文整次会话输出倍率 + ImageOutputPricePerToken float64 // 图片输出 token 价格 (USD) + ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退) } func normalizeBillingServiceTier(serviceTier string) string { @@ -217,8 +218,6 @@ func applyCostBreakdownMultiplier(cost *CostBreakdown, multiplier float64) { cost.ActualCost *= multiplier } -const claudeFable51MaxReasoningEffortMultiplier = 3.0 - func isClaudeFable51Model(model string) bool { model = strings.ToLower(strings.TrimSpace(model)) for _, marker := range []string{"fable-5-1", "fable-5.1", "fable5.1", "fable51"} { @@ -232,25 +231,19 @@ func isClaudeFable51Model(model string) bool { return false } -func defaultMaxReasoningEffortMultiplier(model string) *float64 { - if !isClaudeFable51Model(model) { - return nil +func reasoningEffortBillingMultiplier(effort string, multipliers map[string]float64) float64 { + effort = strings.ToLower(strings.TrimSpace(effort)) + if effort != "none" { + effort = NormalizeMaxReasoningEffort(effort) } - multiplier := claudeFable51MaxReasoningEffortMultiplier - return &multiplier -} - -func maxReasoningEffortBillingMultiplier(model, effort string, pricing *ModelPricing) float64 { - if NormalizeMaxReasoningEffort(effort) != "max" { + if effort == "" { return 1 } - if pricing != nil && pricing.MaxReasoningEffortMultiplier != nil && *pricing.MaxReasoningEffortMultiplier > 0 { - return *pricing.MaxReasoningEffortMultiplier + multiplier := multipliers[effort] + if multiplier <= 0 || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) { + return 1 } - if multiplier := defaultMaxReasoningEffortMultiplier(model); multiplier != nil { - return *multiplier - } - return 1 + return multiplier } func resolvedChannelTimeMultiplier(resolved *ResolvedPricing, at time.Time) float64 { @@ -1311,9 +1304,7 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing applyChannelTokenPriceOverrides(pricing, channelPricing) pricing.FastMultiplier = channelPricing.FastMultiplier pricing.FlexMultiplier = channelPricing.FlexMultiplier - if channelPricing.MaxReasoningEffortMultiplier != nil { - pricing.MaxReasoningEffortMultiplier = channelPricing.MaxReasoningEffortMultiplier - } + pricing.ReasoningEffortMultipliers = maps.Clone(channelPricing.ReasoningEffortMultipliers) if channelPricing.ImageOutputPrice != nil { pricing.ImageOutputPricePerToken = *channelPricing.ImageOutputPrice } else { @@ -1386,7 +1377,7 @@ type CostInput struct { RateMultiplier float64 PricingAt time.Time // 渠道分时定价使用的计费时刻 ServiceTier string // "priority","flex","" 等 - ReasoningEffort string // 最终转发的推理等级;max 可触发模型/渠道倍率 + ReasoningEffort string // 最终转发的推理等级,按匹配定价中配置的等级倍率计费 Resolver *ModelPricingResolver // 定价解析器 Resolved *ResolvedPricing // 可选:预解析的定价结果(避免重复 Resolve 调用) LongContextBillingEnabled *bool @@ -1401,18 +1392,13 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown, if input.LongContextBillingEnabled != nil { applyLongContextBilling = *input.LongContextBillingEnabled } - breakdown, err := s.calculateCostInternalWithPolicy( - input.Model, - input.Tokens, - input.RateMultiplier, - input.ServiceTier, - nil, - applyLongContextBilling, - ) - if err == nil { - applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(input.Model, input.ReasoningEffort, nil)) + pricing, err := s.GetModelPricing(input.Model) + if err != nil { + return nil, err } - return breakdown, err + breakdown := s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongContextBilling) + applyCostBreakdownMultiplier(breakdown, reasoningEffortBillingMultiplier(input.ReasoningEffort, pricing.ReasoningEffortMultipliers)) + return breakdown, nil } // 优先使用预解析结果,避免重复 Resolve 调用 @@ -1435,6 +1421,9 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown, switch resolved.Mode { case BillingModePerRequest, BillingModeImage, BillingModeVideo: breakdown, err = s.calculatePerRequestCost(resolved, input) + if err == nil && resolved.channelPricing != nil { + applyCostBreakdownMultiplier(breakdown, reasoningEffortBillingMultiplier(input.ReasoningEffort, resolved.channelPricing.ReasoningEffortMultipliers)) + } default: // BillingModeToken breakdown, err = s.calculateTokenCost(resolved, input) } @@ -1498,7 +1487,7 @@ func (s *BillingService) calculateTokenCost(resolved *ResolvedPricing, input Cos breakdown := s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongCtx) applyCostBreakdownMultiplier(breakdown, resolvedChannelTimeMultiplier(resolved, input.PricingAt)) - applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(input.Model, input.ReasoningEffort, pricing)) + applyCostBreakdownMultiplier(breakdown, reasoningEffortBillingMultiplier(input.ReasoningEffort, pricing.ReasoningEffortMultipliers)) return breakdown, nil } @@ -1790,17 +1779,13 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing } normalized := normalizeKnownOpenAICodexModel(model) isGPT56 := isOpenAIGPT56Model(normalized) - needsMaxReasoningEffortMultiplier := isClaudeFable51Model(model) && pricing.MaxReasoningEffortMultiplier == nil needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 || (pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0)) fastRatio := openAIModelFastPricingRatio(normalized) - if !needsCacheCreationPolicy && fastRatio <= 0 && !needsMaxReasoningEffortMultiplier { + if !needsCacheCreationPolicy && fastRatio <= 0 { return pricing } cloned := *pricing - if needsMaxReasoningEffortMultiplier { - cloned.MaxReasoningEffortMultiplier = defaultMaxReasoningEffortMultiplier(model) - } if isGPT56 && !cloned.CacheCreationPriceExplicit { if cloned.CacheCreationPricePerToken <= 0 { cloned.CacheCreationPricePerToken = cloned.InputPricePerToken * 1.25 diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 0a68e07ad7..59f20e0762 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -1807,8 +1807,7 @@ func TestGetModelPricing_Fable51FallbackPricing(t *testing.T) { require.InDelta(t, 12.5e-6, pricing.CacheCreation5mPrice, 1e-12) require.InDelta(t, 20e-6, pricing.CacheCreation1hPrice, 1e-12) require.InDelta(t, 0.25e-6, pricing.CacheReadPricePerToken, 1e-12) - require.NotNil(t, pricing.MaxReasoningEffortMultiplier) - require.Equal(t, 3.0, *pricing.MaxReasoningEffortMultiplier) + require.Empty(t, pricing.ReasoningEffortMultipliers) } func TestGetModelPricingWithChannel_CacheReadPriceAffectsPriority(t *testing.T) { diff --git a/backend/internal/service/billing_token_cost_request_test.go b/backend/internal/service/billing_token_cost_request_test.go index 23fae53557..fe4390b227 100644 --- a/backend/internal/service/billing_token_cost_request_test.go +++ b/backend/internal/service/billing_token_cost_request_test.go @@ -217,7 +217,7 @@ func TestCalculateTokenCostForRequest_NoResolverFallsBackToCatalog(t *testing.T) require.Equal(t, want, got) } -func TestCalculateTokenCostForRequest_Fable51MaxEffortUsesDefaultMultiplier(t *testing.T) { +func TestCalculateTokenCostForRequest_Fable51HasNoImplicitReasoningMultiplier(t *testing.T) { bs := NewBillingService(&config.Config{}, nil) tokens := UsageTokens{InputTokens: 1000, OutputTokens: 10} standard, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ @@ -228,18 +228,15 @@ func TestCalculateTokenCostForRequest_Fable51MaxEffortUsesDefaultMultiplier(t *t Model: "claude-fable-5-1", Tokens: tokens, RateMultiplier: 1, ReasoningEffort: "max", }) require.NoError(t, err) - require.InDelta(t, standard.TotalCost*3, max.TotalCost, 1e-12) - require.InDelta(t, standard.ActualCost*3, max.ActualCost, 1e-12) - require.InDelta(t, standard.InputCost*3, max.InputCost, 1e-12) - require.InDelta(t, standard.OutputCost*3, max.OutputCost, 1e-12) + require.Equal(t, standard, max) } -func TestCalculateTokenCostForRequest_ChannelOverridesFable51MaxEffortMultiplier(t *testing.T) { +func TestCalculateTokenCostForRequest_ChannelConfiguresReasoningEffortMultiplier(t *testing.T) { configured := 1.5 bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, []ChannelModelPricing{{ Platform: PlatformAnthropic, Models: []string{"claude-fable-5-1"}, BillingMode: BillingModeToken, InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(50e-6), - MaxReasoningEffortMultiplier: &configured, + ReasoningEffortMultipliers: map[string]float64{"max": configured}, }}, nil) group := &Group{ID: 100, Platform: PlatformAnthropic} gid := group.ID diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index f9fea3bcc9..43dfbc2407 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -2,6 +2,7 @@ package service import ( "fmt" + "maps" "sort" "strings" "time" @@ -87,26 +88,26 @@ type AccountStatsPricingRule struct { // ChannelModelPricing 渠道模型定价条目 type ChannelModelPricing struct { - ID int64 `json:"id,omitempty"` - ChannelID int64 `json:"channel_id,omitempty"` - Platform string `json:"platform"` // 所属平台(anthropic/openai/gemini/...) - Models []string `json:"models"` - BillingMode BillingMode `json:"billing_mode"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - FastMultiplier *float64 `json:"fast_multiplier"` - FlexMultiplier *float64 `json:"flex_multiplier"` - MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"` - ImageInputPrice *float64 `json:"image_input_price"` - ImageOutputPrice *float64 `json:"image_output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - Intervals []PricingInterval `json:"intervals"` - TimePricing *ChannelTimePricing `json:"time_pricing,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + ID int64 `json:"id,omitempty"` + ChannelID int64 `json:"channel_id,omitempty"` + Platform string `json:"platform"` // 所属平台(anthropic/openai/gemini/...) + Models []string `json:"models"` + BillingMode BillingMode `json:"billing_mode"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + FastMultiplier *float64 `json:"fast_multiplier"` + FlexMultiplier *float64 `json:"flex_multiplier"` + ReasoningEffortMultipliers map[string]float64 `json:"reasoning_effort_multipliers,omitempty"` + ImageInputPrice *float64 `json:"image_input_price"` + ImageOutputPrice *float64 `json:"image_output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + Intervals []PricingInterval `json:"intervals"` + TimePricing *ChannelTimePricing `json:"time_pricing,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` } // ChannelTimePricing 渠道模型定价的分时倍率配置。 @@ -208,9 +209,10 @@ func (p *ChannelModelPricing) GetTierByLabel(label string) *PricingInterval { return nil } -// Clone 返回 ChannelModelPricing 的拷贝(切片独立,指针字段共享,调用方只读安全) +// Clone 返回 ChannelModelPricing 的拷贝(切片和映射独立,价格指针字段共享,调用方只读安全) func (p ChannelModelPricing) Clone() ChannelModelPricing { cp := p + cp.ReasoningEffortMultipliers = maps.Clone(p.ReasoningEffortMultipliers) if p.Models != nil { cp.Models = make([]string, len(p.Models)) copy(cp.Models, p.Models) diff --git a/backend/internal/service/channel_available.go b/backend/internal/service/channel_available.go index 46eb15d6bb..262a384821 100644 --- a/backend/internal/service/channel_available.go +++ b/backend/internal/service/channel_available.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "maps" "sort" "strings" ) @@ -117,7 +118,7 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, // 1. Pricing == nil(渠道完全没声明该模型的定价条目) // 2. Pricing 非 nil 但所有价格字段为空(admin UI 建了条目但没填价格) // -// 当 pricingService 为 nil(测试场景),跳过价格回落,但仍补充内置模型倍率。 +// 当 pricingService 为 nil(测试场景),跳过价格回落。 // 可用渠道与模型广场共用。 func fillGlobalPricingFallback(pricingService *PricingService, models []SupportedModel) { for i := range models { @@ -126,7 +127,6 @@ func fillGlobalPricingFallback(pricingService *PricingService, models []Supporte models[i].Pricing = synthesizePricingFromLiteLLM(lp, models[i].Pricing) } } - models[i].Pricing = withDefaultMaxReasoningEffortMultiplier(models[i].Pricing, models[i].Name) } } @@ -176,31 +176,31 @@ func synthesizePricingFromLiteLLM(lp *LiteLLMModelPricing, existing *ChannelMode if mode == BillingModeImage || mode == BillingModePerRequest { return &ChannelModelPricing{ - BillingMode: mode, - PerRequestPrice: nonZeroPtr(lp.OutputCostPerImage), - ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), - InputPrice: nonZeroPtr(lp.InputCostPerToken), - OutputPrice: nonZeroPtr(lp.OutputCostPerToken), - MaxReasoningEffortMultiplier: maxReasoningEffortMultiplierFromPricing(existing), + BillingMode: mode, + PerRequestPrice: nonZeroPtr(lp.OutputCostPerImage), + ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), + InputPrice: nonZeroPtr(lp.InputCostPerToken), + OutputPrice: nonZeroPtr(lp.OutputCostPerToken), + ReasoningEffortMultipliers: reasoningEffortMultipliersFromPricing(existing), } } return &ChannelModelPricing{ - BillingMode: mode, - InputPrice: nonZeroPtr(lp.InputCostPerToken), - OutputPrice: nonZeroPtr(lp.OutputCostPerToken), - CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost), - CacheWrite1hPrice: nonZeroPtr(lp.CacheCreationInputTokenCostAbove1hr), - CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost), - ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), - MaxReasoningEffortMultiplier: maxReasoningEffortMultiplierFromPricing(existing), + BillingMode: mode, + InputPrice: nonZeroPtr(lp.InputCostPerToken), + OutputPrice: nonZeroPtr(lp.OutputCostPerToken), + CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost), + CacheWrite1hPrice: nonZeroPtr(lp.CacheCreationInputTokenCostAbove1hr), + CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost), + ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), + ReasoningEffortMultipliers: reasoningEffortMultipliersFromPricing(existing), } } -func maxReasoningEffortMultiplierFromPricing(pricing *ChannelModelPricing) *float64 { +func reasoningEffortMultipliersFromPricing(pricing *ChannelModelPricing) map[string]float64 { if pricing == nil { return nil } - return pricing.MaxReasoningEffortMultiplier + return maps.Clone(pricing.ReasoningEffortMultipliers) } func nonZeroPtr(v float64) *float64 { diff --git a/backend/internal/service/channel_reasoning_pricing_test.go b/backend/internal/service/channel_reasoning_pricing_test.go new file mode 100644 index 0000000000..b1b03e3ce5 --- /dev/null +++ b/backend/internal/service/channel_reasoning_pricing_test.go @@ -0,0 +1,42 @@ +//go:build unit + +package service + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidatePricingEntries_ReasoningEffortMultipliers(t *testing.T) { + for _, tc := range []struct { + name string + multipliers map[string]float64 + valid bool + }{ + {"unset", nil, true}, + {"cleared", map[string]float64{}, true}, + {"all levels", map[string]float64{"none": 0.5, "minimal": 1, "low": 1.25, "medium": 1.5, "high": 2, "xhigh": 2.5, "max": 3}, true}, + {"unknown level", map[string]float64{"ultra": 2}, false}, + {"blank level", map[string]float64{"": 2}, false}, + {"noncanonical level", map[string]float64{"MAX": 2}, false}, + {"zero", map[string]float64{"high": 0}, false}, + {"negative", map[string]float64{"high": -1}, false}, + {"nan", map[string]float64{"high": math.NaN()}, false}, + {"infinity", map[string]float64{"high": math.Inf(1)}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + pricing := []ChannelModelPricing{{Models: []string{"custom-model"}, ReasoningEffortMultipliers: tc.multipliers}} + err := validatePricingEntries(pricing) + statsErr := validateAccountStatsPricingRules([]AccountStatsPricingRule{{Pricing: pricing}}) + if tc.valid { + require.NoError(t, err) + require.NoError(t, statsErr) + } else { + require.Error(t, err) + require.Error(t, statsErr) + } + }) + } +} diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index 0d34f44014..fb59d7f7bb 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "math" "strings" "sync/atomic" "time" @@ -687,9 +688,30 @@ func validatePricingEntries(pricing []ChannelModelPricing) error { if err := validatePricingBillingMode(pricing); err != nil { return err } + if err := validateReasoningEffortMultipliers(pricing); err != nil { + return err + } return validatePricingTimePricing(pricing) } +func validateReasoningEffortMultipliers(pricing []ChannelModelPricing) error { + for _, p := range pricing { + for effort, multiplier := range p.ReasoningEffortMultipliers { + switch effort { + case "none", "minimal", "low", "medium", "high", "xhigh", "max": + default: + return infraerrors.BadRequest("INVALID_REASONING_EFFORT_MULTIPLIER", + fmt.Sprintf("unsupported reasoning effort %q for models %v", effort, p.Models)) + } + if math.IsNaN(multiplier) || math.IsInf(multiplier, 0) || multiplier <= 0 { + return infraerrors.BadRequest("INVALID_REASONING_EFFORT_MULTIPLIER", + fmt.Sprintf("reasoning_effort_multipliers.%s must be a finite number > 0", effort)) + } + } + } + return nil +} + func validatePricingTimePricing(pricing []ChannelModelPricing) error { for i := range pricing { config := pricing[i].TimePricing diff --git a/backend/internal/service/channel_test.go b/backend/internal/service/channel_test.go index 42c2c46448..973a8cfd08 100644 --- a/backend/internal/service/channel_test.go +++ b/backend/internal/service/channel_test.go @@ -193,7 +193,8 @@ func TestChannelClone_Nil(t *testing.T) { func TestChannelModelPricingClone(t *testing.T) { original := ChannelModelPricing{ - Models: []string{"a", "b"}, + ReasoningEffortMultipliers: map[string]float64{"high": 1.5}, + Models: []string{"a", "b"}, Intervals: []PricingInterval{ {MinTokens: 0, TierLabel: "tier1"}, }, @@ -209,6 +210,8 @@ func TestChannelModelPricingClone(t *testing.T) { } cloned := original.Clone() + cloned.ReasoningEffortMultipliers["high"] = 2 + require.Equal(t, 1.5, original.ReasoningEffortMultipliers["high"]) // Modify clone slices — original unchanged cloned.Models[0] = "hacked" diff --git a/backend/internal/service/gateway_compat_reasoning_pricing_test.go b/backend/internal/service/gateway_compat_reasoning_pricing_test.go new file mode 100644 index 0000000000..d06c623d06 --- /dev/null +++ b/backend/internal/service/gateway_compat_reasoning_pricing_test.go @@ -0,0 +1,115 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestGatewayAnthropicCompatReasoningPricingUsesForwardedEffort(t *testing.T) { + gin.SetMode(gin.TestMode) + endpoints := []struct { + name string + path string + call func(*GatewayService, context.Context, *gin.Context, *Account, []byte) (*ForwardResult, error) + }{ + {"chat_completions", "/v1/chat/completions", func(s *GatewayService, ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) { + return s.ForwardAsChatCompletions(ctx, c, account, body, nil) + }}, + {"responses", "/v1/responses", func(s *GatewayService, ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) { + return s.ForwardAsResponses(ctx, c, account, body, nil) + }}, + } + for _, endpoint := range endpoints { + for _, stream := range []bool{false, true} { + for _, tc := range []struct { + name string + model string + effort string + thinking string + wantEffort string + multiplier float64 + }{ + {"xhigh_converts_to_max", "claude-fable-5-1", "xhigh", "", "max", 3}, + {"native_max", "claude-fable-5-1", "max", "", "max", 3}, + {"missing_effort", "kimi-k3", "", "", "", 1}, + {"disabled_thinking", "kimi-k3", "", "disabled", "", 1}, + {"unforwarded_thinking", "kimi-k3", "", "enabled", "", 1}, + } { + mode := "buffered" + if stream { + mode = "streaming" + } + t.Run(endpoint.name+"/"+mode+"/"+tc.name, func(t *testing.T) { + request := map[string]any{"model": tc.model, "stream": stream} + if endpoint.name == "chat_completions" { + request["messages"] = []map[string]string{{"role": "user", "content": "hello"}} + if tc.effort != "" { + request["reasoning_effort"] = tc.effort + } + } else { + request["input"] = "hello" + if tc.effort != "" { + request["reasoning"] = map[string]string{"effort": tc.effort} + } + } + if tc.thinking != "" { + request["thinking"] = map[string]string{"type": tc.thinking} + } + body, err := json.Marshal(request) + require.NoError(t, err) + upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(namespaceToolAnthropicStream())), + }}} + svc := &GatewayService{ + cfg: &config.Config{}, httpUpstream: upstream, + tlsFPProfileService: &TLSFingerprintProfileService{}, + } + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, endpoint.path, strings.NewReader(string(body))) + account := &Account{ + ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "test-key"}, + } + result, err := endpoint.call(svc, context.Background(), c, account, body) + require.NoError(t, err) + require.Len(t, upstream.requestBodies, 1) + require.Equal(t, tc.wantEffort, gjson.GetBytes(upstream.requestBodies[0], "output_config.effort").String()) + require.Equal(t, tc.wantEffort, optionalStringValue(result.ReasoningEffort)) + if tc.wantEffort == "" { + require.Nil(t, result.ReasoningEffort) + require.False(t, OpenAIBodyHasThinkingEnabled(upstream.requestBodies[0])) + } + + billing := NewBillingService(&config.Config{}, nil) + group := &Group{ID: 1, Platform: PlatformAnthropic, ModelPricing: []ChannelModelPricing{{ + Models: []string{tc.model}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(1e-6), OutputPrice: testPtrFloat64(2e-6), + ReasoningEffortMultipliers: map[string]float64{"xhigh": 2, "max": 3, "high": 1.5}, + }}} + cost, err := billing.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: result.Model, Group: group, + Tokens: UsageTokens{InputTokens: result.Usage.InputTokens, OutputTokens: result.Usage.OutputTokens}, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), RateMultiplier: 1, + Resolver: NewModelPricingResolver(nil, billing), + }) + require.NoError(t, err) + require.InDelta(t, 20e-6*tc.multiplier, cost.TotalCost, 1e-12) + }) + } + } + } +} diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 32d38cf40d..894e853556 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -119,11 +119,15 @@ func (s *GatewayService) ForwardAsChatCompletions( // 10. Build upstream request upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream) - upstreamReq, _, err := s.buildUpstreamRequest(upstreamCtx, c, account, anthropicBody, token, tokenType, mappedModel, reqStream, shouldMimicClaudeCode) + upstreamReq, forwardedBody, err := s.buildUpstreamRequest(upstreamCtx, c, account, anthropicBody, token, tokenType, mappedModel, reqStream, shouldMimicClaudeCode) releaseUpstreamCtx() if err != nil { return nil, fmt.Errorf("build upstream request: %w", err) } + // Bill the final Anthropic effort after conversion and account normalization. + // For example, OpenAI xhigh is forwarded as output_config.effort=max. + reasoningEffort := NormalizeClaudeOutputEffort(gjson.GetBytes(forwardedBody, "output_config.effort").String()) + reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, forwardedBody, mappedModel) // 11. Send request resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) @@ -173,14 +177,7 @@ func (s *GatewayService) ForwardAsChatCompletions( return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg) } - // 13. Extract reasoning effort from CC request body - reasoningEffort := extractCCReasoningEffortFromBody(body, mappedModel, originalModel) - // 国产模型默认 effort 补充:本路径是客户端 CC 请求 → Anthropic 上游, - // 如果上游是 passback-required 国产模型 (Kimi-anthropic / GLM-anthropic / MiniMax) - // 且客户端在 body 里传了 thinking.type=enabled,补中默认 effort。 - reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, mappedModel) - - // 14. Handle normal response + // 13. Handle normal response // Read Anthropic SSE → convert to Responses events → convert to CC format var result *ForwardResult var handleErr error diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 1b97a73961..999b544adc 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -85,9 +85,6 @@ func (s *GatewayService) ForwardAsResponses( mappedModel = normalized } } - reasoningEffort := ExtractResponsesReasoningEffortFromBody(body, mappedModel, originalModel) - // 国产模型默认 effort 补充:需要 mappedModel 判定,推迟到 mapping 完成之后。 - reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, mappedModel) anthropicReq.Model = mappedModel logger.L().Debug("gateway forward_as_responses: model mapping applied", @@ -132,11 +129,15 @@ func (s *GatewayService) ForwardAsResponses( // 10. Build upstream request upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream) - upstreamReq, _, err := s.buildUpstreamRequest(upstreamCtx, c, account, anthropicBody, token, tokenType, mappedModel, reqStream, shouldMimicClaudeCode) + upstreamReq, forwardedBody, err := s.buildUpstreamRequest(upstreamCtx, c, account, anthropicBody, token, tokenType, mappedModel, reqStream, shouldMimicClaudeCode) releaseUpstreamCtx() if err != nil { return nil, fmt.Errorf("build upstream request: %w", err) } + // Bill the final Anthropic effort after conversion and account normalization. + // For example, OpenAI xhigh is forwarded as output_config.effort=max. + reasoningEffort := NormalizeClaudeOutputEffort(gjson.GetBytes(forwardedBody, "output_config.effort").String()) + reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, forwardedBody, mappedModel) // 11. Send request resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) diff --git a/backend/internal/service/gateway_image_reasoning_pricing_test.go b/backend/internal/service/gateway_image_reasoning_pricing_test.go new file mode 100644 index 0000000000..39309153ba --- /dev/null +++ b/backend/internal/service/gateway_image_reasoning_pricing_test.go @@ -0,0 +1,143 @@ +//go:build unit + +package service + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestRecordUsage_ImageReasoningPricing(t *testing.T) { + for _, platform := range []string{PlatformAnthropic, PlatformOpenAI} { + for _, source := range []string{PricingSourceChannel, PricingSourceGroup} { + for _, mode := range []BillingMode{BillingModeImage, BillingModePerRequest} { + for _, independent := range []bool{false, true} { + for _, effort := range []string{"high", "low", ""} { + name := fmt.Sprintf("%s/%s/%s/independent=%t/effort=%s", platform, source, mode, independent, effort) + t.Run(name, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + groupID := int64(77) + model := "gemini-3-pro-image-preview" + price := 0.25 + pricing := ChannelModelPricing{ + Models: []string{model}, BillingMode: mode, PerRequestPrice: &price, + ReasoningEffortMultipliers: map[string]float64{"high": 2}, + } + resolver := newOpenAIImageChannelPricingResolverForTest(t, groupID, model, price) + cache := resolver.channelService.cache.Load().(*channelCache) + cache.pricingByGroupModel[channelModelKey{groupID: groupID, model: model}] = &pricing + group := &Group{ + ID: groupID, Platform: platform, Status: StatusActive, Hydrated: true, + RateMultiplier: 0.5, ImageRateIndependent: independent, ImageRateMultiplier: 0.25, + } + if source == PricingSourceGroup { + group.ModelPricing = []ChannelModelPricing{pricing} + // A matching group card owns the multiplier; the channel must not stack on top. + pricing.ReasoningEffortMultipliers = map[string]float64{"high": 3} + } + apiKey := &APIKey{ID: 10, GroupID: &groupID, Group: group} + account := &Account{ID: 30, Platform: platform, Type: AccountTypeAPIKey} + user := &User{ID: 20} + var forwardedEffort *string + if effort != "" { + forwardedEffort = &effort + } + if platform == PlatformAnthropic { + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, subRepo) + svc.resolver = resolver + require.NoError(t, svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "image_reasoning_pricing", Model: model, Duration: time.Second, + ImageCount: 2, ImageSize: "1K", ReasoningEffort: forwardedEffort, + }, + APIKey: apiKey, User: user, Account: account, + })) + } else { + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + svc.resolver = resolver + require.NoError(t, svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "image_reasoning_pricing", Model: model, Duration: time.Second, + ImageCount: 2, ImageSize: "1K", ReasoningEffort: forwardedEffort, + }, + APIKey: apiKey, User: user, Account: account, + })) + } + wantTotal := 0.5 + if effort == "high" { + wantTotal *= 2 + } + wantRate := 0.5 + if independent { + wantRate = 0.25 + } + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, wantTotal, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, wantTotal*wantRate, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, wantTotal*wantRate, userRepo.lastAmount, 1e-12) + require.Equal(t, forwardedEffort, usageRepo.lastLog.ReasoningEffort) + require.Equal(t, 2, usageRepo.lastLog.ImageCount) + require.Equal(t, string(mode), *usageRepo.lastLog.BillingMode) + }) + } + } + } + } + } +} + +func TestCalculateRecordUsageCost_MediaReasoningPricing(t *testing.T) { + for _, source := range []string{PricingSourceChannel, PricingSourceGroup} { + for _, media := range []string{"gateway_audio", "openai_audio", "openai_video"} { + t.Run(source+"/"+media, func(t *testing.T) { + groupID := int64(77) + model, effort := "media-model", "high" + price := 0.25 + resolver := newOpenAIImageChannelPricingResolverForTest(t, groupID, model, price) + cache := resolver.channelService.cache.Load().(*channelCache) + pricing := cache.pricingByGroupModel[channelModelKey{groupID: groupID, model: model}] + pricing.BillingMode = BillingModePerRequest + pricing.ReasoningEffortMultipliers = map[string]float64{"high": 2} + wantTotal := 1.0 // Two units at $0.25, with high=2. + if media == "openai_video" { + pricing.BillingMode = BillingModeVideo + wantTotal = 5 // Two five-second videos at $0.25/second, with high=2. + } + apiKey := &APIKey{GroupID: &groupID, Group: &Group{ID: groupID, Hydrated: true}} + if source == PricingSourceGroup { + pricing.Models = []string{model} + apiKey.Group.ModelPricing = []ChannelModelPricing{*pricing} + pricing.ReasoningEffortMultipliers = map[string]float64{"high": 3} + } + var cost *CostBreakdown + if media == "gateway_audio" { + svc := &GatewayService{billingService: resolver.billingService, resolver: resolver} + cost = svc.calculateRecordUsageCost(context.Background(), &ForwardResult{ + ReasoningEffort: &effort, AudioUsage: &AudioUsage{Mode: "tts", DurationOrUnits: 2}, + }, apiKey, model, 0.5, 0.5, time.Time{}) + } else { + svc := &OpenAIGatewayService{billingService: resolver.billingService, resolver: resolver} + result := &OpenAIForwardResult{ReasoningEffort: &effort} + if media == "openai_audio" { + result.AudioUsage = &AudioUsage{Mode: "tts", DurationOrUnits: 2} + } else { + result.VideoCount, result.VideoDurationSeconds = 2, 5 + } + var err error + cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, + []string{model}, 0.5, 0.5, 0.5, 0.5, UsageTokens{}, "", nil, time.Time{}) + require.NoError(t, err) + } + require.NotNil(t, cost) + require.InDelta(t, wantTotal, cost.TotalCost, 1e-12) + require.InDelta(t, wantTotal*0.5, cost.ActualCost, 1e-12) + }) + } + } +} diff --git a/backend/internal/service/gateway_reasoning_pricing_test.go b/backend/internal/service/gateway_reasoning_pricing_test.go new file mode 100644 index 0000000000..1aeaba6233 --- /dev/null +++ b/backend/internal/service/gateway_reasoning_pricing_test.go @@ -0,0 +1,70 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestRecordUsage_ReasoningPricingUsesForwardedEffort(t *testing.T) { + for _, platform := range []string{PlatformAnthropic, PlatformOpenAI} { + t.Run(platform, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + groupID := int64(77) + inputPrice, outputPrice := 0.001, 0.002 + requested, forwarded := "max", "high" + model := "gpt-5.4" + if platform == PlatformAnthropic { + model = "claude-fable-5-1" + } + apiKey := &APIKey{ + ID: 10, GroupID: &groupID, + Group: &Group{ + ID: groupID, Platform: platform, Status: StatusActive, Hydrated: true, RateMultiplier: 0.5, + ModelPricing: []ChannelModelPricing{{ + Models: []string{model}, BillingMode: BillingModeToken, + InputPrice: &inputPrice, OutputPrice: &outputPrice, + ReasoningEffortMultipliers: map[string]float64{"high": 1.5, "max": 3}, + }}, + }, + } + account := &Account{ID: 30, Platform: platform, Type: AccountTypeAPIKey} + user := &User{ID: 20} + if platform == PlatformAnthropic { + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, subRepo) + svc.resolver = NewModelPricingResolver(nil, svc.billingService) + require.NoError(t, svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "reasoning_pricing", Model: model, Duration: time.Second, + Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50}, + ReasoningEffort: &forwarded, RequestedReasoningEffort: &requested, + }, + APIKey: apiKey, User: user, Account: account, + })) + } else { + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + svc.resolver = NewModelPricingResolver(nil, svc.billingService) + require.NoError(t, svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "reasoning_pricing", Model: model, Duration: time.Second, + Usage: OpenAIUsage{InputTokens: 100, OutputTokens: 50}, + ReasoningEffort: &forwarded, RequestedReasoningEffort: &requested, + }, + APIKey: apiKey, User: user, Account: account, + })) + } + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, 0.3, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.15, usageRepo.lastLog.ActualCost, 1e-12) + require.Equal(t, forwarded, *usageRepo.lastLog.ReasoningEffort) + require.Equal(t, requested, *usageRepo.lastLog.RequestedReasoningEffort) + require.InDelta(t, 0.15, userRepo.lastAmount, 1e-12) + }) + } +} diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 1e1a0f8477..5a9a00aea7 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -903,6 +903,7 @@ func (s *GatewayService) calculateRecordUsageCost( Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, UsageUnits: result.AudioUsage.DurationOrUnits, SizeTier: result.AudioUsage.Mode, RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), }) if err == nil { return cost @@ -1027,6 +1028,7 @@ func (s *GatewayService) calculateImageCost( Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, RequestCount: result.ImageCount, SizeTier: sizeTier, RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), }) if err == nil { return cost @@ -1044,16 +1046,17 @@ func (s *GatewayService) calculateImageCost( } gid := apiKey.Group.ID cost, err := s.billingService.CalculateCostUnified(CostInput{ - Ctx: ctx, - Model: billingModel, - GroupID: &gid, - Group: apiKey.Group, - Tokens: tokens, - RequestCount: result.ImageCount, - SizeTier: sizeTier, - RateMultiplier: multiplier, - Resolver: s.resolver, - Resolved: resolved, + Ctx: ctx, + Model: billingModel, + GroupID: &gid, + Group: apiKey.Group, + Tokens: tokens, + RequestCount: result.ImageCount, + SizeTier: sizeTier, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), + RateMultiplier: multiplier, + Resolver: s.resolver, + Resolved: resolved, }) if err != nil { logger.LegacyPrintf("service.gateway", "Calculate image token cost failed: %v", err) diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index 21b6e9af97..6c5418516a 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -58,7 +58,7 @@ func (s *GeminiMessagesCompatService) ForwardAsChatCompletions( return nil, fmt.Errorf("marshal chat completions compat request: %w", err) } - return s.forwardClaudeBodyAsChatCompletions(ctx, c, account, claudeBody, originalModel, clientStream, includeUsage, startTime, body) + return s.forwardClaudeBodyAsChatCompletions(ctx, c, account, claudeBody, originalModel, clientStream, includeUsage, startTime) } func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions( @@ -70,7 +70,6 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions( clientStream bool, includeUsage bool, startTime time.Time, - originalChatBody []byte, ) (*ForwardResult, error) { var req struct { Model string `json:"model"` @@ -208,10 +207,7 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions( c.Header("x-request-id", requestID) } - reasoningEffort := extractCCReasoningEffortFromBody(originalChatBody, mappedModel) - // 国产模型默认 effort 补充(本路径上游是 Gemini,不会命中 passback-required)。 - // 保持与 OpenAI 网关路径调用模式一致,便于未来上游变异时语义一致。 - reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, originalChatBody, mappedModel) + reasoningEffort := extractGeminiReasoningEffortFromBody(geminiReq) if resp.StatusCode >= 400 { respBody := s.readUpstreamErrorBody(resp) diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 306d84d8f4..0b7c154fec 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -1668,6 +1668,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), Stream: stream, + ReasoningEffort: extractGeminiReasoningEffortFromBody(body), Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, ImageCount: imageCount, diff --git a/backend/internal/service/gemini_native_reasoning_pricing_test.go b/backend/internal/service/gemini_native_reasoning_pricing_test.go new file mode 100644 index 0000000000..718867ec30 --- /dev/null +++ b/backend/internal/service/gemini_native_reasoning_pricing_test.go @@ -0,0 +1,190 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestGeminiNativeReasoningPricingUsesExplicitForwardedLevel(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, provider := range []string{"api_key", "oauth_ai_studio", "oauth_code_assist", "antigravity"} { + for _, stream := range []bool{false, true} { + for _, tc := range []struct { + name string + thinking map[string]any + wantEffort string + multiplier float64 + }{ + {"high", map[string]any{"thinkingLevel": "high"}, "high", 2}, + {"uppercase_high", map[string]any{"thinkingLevel": "HIGH"}, "high", 2}, + {"snake_case_high", map[string]any{"thinking_level": "high"}, "high", 2}, + {"minimal", map[string]any{"thinkingLevel": "minimal"}, "minimal", 1.1}, + {"low", map[string]any{"thinkingLevel": "low"}, "low", 1.2}, + {"medium", map[string]any{"thinkingLevel": "medium"}, "medium", 1.5}, + {"missing_level", nil, "", 1}, + {"budget_only", map[string]any{"thinkingBudget": 24576}, "", 1}, + {"unspecified_level", map[string]any{"thinkingLevel": "THINKING_LEVEL_UNSPECIFIED"}, "", 1}, + } { + mode := "buffered" + if stream { + mode = "streaming" + } + t.Run(provider+"/"+mode+"/"+tc.name, func(t *testing.T) { + const model = "gemini-3.1-pro-high" + request := map[string]any{ + "contents": []any{map[string]any{"role": "user", "parts": []any{map[string]string{"text": "hello"}}}}, + } + if tc.name == "snake_case_high" { + request["generation_config"] = map[string]any{"thinking_config": tc.thinking} + } else if tc.thinking != nil { + request["generationConfig"] = map[string]any{"thinkingConfig": tc.thinking} + } + body, err := json.Marshal(request) + require.NoError(t, err) + action := "generateContent" + if stream { + action = "streamGenerateContent" + } + path := "/v1beta/models/" + model + ":" + action + c, recorder := newAntigravityCompatContext(http.MethodPost, path, body) + responseBody := `{"candidates":[{"content":{"parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}` + wrapped := provider == "oauth_code_assist" || provider == "antigravity" + if wrapped { + responseBody = `{"response":` + responseBody + `}` + } + contentType := "application/json" + if stream || wrapped { + contentType = "text/event-stream" + responseBody = "data: " + responseBody + "\n\n" + } + upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{contentType}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }}} + account := geminiSignalTestAccount() + var result *ForwardResult + if provider == "antigravity" { + account = newAntigravityCompatAccount(AccountTypeOAuth) + svc := newAntigravityCompatService(config.GatewayConfig{}, upstream) + result, err = svc.ForwardGemini(context.Background(), c, account, model, action, stream, body, false) + } else { + if provider != "api_key" { + account.Type = AccountTypeOAuth + account.Credentials["access_token"] = "test-token" + } + if provider == "oauth_code_assist" { + account.Credentials["project_id"] = "test-project" + } + svc := &GeminiMessagesCompatService{ + httpUpstream: upstream, cfg: &config.Config{}, tokenProvider: &GeminiTokenProvider{}, + } + result, err = svc.ForwardNative(context.Background(), c, account, model, action, stream, body) + } + require.NoError(t, err) + require.Equal(t, http.StatusOK, recorder.Code) + require.Len(t, upstream.requestBodies, 1) + forwardedPath := "generationConfig.thinkingConfig.thinkingLevel" + requestedLevel, _ := tc.thinking["thinkingLevel"].(string) + if tc.name == "snake_case_high" { + forwardedPath = "generation_config.thinking_config.thinking_level" + requestedLevel, _ = tc.thinking["thinking_level"].(string) + } + if wrapped { + forwardedPath = "request." + forwardedPath + } + require.Equal(t, requestedLevel, gjson.GetBytes(upstream.requestBodies[0], forwardedPath).String()) + require.Equal(t, tc.wantEffort, optionalStringValue(result.ReasoningEffort)) + if tc.wantEffort == "" { + require.Nil(t, result.ReasoningEffort) + } + + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + billing := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + billing.resolver = NewModelPricingResolver(nil, billing.billingService) + groupID := int64(77) + apiKey := &APIKey{ID: 10, GroupID: &groupID, Group: &Group{ + ID: groupID, Platform: account.Platform, Status: StatusActive, Hydrated: true, RateMultiplier: 0.5, + ModelPricing: []ChannelModelPricing{{ + Models: []string{model}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(0.05), OutputPrice: testPtrFloat64(0.1), + ReasoningEffortMultipliers: map[string]float64{"minimal": 1.1, "low": 1.2, "medium": 1.5, "high": 2}, + }}, + }} + require.NoError(t, billing.RecordUsage(context.Background(), &RecordUsageInput{ + Result: result, APIKey: apiKey, User: &User{ID: 20}, Account: account, + })) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, tc.wantEffort, optionalStringValue(usageRepo.lastLog.ReasoningEffort)) + require.InDelta(t, tc.multiplier, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, tc.multiplier*0.5, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, tc.multiplier*0.5, userRepo.lastAmount, 1e-12) + }) + } + } + } +} + +func TestGeminiChatCompatReasoningPricingIgnoresUnforwardedEffort(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, stream := range []bool{false, true} { + mode := "buffered" + if stream { + mode = "streaming" + } + t.Run(mode, func(t *testing.T) { + const model = "gemini-3.1-pro" + body, err := json.Marshal(map[string]any{ + "model": model, "stream": stream, "reasoning_effort": "high", + "messages": []map[string]string{{"role": "user", "content": "hello"}}, + }) + require.NoError(t, err) + responseBody := `{"candidates":[{"content":{"parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}` + contentType := "application/json" + if stream { + contentType = "text/event-stream" + responseBody = "data: " + responseBody + "\n\n" + } + upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{contentType}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }}} + svc := &GeminiMessagesCompatService{httpUpstream: upstream, cfg: &config.Config{}} + c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/chat/completions", body) + result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiSignalTestAccount(), body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, recorder.Code) + require.Len(t, upstream.requestBodies, 1) + require.False(t, gjson.GetBytes(upstream.requestBodies[0], "generationConfig.thinkingConfig").Exists()) + require.Nil(t, result.ReasoningEffort) + + billing := NewBillingService(&config.Config{}, nil) + cost, err := billing.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: model, + Group: &Group{ID: 77, Platform: PlatformGemini, ModelPricing: []ChannelModelPricing{{ + Models: []string{model}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(0.05), OutputPrice: testPtrFloat64(0.1), + ReasoningEffortMultipliers: map[string]float64{"high": 2}, + }}}, + Tokens: UsageTokens{InputTokens: result.Usage.InputTokens, OutputTokens: result.Usage.OutputTokens}, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), RateMultiplier: 1, + Resolver: NewModelPricingResolver(nil, billing), + }) + require.NoError(t, err) + require.InDelta(t, 1, cost.TotalCost, 1e-12) + }) + } +} diff --git a/backend/internal/service/gemini_reasoning_effort.go b/backend/internal/service/gemini_reasoning_effort.go new file mode 100644 index 0000000000..8ab96bce74 --- /dev/null +++ b/backend/internal/service/gemini_reasoning_effort.go @@ -0,0 +1,32 @@ +package service + +import ( + "strings" + + "github.com/tidwall/gjson" +) + +// extractGeminiReasoningEffortFromBody reads only an explicit level from the +// forwarded Gemini request. A budget or an omitted level does not define a +// billing level, even when the upstream model enables thinking by default. +func extractGeminiReasoningEffortFromBody(body []byte) *string { + config := gjson.GetBytes(body, "generationConfig") + if !config.Exists() { + config = gjson.GetBytes(body, "generation_config") + } + thinking := config.Get("thinkingConfig") + if !thinking.Exists() { + thinking = config.Get("thinking_config") + } + level := thinking.Get("thinkingLevel") + if !level.Exists() { + level = thinking.Get("thinking_level") + } + effort := strings.ToLower(strings.TrimSpace(level.String())) + switch effort { + case "minimal", "low", "medium", "high": + return &effort + default: + return nil + } +} diff --git a/backend/internal/service/model_plaza_service.go b/backend/internal/service/model_plaza_service.go index b1618acf78..17cb9b1b90 100644 --- a/backend/internal/service/model_plaza_service.go +++ b/backend/internal/service/model_plaza_service.go @@ -224,6 +224,9 @@ func (s *ModelPlazaService) ListGroups(ctx context.Context) ([]PlazaGroup, error // token 模型取计费阶梯表(单价与档位均由真实计费函数得出), // 图片/按次模型(或阶梯表不可用时)沿用渠道定价与分组图片档位价。 func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaModel, g *Group) { + if groupPricing := matchGroupModelPricing(g, m.Name); groupPricing != nil { + m.Pricing = groupPricing + } if s.billingService != nil && s.resolver != nil { sched, err := s.billingService.ResolveContextPricingSchedule(ctx, s.resolver, ContextPricingScheduleInput{ Model: m.Name, @@ -231,7 +234,7 @@ func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaMode Platform: m.Platform, }) if err == nil && sched != nil && len(sched.Tiers) > 0 { - m.Pricing = withDefaultMaxReasoningEffortMultiplier(plazaPricingFromSchedule(m.Pricing, sched), m.Name) + m.Pricing = plazaPricingFromSchedule(m.Pricing, sched) if len(sched.Tiers) > 1 { m.LongContextBasis = sched.Basis } @@ -239,20 +242,7 @@ func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaMode return } } - m.Pricing = withDefaultMaxReasoningEffortMultiplier(plazaImageDisplayPricing(m.Pricing, g), m.Name) -} - -func withDefaultMaxReasoningEffortMultiplier(pricing *ChannelModelPricing, model string) *ChannelModelPricing { - if pricing == nil || pricing.MaxReasoningEffortMultiplier != nil { - return pricing - } - multiplier := defaultMaxReasoningEffortMultiplier(model) - if multiplier == nil { - return pricing - } - cloned := pricing.Clone() - cloned.MaxReasoningEffortMultiplier = multiplier - return &cloned + m.Pricing = plazaImageDisplayPricing(m.Pricing, g) } // plazaPricingFromSchedule 把阶梯表压成展示用的 ChannelModelPricing: @@ -263,7 +253,7 @@ func plazaPricingFromSchedule(raw *ChannelModelPricing, sched *ContextPricingSch out.ImageInputPrice = raw.ImageInputPrice out.ImageOutputPrice = raw.ImageOutputPrice out.PerRequestPrice = raw.PerRequestPrice - out.MaxReasoningEffortMultiplier = raw.MaxReasoningEffortMultiplier + out.ReasoningEffortMultipliers = reasoningEffortMultipliersFromPricing(raw) } first := sched.Tiers[0] out.InputPrice = first.Input diff --git a/backend/internal/service/model_plaza_service_test.go b/backend/internal/service/model_plaza_service_test.go index e7c5f4ffb9..fa2e7a8345 100644 --- a/backend/internal/service/model_plaza_service_test.go +++ b/backend/internal/service/model_plaza_service_test.go @@ -57,17 +57,14 @@ func TestListPlazaGroups_GroupCentricAggregation(t *testing.T) { require.Equal(t, "claude-sonnet", out[0].Models[1].Name) } -func TestWithDefaultMaxReasoningEffortMultiplier_Fable51(t *testing.T) { - base := &ChannelModelPricing{BillingMode: BillingModeToken} - got := withDefaultMaxReasoningEffortMultiplier(base, "claude-fable-5-1") - require.NotSame(t, base, got) - require.NotNil(t, got.MaxReasoningEffortMultiplier) - require.Equal(t, 3.0, *got.MaxReasoningEffortMultiplier) - require.Nil(t, base.MaxReasoningEffortMultiplier) - - configured := 1.25 - custom := &ChannelModelPricing{MaxReasoningEffortMultiplier: &configured} - require.Same(t, custom, withDefaultMaxReasoningEffortMultiplier(custom, "claude-fable-5-1")) +func TestListPlazaGroups_Fable51HasNoImplicitReasoningMultiplier(t *testing.T) { + ch := plazaPricedChannel(1, "ch", []int64{10}, "anthropic", "claude-fable-5-1") + svc := newPlazaService([]Channel{ch}, []Group{{ID: 10, Platform: "anthropic"}}, nil) + groups, err := svc.ListGroups(context.Background()) + require.NoError(t, err) + require.Len(t, groups, 1) + require.Len(t, groups[0].Models, 1) + require.Empty(t, groups[0].Models[0].Pricing.ReasoningEffortMultipliers) } func TestListPlazaGroups_DedupFirstWinsWithPricingUpgrade(t *testing.T) { diff --git a/backend/internal/service/model_pricing_resolver.go b/backend/internal/service/model_pricing_resolver.go index ecd3d45209..12c569fedd 100644 --- a/backend/internal/service/model_pricing_resolver.go +++ b/backend/internal/service/model_pricing_resolver.go @@ -3,6 +3,7 @@ package service import ( "context" "log/slog" + "maps" "strings" ) @@ -247,9 +248,7 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin } resolved.BasePricing.FastMultiplier = chPricing.FastMultiplier resolved.BasePricing.FlexMultiplier = chPricing.FlexMultiplier - if chPricing.MaxReasoningEffortMultiplier != nil { - resolved.BasePricing.MaxReasoningEffortMultiplier = chPricing.MaxReasoningEffortMultiplier - } + resolved.BasePricing.ReasoningEffortMultipliers = maps.Clone(chPricing.ReasoningEffortMultipliers) // 渠道定价覆盖一切:显式配置则用配置值,未配置则归零(不回退到 LiteLLM) if chPricing.ImageOutputPrice != nil { resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go index bc865af81c..bc029bf480 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -23,6 +23,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" "go.uber.org/zap" ) @@ -111,7 +112,7 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( } upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream) - upstreamReq, _, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL, body) + upstreamReq, forwardedBody, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL, body) releaseUpstreamCtx() if err != nil { return nil, fmt.Errorf("build upstream request: %w", err) @@ -132,8 +133,9 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg) } - reasoningEffort := extractCCReasoningEffortFromBody(body, upstreamModel, billingModel, originalModel) - reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel) + // Bill the Anthropic effort actually sent upstream (for example xhigh → max). + reasoningEffort := NormalizeClaudeOutputEffort(gjson.GetBytes(forwardedBody, "output_config.effort").String()) + reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, forwardedBody, upstreamModel) if clientStream { return s.handleCCStreamingFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime, includeUsage) diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index e5ddc972f8..c92528f2d7 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -80,10 +80,6 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( // anchored to the client's stable conversation prefix. grokCacheIdentity = resolveGrokCacheIdentity(c, body, "", upstreamModel) } - reasoningEffort := extractOpenAIReasoningEffortFromBody(body, upstreamModel, billingModel, originalModel) - // 国产模型默认 effort 补充:需要 mappedModel 判定,推迟到 billingModel 算出之后。 - reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel) - // 3. Rewrite model in body (no protocol conversion) upstreamBody := body if upstreamModel != originalModel { @@ -188,6 +184,10 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( if customUA == "" && account.IsGrokOAuth() { customUA = defaultGrokUpstreamUserAgent() } + // Record the final provider-normalized effort, so usage and pricing match + // the outbound request (for example GLM xhigh is forwarded as max). + reasoningEffort := extractOpenAIReasoningEffortFromBody(upstreamBody, upstreamModel, billingModel, originalModel) + reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, upstreamBody, upstreamModel) resp, err := s.sendCCUpstreamRequest(ctx, c, account, targetURL, upstreamBody, clientStream, token, customUA, grokCacheIdentity) if err != nil { return nil, err diff --git a/backend/internal/service/openai_gateway_chat_completions_raw_test.go b/backend/internal/service/openai_gateway_chat_completions_raw_test.go index a67ad5fca9..433cb8cfaf 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw_test.go @@ -456,6 +456,9 @@ func TestForwardAsRawChatCompletions_NormalizesGLMReasoningEffortForUpstream(t * require.NoError(t, err) require.NotNil(t, result) require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning_effort").String()) + require.NotNil(t, result.ReasoningEffort) + require.Equal(t, "max", *result.ReasoningEffort) + require.Equal(t, 3.0, reasoningEffortBillingMultiplier(*result.ReasoningEffort, map[string]float64{"xhigh": 2, "max": 3})) } func TestForwardAsRawChatCompletions_SilentRefusalTriggersFailover(t *testing.T) { diff --git a/backend/internal/service/openai_gateway_converted_reasoning_pricing_test.go b/backend/internal/service/openai_gateway_converted_reasoning_pricing_test.go new file mode 100644 index 0000000000..4a001d4f0d --- /dev/null +++ b/backend/internal/service/openai_gateway_converted_reasoning_pricing_test.go @@ -0,0 +1,133 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestOpenAINativeAnthropicReasoningPricingUsesForwardedEffort(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, endpoint := range []string{"responses", "chat/completions"} { + for _, stream := range []bool{false, true} { + for _, tc := range []struct { + effort string + wantEffort string + wantCost float64 + }{ + {"xhigh", "max", 3}, + {"high", "high", 2}, + {"", "", 1}, + } { + t.Run(fmt.Sprintf("%s/stream=%t/effort=%s", endpoint, stream, tc.effort), func(t *testing.T) { + request := map[string]any{"model": "k3", "stream": stream} + if endpoint == "responses" { + request["input"] = "hello" + if tc.effort != "" { + request["reasoning"] = map[string]string{"effort": tc.effort} + } + } else { + request["messages"] = []map[string]string{{"role": "user", "content": "hello"}} + if tc.effort != "" { + request["reasoning_effort"] = tc.effort + } + } + body, err := json.Marshal(request) + require.NoError(t, err) + resp := nativeAnthropicStreamResponse() + responseBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + resp.Body = io.NopCloser(strings.NewReader(strings.ReplaceAll(string(responseBody), `"input_tokens":93`, `"input_tokens":1000000`))) + upstream := &httpUpstreamRecorder{resp: resp} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + c := adaptiveProtocolTestContext("/v1/"+endpoint, body) + account := nativeAnthropicTestAccount() + account.Credentials["base_url"] = "http://anthropic.example" + var result *OpenAIForwardResult + if endpoint == "responses" { + result, err = svc.Forward(context.Background(), c, account, body) + } else { + result, err = svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + } + require.NoError(t, err) + require.Equal(t, "/v1/messages", upstream.lastReq.URL.Path) + require.Equal(t, tc.wantEffort, gjson.GetBytes(upstream.lastBody, "output_config.effort").String()) + require.Equal(t, tc.wantEffort, optionalStringValue(result.ReasoningEffort)) + assertConvertedReasoningPrice(t, result, tc.wantCost) + }) + } + } + } +} + +func TestOpenAIMessagesGLMReasoningPricingUsesForwardedEffort(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, stream := range []bool{false, true} { + for _, tc := range []struct { + effort string + maxPolicy string + wantEffort string + wantCost float64 + }{ + {"medium", "", "high", 2}, + {"xhigh", "", "max", 3}, + {"xhigh", "medium", "medium", 1.5}, + } { + t.Run(fmt.Sprintf("stream=%t/effort=%s/policy=%s", stream, tc.effort, tc.maxPolicy), func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":"glm-5.2","stream":%t,"max_tokens":32,"output_config":{"effort":%q},"messages":[{"role":"user","content":"hello"}]}`, stream, tc.effort)) + responseBody := `{"id":"chatcmpl_glm","object":"chat.completion","model":"glm-5.2","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1000000,"completion_tokens":2,"total_tokens":1000002}}` + contentType := "application/json" + if stream { + contentType = "text/event-stream" + responseBody = "data: " + `{"id":"chatcmpl_glm","object":"chat.completion.chunk","model":"glm-5.2","choices":[{"index":0,"delta":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1000000,"completion_tokens":2,"total_tokens":1000002}}` + "\n\ndata: [DONE]\n\n" + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{contentType}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + ctx := context.Background() + if tc.maxPolicy != "" { + ctx = WithOpenAIReasoningEffortPolicy(ctx, tc.maxPolicy, nil, "") + } + result, err := svc.ForwardAsAnthropic(ctx, adaptiveProtocolTestContext("/v1/messages", body), forceChatMessagesFallbackAccount(), body, "", "") + require.NoError(t, err) + require.Equal(t, "/v1/chat/completions", upstream.lastReq.URL.Path) + require.Equal(t, tc.wantEffort, gjson.GetBytes(upstream.lastBody, "reasoning_effort").String()) + require.Equal(t, tc.wantEffort, optionalStringValue(result.ReasoningEffort)) + assertConvertedReasoningPrice(t, result, tc.wantCost) + }) + } + } +} + +func assertConvertedReasoningPrice(t *testing.T, result *OpenAIForwardResult, wantCost float64) { + t.Helper() + require.Equal(t, 1_000_000, result.Usage.InputTokens) + billing := NewBillingService(rawChatCompletionsTestConfig(), nil) + group := &Group{ID: 1, Platform: PlatformOpenAI, ModelPricing: []ChannelModelPricing{{ + Models: []string{result.BillingModel}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(1e-6), OutputPrice: testPtrFloat64(0), + ReasoningEffortMultipliers: map[string]float64{"medium": 1.5, "high": 2, "xhigh": 2, "max": 3}, + }}} + cost, err := billing.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: result.BillingModel, Group: group, + Tokens: UsageTokens{InputTokens: result.Usage.InputTokens, OutputTokens: result.Usage.OutputTokens}, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), RateMultiplier: 1, + Resolver: NewModelPricingResolver(nil, billing), + }) + require.NoError(t, err) + require.InDelta(t, wantCost, cost.TotalCost, 1e-12) + require.InDelta(t, wantCost, cost.ActualCost, 1e-12) +} diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index 928e3fa982..b5f1fbbc17 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -12,7 +12,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" - "github.com/tidwall/gjson" "go.uber.org/zap" ) @@ -67,9 +66,6 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( chatReq.StreamOptions = &apicompat.ChatStreamOptions{IncludeUsage: true} } - convertedEffort := chatReq.ReasoningEffort - reasoningEffort := &convertedEffort - reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel) serviceTier := extractOpenAIServiceTierFromBody(body) chatBody, err := json.Marshal(chatReq) @@ -90,11 +86,12 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( } if changed { chatBody = policyBody - if effectiveEffort := strings.TrimSpace(gjson.GetBytes(chatBody, "reasoning_effort").String()); effectiveEffort != "" { - reasoningEffort = &effectiveEffort - } } } + // Provider normalization and policy caps can both change the converted effort. + // Use the final outbound value for usage logs and billing. + reasoningEffort := extractOpenAIReasoningEffortFromBody(chatBody, upstreamModel, billingModel, originalModel) + reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, chatBody, upstreamModel) // Unlike forwardResponsesViaRawChatCompletions, applyOpenAIFastPolicyToBody // is intentionally skipped: Anthropic Messages bodies carry no service_tier, // so the converted Chat Completions body never contains one and the policy diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 2c8aa8d4b4..9d2d1e4e3c 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -2312,9 +2312,7 @@ func normalizeOpenAIReasoningEffort(raw string) string { value = strings.NewReplacer("-", "", "_", "", " ", "").Replace(value) switch value { - case "none", "minimal": - return "" - case "low", "medium", "high": + case "none", "minimal", "low", "medium", "high": return value case "xhigh", "extrahigh", "max": return "xhigh" diff --git a/backend/internal/service/openai_gateway_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go index fa2e190b97..4295fa9544 100644 --- a/backend/internal/service/openai_gateway_responses_anthropic_native.go +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -75,9 +75,6 @@ func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic( upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) anthropicReq.Model = upstreamModel - reasoningEffort := ExtractResponsesReasoningEffortFromBody(body, upstreamModel, billingModel, originalModel) - reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel) - // 5. Force upstream streaming(客户端原始终决定响应格式; // 上游恒为流式,非流式由缓冲路径组装)。 anthropicReq.Stream = true @@ -116,11 +113,14 @@ func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic( } upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream) - upstreamReq, _, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL, body) + upstreamReq, forwardedBody, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL, body) releaseUpstreamCtx() if err != nil { return nil, fmt.Errorf("build upstream request: %w", err) } + // Bill the Anthropic effort actually sent upstream (for example xhigh → max). + reasoningEffort := NormalizeClaudeOutputEffort(gjson.GetBytes(forwardedBody, "output_config.effort").String()) + reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, forwardedBody, upstreamModel) resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { diff --git a/backend/internal/service/openai_gateway_service_hotpath_test.go b/backend/internal/service/openai_gateway_service_hotpath_test.go index 3b7e80bc78..5142b7934d 100644 --- a/backend/internal/service/openai_gateway_service_hotpath_test.go +++ b/backend/internal/service/openai_gateway_service_hotpath_test.go @@ -932,10 +932,10 @@ func TestExtractOpenAIReasoningEffortFromBody(t *testing.T) { wantValue: "xhigh", }, { - name: "minimal 归一化为空", - body: []byte(`{"reasoning":{"effort":"minimal"}}`), - model: "gpt-5-high", - wantNil: true, + name: "minimal 保留用于计费", + body: []byte(`{"reasoning":{"effort":"minimal"}}`), + model: "gpt-5-high", + wantValue: "minimal", }, { name: "缺失字段时从模型后缀推导", diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 6ae03bb5a2..86ebf21ebd 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -590,6 +590,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, UsageUnits: result.AudioUsage.DurationOrUnits, SizeTier: result.AudioUsage.Mode, RateMultiplier: webSearchMultiplier, Resolver: s.resolver, Resolved: resolved, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), }) } cfg := groupAudioPriceConfigFromAPIKey(apiKey) @@ -726,17 +727,13 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( LongContextBillingEnabled: longContextBillingGate, }) } - breakdown, err := s.billingService.calculateCostWithServiceTierPolicy( + return s.billingService.calculateCostWithServiceTierPolicy( billingModel, tokens, multiplier, serviceTier, longContextBillingGate == nil || *longContextBillingGate, ) - if err == nil { - applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(billingModel, reasoningEffort, nil)) - } - return breakdown, err } func (s *OpenAIGatewayService) calculateOpenAIImageCost( @@ -755,6 +752,7 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost( Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, RequestCount: result.ImageCount, SizeTier: sizeTier, RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), }) if err == nil { return cost @@ -775,15 +773,16 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost( (resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) { gid := apiKey.Group.ID cost, err := s.billingService.CalculateCostUnified(CostInput{ - Ctx: ctx, - Model: billingModel, - GroupID: &gid, - Group: apiKey.Group, - RequestCount: result.ImageCount, - SizeTier: sizeTier, - RateMultiplier: multiplier, - Resolver: s.resolver, - Resolved: resolved, + Ctx: ctx, + Model: billingModel, + GroupID: &gid, + Group: apiKey.Group, + RequestCount: result.ImageCount, + SizeTier: sizeTier, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), + RateMultiplier: multiplier, + Resolver: s.resolver, + Resolved: resolved, }) if err == nil { return cost @@ -814,6 +813,7 @@ func (s *OpenAIGatewayService) calculateOpenAIVideoCost( Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, UsageUnits: float64(videoCount * durationSeconds), SizeTier: resolution, RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), }) if err == nil { return cost @@ -839,16 +839,17 @@ func (s *OpenAIGatewayService) calculateOpenAIVideoCost( units = float64(videoCount * durationSeconds) } cost, err := s.billingService.CalculateCostUnified(CostInput{ - Ctx: ctx, - Model: billingModel, - GroupID: &gid, - Group: apiKey.Group, - RequestCount: videoCount, - UsageUnits: units, - SizeTier: resolution, - RateMultiplier: multiplier, - Resolver: s.resolver, - Resolved: resolved, + Ctx: ctx, + Model: billingModel, + GroupID: &gid, + Group: apiKey.Group, + RequestCount: videoCount, + UsageUnits: units, + SizeTier: resolution, + ReasoningEffort: optionalStringValue(result.ReasoningEffort), + RateMultiplier: multiplier, + Resolver: s.resolver, + Resolved: resolved, }) if err == nil { cost.BillingMode = string(BillingModeVideo) diff --git a/backend/internal/service/reasoning_effort_billing_test.go b/backend/internal/service/reasoning_effort_billing_test.go new file mode 100644 index 0000000000..c62b4bf387 --- /dev/null +++ b/backend/internal/service/reasoning_effort_billing_test.go @@ -0,0 +1,245 @@ +//go:build unit + +package service + +import ( + "context" + "math" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func TestReasoningEffortBillingMultiplier(t *testing.T) { + multipliers := map[string]float64{"none": 0.5, "minimal": 0.75, "low": 0.9, "medium": 1.25, "high": 1.5, "xhigh": 2, "max": 3} + for _, tc := range []struct { + effort string + want float64 + }{ + {"none", 0.5}, {"minimal", 0.75}, {"low", 0.9}, {"medium", 1.25}, + {"high", 1.5}, {"xhigh", 2}, {"max", 3}, {" Extra-High ", 2}, + {" MAX ", 3}, {"", 1}, {"unknown", 1}, + } { + t.Run(tc.effort, func(t *testing.T) { + require.Equal(t, tc.want, reasoningEffortBillingMultiplier(tc.effort, multipliers)) + }) + } + require.Equal(t, 1.0, reasoningEffortBillingMultiplier("max", nil)) + for _, invalid := range []float64{0, -1, math.NaN(), math.Inf(1)} { + require.Equal(t, 1.0, reasoningEffortBillingMultiplier("max", map[string]float64{"max": invalid})) + } +} + +func TestReasoningEffortBillingSupportsAllModelsAndLevels(t *testing.T) { + for _, model := range []string{"claude-fable-5-1", "gpt-5.4", "gemini-2.5-pro", "custom-model"} { + t.Run(model, func(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformOpenAI, []ChannelModelPricing{{ + Platform: PlatformOpenAI, Models: []string{model}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(1e-6), OutputPrice: testPtrFloat64(2e-6), + ReasoningEffortMultipliers: map[string]float64{"none": 0.5, "high": 1.5, "max": 3}, + }}, nil) + group := &Group{ID: 100, Platform: PlatformOpenAI} + for effort, multiplier := range map[string]float64{"none": 0.5, "high": 1.5, "max": 3, "low": 1, "": 1} { + cost, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: model, Group: group, Resolver: resolver, + Tokens: UsageTokens{InputTokens: 1000, OutputTokens: 200}, RateMultiplier: 0.8, + ReasoningEffort: effort, + }) + require.NoError(t, err) + require.InDelta(t, 0.0014*multiplier, cost.TotalCost, 1e-12, effort) + require.InDelta(t, cost.TotalCost*0.8, cost.ActualCost, 1e-12, effort) + } + }) + } +} + +func TestReasoningEffortBillingStacksWithContextTierServiceTierAndTimePricing(t *testing.T) { + for _, interval := range []bool{false, true} { + name := "catalog long context" + if interval { + name = "channel interval" + } + t.Run(name, func(t *testing.T) { + resolved := &ResolvedPricing{ + Mode: BillingModeToken, Source: PricingSourceChannel, + BasePricing: &ModelPricing{ + InputPricePerToken: 1e-6, ImageInputPricePerToken: 2e-6, + OutputPricePerToken: 3e-6, ImageOutputPricePerToken: 4e-6, + CacheCreationPricePerToken: 5e-6, CacheReadPricePerToken: 6e-6, + FastMultiplier: testPtrFloat64(2.5), + ReasoningEffortMultipliers: map[string]float64{"high": 1.7}, + LongContextInputThreshold: 100, LongContextInputMultiplier: 2, LongContextOutputMultiplier: 3, + }, + longContextPricingEnabled: true, + channelPricing: &ChannelModelPricing{TimePricing: &ChannelTimePricing{ + Timezone: "UTC", Periods: []ChannelTimePricingPeriod{{StartTime: "08:00", EndTime: "16:00", Multiplier: 1.2}}, + }}, + } + if interval { + resolved.Intervals = []PricingInterval{{MinTokens: 100, InputMultiplier: testPtrFloat64(2), OutputMultiplier: testPtrFloat64(3)}} + } + input := CostInput{ + Model: "custom-model", RateMultiplier: 0.8, ServiceTier: "fast", + Tokens: UsageTokens{InputTokens: 200, ImageInputTokens: 20, OutputTokens: 10, ImageOutputTokens: 2, CacheCreationTokens: 5, CacheReadTokens: 3}, + Resolver: &ModelPricingResolver{}, Resolved: resolved, + PricingAt: time.Date(2026, 9, 18, 12, 0, 0, 0, time.UTC), + } + bs := &BillingService{} + standard, err := bs.CalculateCostUnified(input) + require.NoError(t, err) + input.ReasoningEffort = "high" + adjusted, err := bs.CalculateCostUnified(input) + require.NoError(t, err) + for _, costs := range [][2]float64{ + {standard.InputCost, adjusted.InputCost}, {standard.ImageInputCost, adjusted.ImageInputCost}, + {standard.OutputCost, adjusted.OutputCost}, {standard.ImageOutputCost, adjusted.ImageOutputCost}, + {standard.CacheCreationCost, adjusted.CacheCreationCost}, {standard.CacheReadCost, adjusted.CacheReadCost}, + {standard.TotalCost, adjusted.TotalCost}, {standard.ActualCost, adjusted.ActualCost}, + } { + require.Positive(t, costs[0]) + require.InDelta(t, costs[0]*1.7, costs[1], 1e-12) + } + require.Equal(t, standard.LongContextBillingApplied, adjusted.LongContextBillingApplied) + }) + } +} + +func TestReasoningEffortBillingGroupOverrideAndUnitBilling(t *testing.T) { + for _, mode := range []BillingMode{BillingModeToken, BillingModePerRequest, BillingModeImage, BillingModeVideo} { + t.Run(string(mode), func(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformOpenAI, []ChannelModelPricing{{ + Platform: PlatformOpenAI, Models: []string{"custom-model"}, BillingMode: mode, + InputPrice: testPtrFloat64(1e-6), PerRequestPrice: testPtrFloat64(0.1), + ReasoningEffortMultipliers: map[string]float64{"high": 2, "max": 3}, + }}, nil) + group := &Group{ID: 100, Platform: PlatformOpenAI, ModelPricing: []ChannelModelPricing{{ + Models: []string{"custom-model"}, BillingMode: mode, + InputPrice: testPtrFloat64(1e-6), PerRequestPrice: testPtrFloat64(0.1), + ReasoningEffortMultipliers: map[string]float64{"high": 1.25}, + }}} + for effort, multiplier := range map[string]float64{"high": 1.25, "max": 1} { + input := CostInput{ + Ctx: context.Background(), Model: "custom-model", Group: group, GroupID: &group.ID, + Tokens: UsageTokens{InputTokens: 1000}, RequestCount: 2, RateMultiplier: 0.8, + ReasoningEffort: effort, Resolver: resolver, + } + cost, err := bs.CalculateCostUnified(input) + require.NoError(t, err) + base := 0.2 + if mode == BillingModeToken { + base = 0.001 + } + require.InDelta(t, base*multiplier, cost.TotalCost, 1e-12) + require.InDelta(t, cost.TotalCost*0.8, cost.ActualCost, 1e-12) + } + }) + } +} + +func TestAccountStatsCustomRulesUseOwnReasoningEffortMultipliers(t *testing.T) { + for _, mode := range []BillingMode{BillingModeToken, BillingModePerRequest, BillingModeImage, BillingModeVideo} { + t.Run(string(mode), func(t *testing.T) { + channel := &Channel{AccountStatsPricingRules: []AccountStatsPricingRule{{ + AccountIDs: []int64{1}, Pricing: []ChannelModelPricing{{ + Models: []string{"claude-fable-5-1"}, BillingMode: mode, + InputPrice: testPtrFloat64(0.001), PerRequestPrice: testPtrFloat64(0.1), + ReasoningEffortMultipliers: map[string]float64{"high": 1.5}, + }}, + }}} + for effort, multiplier := range map[string]float64{"high": 1.5, "max": 1} { + cost := tryCustomRules(channel, 1, 100, PlatformAnthropic, "claude-fable-5-1", UsageTokens{InputTokens: 100}, 2, effort) + require.NotNil(t, cost) + base := 0.2 + if mode == BillingModeToken { + base = 0.1 + } + require.InDelta(t, base*multiplier, *cost, 1e-12) + } + }) + } +} + +func TestDisplayPricingPreservesConfiguredReasoningEffortMultipliers(t *testing.T) { + raw := &ChannelModelPricing{ReasoningEffortMultipliers: map[string]float64{"high": 1.5, "max": 3}} + for _, mode := range []BillingMode{BillingModeToken, BillingModePerRequest, BillingModeImage} { + raw.BillingMode = mode + display := synthesizePricingFromLiteLLM(&LiteLLMModelPricing{InputCostPerToken: 1e-6, OutputCostPerImage: 0.1}, raw) + require.Equal(t, raw.ReasoningEffortMultipliers, display.ReasoningEffortMultipliers) + display.ReasoningEffortMultipliers["high"] = 9 + require.Equal(t, 1.5, raw.ReasoningEffortMultipliers["high"]) + } + display := plazaPricingFromSchedule(raw, &ContextPricingSchedule{Tiers: []ContextPricingTier{{Input: testPtrFloat64(1e-6)}}}) + require.Equal(t, raw.ReasoningEffortMultipliers, display.ReasoningEffortMultipliers) + display.ReasoningEffortMultipliers["max"] = 9 + require.Equal(t, 3.0, raw.ReasoningEffortMultipliers["max"]) +} + +func TestPlazaUsesGroupReasoningEffortMultipliers(t *testing.T) { + m := &PlazaModel{Name: "custom-model", Platform: PlatformOpenAI, Pricing: &ChannelModelPricing{ + ReasoningEffortMultipliers: map[string]float64{"max": 3}, + }} + g := &Group{ModelPricing: []ChannelModelPricing{{ + Models: []string{"custom-model"}, BillingMode: BillingModeToken, + ReasoningEffortMultipliers: map[string]float64{"high": 1.5}, + }}} + (&ModelPlazaService{}).fillDisplayPricing(context.Background(), m, g) + require.Equal(t, map[string]float64{"high": 1.5}, m.Pricing.ReasoningEffortMultipliers) + m.Pricing.ReasoningEffortMultipliers["high"] = 9 + require.Equal(t, 1.5, g.ModelPricing[0].ReasoningEffortMultipliers["high"]) +} + +func TestReasoningEffortBillingNoResolverPreservesCatalogPolicies(t *testing.T) { + bs := NewBillingService(&config.Config{}, nil) + tokens := UsageTokens{InputTokens: 300000, OutputTokens: 100, CacheCreationTokens: 20, CacheReadTokens: 10} + for _, applyLongContext := range []bool{false, true} { + for _, serviceTier := range []string{"", "priority", "flex"} { + for _, rateMultiplier := range []float64{-1, 0, 0.8} { + want, err := bs.calculateCostInternalWithPolicy("gpt-5.4", tokens, rateMultiplier, serviceTier, nil, applyLongContext) + require.NoError(t, err) + got, err := bs.CalculateCostUnified(CostInput{ + Model: "gpt-5.4", Tokens: tokens, RateMultiplier: rateMultiplier, + ServiceTier: serviceTier, LongContextBillingEnabled: &applyLongContext, ReasoningEffort: "max", + }) + require.NoError(t, err) + require.Equal(t, want, got) + } + } + } +} + +func TestReasoningEffortBillingPreservesForwardedNoneAndMinimal(t *testing.T) { + for _, effort := range []string{"none", "minimal"} { + t.Run(effort, func(t *testing.T) { + for _, body := range [][]byte{ + []byte(`{"model":"gpt-5.4","reasoning":{"effort":"` + effort + `"}}`), + []byte(`{"model":"gpt-5.4","reasoning_effort":"` + effort + `"}`), + } { + got := extractOpenAIReasoningEffortFromBody(body, "gpt-5.4") + require.NotNil(t, got) + require.Equal(t, effort, *got) + require.Equal(t, 0.5, reasoningEffortBillingMultiplier(*got, map[string]float64{effort: 0.5})) + require.Equal(t, got, extractCCReasoningEffortFromBody(body, "gpt-5.4")) + } + for _, body := range []map[string]any{ + {"reasoning": map[string]any{"effort": effort}}, + {"reasoning_effort": effort}, + } { + got := extractOpenAIReasoningEffort(body, "gpt-5.4") + require.NotNil(t, got) + require.Equal(t, effort, *got) + } + body := []byte(`{"model":"gpt-5.4","reasoning":{"effort":"` + effort + `"}}`) + got := ExtractResponsesReasoningEffortFromBody(body, "gpt-5.4") + require.NotNil(t, got) + require.Equal(t, effort, *got) + }) + } + + // A compatibility endpoint that strips none must not bill the removed request preference. + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://compat.example/v1"}} + filtered, err := filterOpenAIResponsesNoneReasoningEffortForAccount(account, []byte(`{"model":"custom-model","reasoning":{"effort":"none"}}`)) + require.NoError(t, err) + require.Nil(t, extractOpenAIReasoningEffortFromBody(filtered, "custom-model")) +} diff --git a/backend/migrations/239_channel_reasoning_effort_multipliers.sql b/backend/migrations/239_channel_reasoning_effort_multipliers.sql new file mode 100644 index 0000000000..6fd3f43ed1 --- /dev/null +++ b/backend/migrations/239_channel_reasoning_effort_multipliers.sql @@ -0,0 +1,56 @@ +-- Migrate only explicitly configured legacy max pricing. There is no model-specific default. +-- Run the backfill only when introducing the column: replaying this migration must +-- preserve current settings, including maps that have deliberately been cleared. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_attribute + WHERE attrelid = 'channel_model_pricing'::regclass + AND attname = 'reasoning_effort_multipliers' + AND NOT attisdropped + ) THEN + ALTER TABLE channel_model_pricing + ADD COLUMN IF NOT EXISTS reasoning_effort_multipliers JSONB NOT NULL DEFAULT '{}'::jsonb; + + UPDATE channel_model_pricing + SET reasoning_effort_multipliers = jsonb_build_object('max', max_reasoning_effort_multiplier) + WHERE max_reasoning_effort_multiplier IS NOT NULL; + END IF; +END $$; + +ALTER TABLE channel_account_stats_model_pricing + ADD COLUMN IF NOT EXISTS reasoning_effort_multipliers JSONB NOT NULL DEFAULT '{}'::jsonb; + +-- Group pricing stores the same entries as JSON. An existing generic map wins, +-- even when empty. Remove the consumed legacy key so replay cannot revive it. +UPDATE groups AS g +SET model_pricing = ( + SELECT jsonb_agg( + CASE + WHEN jsonb_typeof(entry) = 'object' AND entry ? 'max_reasoning_effort_multiplier' THEN + (entry - 'max_reasoning_effort_multiplier') || + CASE + WHEN NOT (entry ? 'reasoning_effort_multipliers') + AND jsonb_typeof(entry->'max_reasoning_effort_multiplier') = 'number' THEN + jsonb_build_object('reasoning_effort_multipliers', + jsonb_build_object('max', entry->'max_reasoning_effort_multiplier')) + ELSE '{}'::jsonb + END + ELSE entry + END ORDER BY ordinal + ) + FROM jsonb_array_elements(g.model_pricing) WITH ORDINALITY AS pricing(entry, ordinal) +) +WHERE jsonb_typeof(g.model_pricing) = 'array' + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements( + CASE WHEN jsonb_typeof(g.model_pricing) = 'array' THEN g.model_pricing ELSE '[]'::jsonb END + ) AS pricing(entry) + WHERE jsonb_typeof(entry) = 'object' AND entry ? 'max_reasoning_effort_multiplier' + ); + +COMMENT ON COLUMN channel_model_pricing.reasoning_effort_multipliers IS + 'Custom billing multipliers by reasoning effort; omitted efforts use 1x'; +COMMENT ON COLUMN channel_account_stats_model_pricing.reasoning_effort_multipliers IS + 'Custom account statistics billing multipliers by reasoning effort; omitted efforts use 1x'; diff --git a/backend/migrations/channel_reasoning_effort_multipliers_migration_test.go b/backend/migrations/channel_reasoning_effort_multipliers_migration_test.go new file mode 100644 index 0000000000..ed3da3ba21 --- /dev/null +++ b/backend/migrations/channel_reasoning_effort_multipliers_migration_test.go @@ -0,0 +1,25 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestChannelReasoningEffortMultipliersMigration(t *testing.T) { + content, err := FS.ReadFile("239_channel_reasoning_effort_multipliers.sql") + require.NoError(t, err) + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, "ALTER TABLE channel_model_pricing ADD COLUMN IF NOT EXISTS reasoning_effort_multipliers JSONB NOT NULL DEFAULT '{}'::jsonb") + require.Contains(t, sql, "ALTER TABLE channel_account_stats_model_pricing ADD COLUMN IF NOT EXISTS reasoning_effort_multipliers JSONB NOT NULL DEFAULT '{}'::jsonb") + require.Contains(t, sql, "WHERE max_reasoning_effort_multiplier IS NOT NULL") + require.Contains(t, sql, "jsonb_build_object('max', max_reasoning_effort_multiplier)") + require.Contains(t, sql, "attrelid = 'channel_model_pricing'::regclass AND attname = 'reasoning_effort_multipliers' AND NOT attisdropped") + require.Less(t, strings.Index(sql, "UPDATE channel_model_pricing"), strings.Index(sql, "END IF;")) + require.Contains(t, sql, "UPDATE groups AS g SET model_pricing") + require.Contains(t, sql, "NOT (entry ? 'reasoning_effort_multipliers')") + require.Contains(t, sql, "entry - 'max_reasoning_effort_multiplier'") + require.Contains(t, sql, "END ORDER BY ordinal") + require.NotContains(t, strings.ToLower(sql), "fable") +} diff --git a/docs/screenshots/reasoning-effort-pricing.png b/docs/screenshots/reasoning-effort-pricing.png new file mode 100644 index 0000000000..df7b1ef7a2 Binary files /dev/null and b/docs/screenshots/reasoning-effort-pricing.png differ diff --git a/frontend/src/api/admin/channels.ts b/frontend/src/api/admin/channels.ts index d956bb92c7..a04660ca97 100644 --- a/frontend/src/api/admin/channels.ts +++ b/frontend/src/api/admin/channels.ts @@ -50,7 +50,7 @@ export interface ChannelModelPricing { cache_read_price: number | null fast_multiplier?: number | null flex_multiplier?: number | null - max_reasoning_effort_multiplier?: number | null + reasoning_effort_multipliers?: Record | null image_input_price: number | null image_output_price: number | null per_request_price: number | null @@ -180,7 +180,7 @@ export interface ModelDefaultPricing { cache_read_price?: number image_input_price?: number image_output_price?: number - max_reasoning_effort_multiplier?: number | null + reasoning_effort_multipliers?: Record | null } export async function getModelDefaultPricing(model: string): Promise { diff --git a/frontend/src/api/channels.ts b/frontend/src/api/channels.ts index 5c3f83542e..e229bbcba3 100644 --- a/frontend/src/api/channels.ts +++ b/frontend/src/api/channels.ts @@ -45,7 +45,7 @@ export interface UserSupportedModelPricing { cache_write_price: number | null cache_write_1h_price?: number | null cache_read_price: number | null - max_reasoning_effort_multiplier?: number | null + reasoning_effort_multipliers?: Record | null image_input_price: number | null image_output_price: number | null per_request_price: number | null diff --git a/frontend/src/components/admin/channel/PricingEntryCard.vue b/frontend/src/components/admin/channel/PricingEntryCard.vue index ba6fe3cf2f..f2448d6d08 100644 --- a/frontend/src/components/admin/channel/PricingEntryCard.vue +++ b/frontend/src/components/admin/channel/PricingEntryCard.vue @@ -144,7 +144,7 @@ -
+
-
- - -
@@ -261,6 +256,43 @@ />
+ +
+
+ + +
+

{{ t('admin.channels.form.reasoningEffortMultipliersHint') }}

+
+ +
+ +
@@ -275,7 +307,8 @@ import IntervalRow from './IntervalRow.vue' import ModelTagInput from './ModelTagInput.vue' import TimePricingSection from './TimePricingSection.vue' import type { PricingFormEntry, IntervalFormEntry } from './types' -import { perTokenToMTok, getPlatformTagClass } from './types' +import { perTokenToMTok, getPlatformTagClass, isValidPositiveMultiplier, validateReasoningEffortMultipliers } from './types' +import { REASONING_EFFORT_LEVELS, type ReasoningEffortLevel } from '@/constants/channel' import type { BillingMode } from '@/api/admin/channels' import channelsAPI from '@/api/admin/channels' @@ -313,12 +346,20 @@ const billingModeLabel = computed(() => { return opt ? opt.label : props.entry.billing_mode }) -const maxReasoningEffortMultiplierPlaceholder = computed(() => - props.entry.models.some(model => /fable(?:-5-1|-5\.1|5\.1|51)(?!\d)/i.test(model)) - ? t('admin.channels.form.fable51DefaultMaxReasoningMultiplier') - : t('admin.channels.form.multiplierPlaceholder') +const reasoningEffortMultiplierError = computed(() => + validateReasoningEffortMultipliers(props.entry.reasoning_effort_multipliers, t) ) +function updateReasoningEffortMultiplier(effort: ReasoningEffortLevel, value: string) { + const multipliers = { ...props.entry.reasoning_effort_multipliers } + if (value === '') delete multipliers[effort] + else multipliers[effort] = value + emit('update', { + ...props.entry, + reasoning_effort_multipliers: Object.keys(multipliers).length ? multipliers : null, + }) +} + function emitField(field: keyof PricingFormEntry, value: string) { emit('update', { ...props.entry, [field]: value === '' ? null : value }) } @@ -394,7 +435,7 @@ async function onModelsUpdate(newModels: string[]) { cache_read_price: perTokenToMTok(result.cache_read_price ?? null), image_input_price: perTokenToMTok(result.image_input_price ?? null), image_output_price: perTokenToMTok(result.image_output_price ?? null), - max_reasoning_effort_multiplier: result.max_reasoning_effort_multiplier ?? null, + reasoning_effort_multipliers: props.entry.reasoning_effort_multipliers ?? result.reasoning_effort_multipliers ?? null, }) } } catch { diff --git a/frontend/src/components/admin/channel/__tests__/PricingEntryCard.timePricing.spec.ts b/frontend/src/components/admin/channel/__tests__/PricingEntryCard.timePricing.spec.ts index 947ac41c1f..deee992b31 100644 --- a/frontend/src/components/admin/channel/__tests__/PricingEntryCard.timePricing.spec.ts +++ b/frontend/src/components/admin/channel/__tests__/PricingEntryCard.timePricing.spec.ts @@ -1,7 +1,12 @@ -import { shallowMount } from '@vue/test-utils' +import { flushPromises, shallowMount } from '@vue/test-utils' import { describe, expect, it, vi } from 'vitest' import PricingEntryCard from '../PricingEntryCard.vue' import type { PricingFormEntry } from '../types' +import channelsAPI from '@/api/admin/channels' + +vi.mock('@/api/admin/channels', () => ({ + default: { getModelDefaultPricing: vi.fn() }, +})) vi.mock('vue-i18n', async importOriginal => ({ ...await importOriginal(), @@ -18,13 +23,14 @@ function createEntry(billingMode: PricingFormEntry['billing_mode'] = 'token'): P cache_read_price: null, fast_multiplier: null, flex_multiplier: null, - max_reasoning_effort_multiplier: null, + reasoning_effort_multipliers: null, image_input_price: null, image_output_price: null, per_request_price: null, intervals: [], time_pricing: { timezone: 'Asia/Shanghai', + weekdays_only: false, periods: [{ start_time: '09:00', end_time: '12:00', multiplier: '2.00' }], }, } @@ -67,14 +73,14 @@ describe('PricingEntryCard time pricing visibility', () => { ...entry, billing_mode: 'image', intervals: [], - time_pricing: { timezone: 'Asia/Shanghai', periods: [] }, + time_pricing: { timezone: 'Asia/Shanghai', weekdays_only: false, periods: [] }, }) expect(entry.time_pricing.periods).toHaveLength(1) }) }) describe('PricingEntryCard request multipliers', () => { - it('shows Fast, Flex, and Max effort controls only when explicitly enabled', () => { + it('shows Fast and Flex controls only when explicitly enabled', () => { const hidden = shallowMount(PricingEntryCard, { props: { entry: createEntry() } }) expect(hidden.text()).not.toContain('admin.channels.form.fastMultiplier') @@ -83,6 +89,73 @@ describe('PricingEntryCard request multipliers', () => { }) expect(shown.text()).toContain('admin.channels.form.fastMultiplier') expect(shown.text()).toContain('admin.channels.form.flexMultiplier') - expect(shown.text()).toContain('admin.channels.form.maxReasoningEffortMultiplier') + expect(shown.text()).toContain('admin.channels.form.reasoningEffortMultipliers') + }) + + it.each(['token', 'per_request', 'image', 'video'] as const)('supports every reasoning level for %s pricing and account statistics', billingMode => { + const wrapper = shallowMount(PricingEntryCard, { props: { entry: createEntry(billingMode) } }) + expect(wrapper.findAll('[data-reasoning-effort]').map(input => input.attributes('data-reasoning-effort'))) + .toEqual(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']) + }) + + it('edits levels independently, then clears individual and all overrides', async () => { + const entry = { ...createEntry(), reasoning_effort_multipliers: { high: 1.5, max: 3 } } + const wrapper = shallowMount(PricingEntryCard, { props: { entry } }) + const update = () => wrapper.emitted('update')!.at(-1)![0] as PricingFormEntry + + await wrapper.get('[data-reasoning-effort="high"]').setValue('0.75') + expect(update().reasoning_effort_multipliers).toEqual({ high: '0.75', max: 3 }) + expect(entry.reasoning_effort_multipliers.high).toBe(1.5) + await wrapper.setProps({ entry: update() }) + + await wrapper.get('[data-reasoning-effort="max"]').setValue('') + expect(update().reasoning_effort_multipliers).toEqual({ high: '0.75' }) + await wrapper.setProps({ entry: update() }) + + await wrapper.get('[data-testid="reasoning-effort-multipliers"] button').trigger('click') + expect(update().reasoning_effort_multipliers).toBeNull() + await wrapper.setProps({ entry: update() }) + expect(wrapper.get('[data-reasoning-effort="high"]').element.value).toBe('') + }) + + it('clearing the last configured level restores default billing', async () => { + const wrapper = shallowMount(PricingEntryCard, { + props: { entry: { ...createEntry(), reasoning_effort_multipliers: { max: 3 } } }, + }) + await wrapper.get('[data-reasoning-effort="max"]').setValue('') + expect((wrapper.emitted('update')![0][0] as PricingFormEntry).reasoning_effort_multipliers).toBeNull() + }) + + it('marks invalid multipliers and shows a validation error', () => { + const wrapper = shallowMount(PricingEntryCard, { + props: { entry: { ...createEntry(), reasoning_effort_multipliers: { high: 0, max: 2 } } }, + }) + expect(wrapper.get('[data-reasoning-effort="high"]').attributes('aria-invalid')).toBe('true') + expect(wrapper.get('[data-reasoning-effort="max"]').attributes('aria-invalid')).toBe('false') + expect(wrapper.get('[role="alert"]').text()).toContain('reasoningEffortMultiplierPositive') + }) + + it('does not assign a model-specific multiplier to Fable', () => { + const wrapper = shallowMount(PricingEntryCard, { + props: { entry: { ...createEntry(), models: ['claude-fable-5-1'] } }, + }) + expect(wrapper.get('[data-reasoning-effort="max"]').element.value).toBe('') + expect(wrapper.get('[data-reasoning-effort="max"]').attributes('placeholder')) + .toBe('admin.channels.form.reasoningEffortMultiplierDefault') + }) + + it('keeps custom effort multipliers when auto-filling model token prices', async () => { + vi.mocked(channelsAPI.getModelDefaultPricing).mockResolvedValue({ + found: true, input_price: 3e-6, output_price: 15e-6, + }) + const wrapper = shallowMount(PricingEntryCard, { + props: { entry: { ...createEntry(), reasoning_effort_multipliers: { high: 0.5 } } }, + }) + wrapper.findComponent({ name: 'ModelTagInput' }).vm.$emit('update:models', ['example-model']) + await flushPromises() + expect(wrapper.emitted('update')!.at(-1)![0]).toMatchObject({ + models: ['example-model'], input_price: 3, output_price: 15, + reasoning_effort_multipliers: { high: 0.5 }, + }) }) }) diff --git a/frontend/src/components/admin/channel/__tests__/types.spec.ts b/frontend/src/components/admin/channel/__tests__/types.spec.ts index 58b5724239..3a5efd0526 100644 --- a/frontend/src/components/admin/channel/__tests__/types.spec.ts +++ b/frontend/src/components/admin/channel/__tests__/types.spec.ts @@ -4,15 +4,43 @@ import { apiTimePricingToForm, createDefaultTimePricingForm, formIntervalsToAPI, + formReasoningEffortMultipliersToAPI, formTimePricingToAPI, isValidPositiveMultiplier, validateIntervals, + validateReasoningEffortMultipliers, validateTimePricing, type IntervalFormEntry, type TimePricingFormEntry, type TimePricingPeriodFormEntry, } from '../types' +describe('reasoning effort multipliers', () => { + it('serializes independent overrides without altering their values', () => { + expect(formReasoningEffortMultipliersToAPI({ none: '0.5', high: 1, max: '3', low: '' })) + .toEqual({ none: 0.5, high: 1, max: 3 }) + }) + + it.each([null, undefined, {}, { max: '' }])('clears empty overrides with null: %j', value => { + expect(formReasoningEffortMultipliersToAPI(value)).toBeNull() + expect(validateReasoningEffortMultipliers(value, t)).toBeNull() + }) + + it('accepts supported levels with positive finite multipliers, including discounts', () => { + expect(validateReasoningEffortMultipliers({ + none: 0.01, minimal: 0.5, low: 1, medium: '1.2', high: 2, xhigh: 2.5, max: 3, + }, t)).toBeNull() + }) + + it.each([0, -1, Infinity, NaN, 'invalid', 'Infinity'])('rejects invalid multiplier %s', multiplier => { + expect(validateReasoningEffortMultipliers({ high: multiplier }, t)).toContain('reasoningEffortMultiplierPositive') + }) + + it('rejects unsupported effort keys', () => { + expect(validateReasoningEffortMultipliers({ unknown: 2 }, t)).toContain('reasoningEffortLevelInvalid') + }) +}) + describe('interval multiplier conversion', () => { it('preserves component multipliers without MTok conversion', () => { const form = apiIntervalsToForm([{ diff --git a/frontend/src/components/admin/channel/types.ts b/frontend/src/components/admin/channel/types.ts index 0575e1b8c6..bd818ea4e5 100644 --- a/frontend/src/components/admin/channel/types.ts +++ b/frontend/src/components/admin/channel/types.ts @@ -1,4 +1,5 @@ import type { BillingMode, ChannelTimePricing, PricingInterval } from '@/api/admin/channels' +import { REASONING_EFFORT_LEVELS } from '@/constants/channel' type TranslateFn = (key: string, params?: Record) => string @@ -29,7 +30,7 @@ export interface PricingFormEntry { cache_read_price: number | string | null fast_multiplier?: number | string | null flex_multiplier?: number | string | null - max_reasoning_effort_multiplier?: number | string | null + reasoning_effort_multipliers?: Record | null image_input_price: number | string | null image_output_price: number | string | null per_request_price: number | string | null @@ -182,6 +183,30 @@ export function isValidPositiveMultiplier(val: number | string | null | undefine return Number.isFinite(multiplier) && multiplier > 0 } +export function formReasoningEffortMultipliersToAPI( + value: PricingFormEntry['reasoning_effort_multipliers'], +): Record | null { + const entries = Object.entries(value || {}) + .filter(([, multiplier]) => multiplier !== '') + .map(([effort, multiplier]) => [effort, Number(multiplier)]) + return entries.length ? Object.fromEntries(entries) : null +} + +export function validateReasoningEffortMultipliers( + value: PricingFormEntry['reasoning_effort_multipliers'], + t: TranslateFn, +): string | null { + for (const [effort, multiplier] of Object.entries(value || {})) { + if (!REASONING_EFFORT_LEVELS.some(level => level === effort)) { + return t('admin.channels.form.reasoningEffortLevelInvalid', { effort }) + } + if (multiplier !== '' && !isValidPositiveMultiplier(multiplier)) { + return t('admin.channels.form.reasoningEffortMultiplierPositive', { effort }) + } + } + return null +} + /** 前端显示值($/MTok) → 后端存储值(per-token) */ export function mTokToPerToken(val: number | string | null | undefined): number | null { const num = toNullableNumber(val) diff --git a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue index 125154f2e9..71f5ec024d 100644 --- a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue +++ b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue @@ -100,11 +100,13 @@ {{ t('modelPlaza.table.marginalBadge') }} - {{ t('modelPlaza.table.maxReasoningMultiplierBadge', { multiplier: m.pricing.max_reasoning_effort_multiplier }) }} + {{ t('modelPlaza.table.reasoningMultiplierBadge', { effort, multiplier }) }} @@ -311,11 +313,22 @@ import { platformAccentColor, platformBadgeLightClass, platformLabel } from '@/u import { BILLING_MODE_TOKEN, BILLING_MODE_IMAGE, + REASONING_EFFORT_LEVELS, type BillingMode } from '@/constants/channel' import type { PlazaModel, PlazaTimePricingPeriod } from '@/api/modelPlaza' import type { UserPricingInterval } from '@/api/channels' +function reasoningEffortMultipliers(model: PlazaModel): [string, number][] { + const multipliers = model.pricing?.reasoning_effort_multipliers + return REASONING_EFFORT_LEVELS.flatMap(effort => { + const multiplier = multipliers?.[effort] + return typeof multiplier === 'number' && Number.isFinite(multiplier) && multiplier > 0 + ? [[effort, multiplier] as [string, number]] + : [] + }) +} + const props = defineProps<{ models: PlazaModel[] /** 分组平台;实付分区底色随平台着色,未知平台回退品牌青。 */ diff --git a/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts b/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts index 9813e40fa5..c8c6bbe77e 100644 --- a/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts +++ b/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts @@ -68,13 +68,27 @@ describe('PlazaModelPricingTable', () => { expect(text).toContain('1x') }) - it('shows the Max reasoning billing multiplier', () => { + it('shows all configured reasoning multipliers in level order', () => { const model = tokenModel() - model.pricing!.max_reasoning_effort_multiplier = 3 + model.pricing!.reasoning_effort_multipliers = { max: 3, none: 0.5, high: 1.5 } const wrapper = mountTable([model], 1) - expect(wrapper.text()).toContain('modelPlaza.table.maxReasoningMultiplierBadge') - expect(wrapper.find('[title="modelPlaza.table.maxReasoningMultiplierHint"]').exists()).toBe(true) + const badges = wrapper.findAll('[data-reasoning-effort]') + expect(badges.map(badge => badge.attributes('data-reasoning-effort'))).toEqual(['none', 'high', 'max']) + expect(badges.every(badge => badge.attributes('title') === 'modelPlaza.table.reasoningMultiplierHint')).toBe(true) + expect(wrapper.text()).toContain('modelPlaza.table.reasoningMultiplierBadge') + }) + + it('does not show automatic reasoning charges for an unconfigured Fable model', () => { + const wrapper = mountTable([tokenModel({ name: 'claude-fable-5-1' })], 1) + expect(wrapper.find('[data-reasoning-effort]').exists()).toBe(false) + }) + + it('omits invalid or unsupported multipliers from display', () => { + const model = tokenModel() + model.pricing!.reasoning_effort_multipliers = { max: 0, high: Infinity, unknown: 2, low: 1 } + const wrapper = mountTable([model], 1) + expect(wrapper.findAll('[data-reasoning-effort]').map(badge => badge.attributes('data-reasoning-effort'))).toEqual(['low']) }) it('倍率 ≠ 1 时价格列为折后实付价,官方价列保持原价', () => { diff --git a/frontend/src/constants/channel.ts b/frontend/src/constants/channel.ts index 1e1b9205de..0d548d1fbc 100644 --- a/frontend/src/constants/channel.ts +++ b/frontend/src/constants/channel.ts @@ -14,6 +14,10 @@ export type BillingMode = | typeof BILLING_MODE_IMAGE | typeof BILLING_MODE_VIDEO +/** Supported request reasoning effort levels, ordered from lowest to highest. */ +export const REASONING_EFFORT_LEVELS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const +export type ReasoningEffortLevel = typeof REASONING_EFFORT_LEVELS[number] + /** Billing-model-source values (must match service.BillingModelSource* constants in Go). */ export const BILLING_MODEL_SOURCE_REQUESTED = 'requested' as const export const BILLING_MODEL_SOURCE_UPSTREAM = 'upstream' as const diff --git a/frontend/src/i18n/locales/en/admin/channels.ts b/frontend/src/i18n/locales/en/admin/channels.ts index 77f5c00644..db867cfc6f 100644 --- a/frontend/src/i18n/locales/en/admin/channels.ts +++ b/frontend/src/i18n/locales/en/admin/channels.ts @@ -140,10 +140,15 @@ export default { pricePlaceholder: 'Default', fastMultiplier: 'Fast Multiplier', flexMultiplier: 'Flex Multiplier', - maxReasoningEffortMultiplier: 'Max Effort Multiplier', - fable51DefaultMaxReasoningMultiplier: 'Default: 3', + reasoningEffortMultipliers: 'Custom Reasoning Effort Multipliers (optional)', + reasoningEffortMultipliersHint: 'Apply the multiplier for the final forwarded reasoning effort to the entire request cost and quota usage. Unconfigured levels use 1×. These multiply with other billing multipliers.', + reasoningEffortMultiplierLabel: '{effort} reasoning effort multiplier', + reasoningEffortMultiplierDefault: 'Default: 1', + clearReasoningEffortMultipliers: 'Clear multipliers', + reasoningEffortMultiplierPositive: 'The {effort} reasoning effort multiplier must be a finite number greater than 0', + reasoningEffortLevelInvalid: 'Unsupported reasoning effort: {effort}', multiplierPlaceholder: 'Not set', - multiplierPositive: 'Fast/Flex/Max effort multipliers must be greater than 0', + multiplierPositive: 'Fast/Flex multipliers must be finite numbers greater than 0', inputMultiplier: 'Input Mult.', outputMultiplier: 'Output Mult.', cacheWriteMultiplier: 'Cache Write Mult.', diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts index 76be95b4d2..0a8d8d352a 100644 --- a/frontend/src/i18n/locales/en/dashboard.ts +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -673,8 +673,8 @@ export default { cacheReadShort: 'R', tierHint: 'The whole request is billed at the tier matching its total context (input + cache write + cache read)', tierHintMarginal: 'Only the portion above the threshold is billed at this tier; output is unaffected', - maxReasoningMultiplierBadge: 'Max ×{multiplier}', - maxReasoningMultiplierHint: 'When the forwarded reasoning effort is max, billing and quota usage for the request are multiplied by {multiplier}', + reasoningMultiplierBadge: '{effort} ×{multiplier}', + reasoningMultiplierHint: 'When the forwarded reasoning effort is {effort}, billing and quota usage for the request are multiplied by {multiplier}. Unconfigured levels use 1×', marginalBadge: 'excess-only tiers', timePricingRowHint: 'Requests made within this period ({timezone} time) are billed at the prices in this row', timePricingRowHintWeekdays: diff --git a/frontend/src/i18n/locales/zh/admin/channels.ts b/frontend/src/i18n/locales/zh/admin/channels.ts index 7680091195..6d841f9323 100644 --- a/frontend/src/i18n/locales/zh/admin/channels.ts +++ b/frontend/src/i18n/locales/zh/admin/channels.ts @@ -140,10 +140,15 @@ export default { pricePlaceholder: '默认', fastMultiplier: 'Fast 倍率', flexMultiplier: 'Flex 倍率', - maxReasoningEffortMultiplier: 'Max 推理倍率', - fable51DefaultMaxReasoningMultiplier: '默认 3', + reasoningEffortMultipliers: '自定义思考等级倍率(可选)', + reasoningEffortMultipliersHint: '按请求最终转发的思考等级,对整次请求的费用与额度消耗应用对应倍率。未配置的等级按 1 倍计费,可与其他计费倍率叠加。', + reasoningEffortMultiplierLabel: '{effort} 思考等级倍率', + reasoningEffortMultiplierDefault: '默认 1', + clearReasoningEffortMultipliers: '清空倍率', + reasoningEffortMultiplierPositive: '{effort} 思考等级倍率必须为大于 0 的有限数字', + reasoningEffortLevelInvalid: '不支持的思考等级:{effort}', multiplierPlaceholder: '未配置', - multiplierPositive: 'Fast/Flex/Max 推理倍率必须大于 0', + multiplierPositive: 'Fast/Flex 倍率必须为大于 0 的有限数字', inputMultiplier: '输入倍率', outputMultiplier: '输出倍率', cacheWriteMultiplier: '缓存写倍率', diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts index d74ff78d99..7393964b0f 100644 --- a/frontend/src/i18n/locales/zh/dashboard.ts +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -678,8 +678,8 @@ export default { cacheReadShort: '读', tierHint: '按单次请求的总上下文(输入 + 缓存写入 + 缓存读取)所在档位对整单计价', tierHintMarginal: '仅超过阈值的部分按该档计价,输出不加价', - maxReasoningMultiplierBadge: 'Max ×{multiplier}', - maxReasoningMultiplierHint: '最终转发的推理强度为 max 时,整次请求的计费与额度消耗乘以 {multiplier}', + reasoningMultiplierBadge: '{effort} ×{multiplier}', + reasoningMultiplierHint: '最终转发的思考等级为 {effort} 时,整次请求的计费与额度消耗乘以 {multiplier};未配置的等级按 1 倍计费', marginalBadge: '超出部分计价', timePricingRowHint: '按 {timezone} 时间,在该时段内发起的请求按本行价格计费', timePricingRowHintWeekdays: diff --git a/frontend/src/views/admin/ChannelsView.vue b/frontend/src/views/admin/ChannelsView.vue index ace4b14519..6e5e351d6a 100644 --- a/frontend/src/views/admin/ChannelsView.vue +++ b/frontend/src/views/admin/ChannelsView.vue @@ -634,7 +634,7 @@ import { extractApiErrorMessage } from '@/utils/apiError' import { adminAPI } from '@/api/admin' import type { Channel, ChannelModelPricing, CreateChannelRequest, UpdateChannelRequest, AccountStatsPricingRule } from '@/api/admin/channels' import type { PricingFormEntry } from '@/components/admin/channel/types' -import { apiIntervalsToForm, apiTimePricingToForm, createDefaultTimePricingForm, findModelConflict, formIntervalsToAPI, formTimePricingToAPI, isValidPositiveMultiplier, mTokToPerToken, perTokenToMTok, validateIntervals, validateTimePricing } from '@/components/admin/channel/types' +import { apiIntervalsToForm, apiTimePricingToForm, createDefaultTimePricingForm, findModelConflict, formIntervalsToAPI, formReasoningEffortMultipliersToAPI, formTimePricingToAPI, isValidPositiveMultiplier, mTokToPerToken, perTokenToMTok, validateIntervals, validateReasoningEffortMultipliers, validateTimePricing } from '@/components/admin/channel/types' import type { AdminGroup, GroupPlatform } from '@/types' import type { Column } from '@/components/common/types' import { platformTextClass, platformBadgeLightClass } from '@/utils/platformColors' @@ -865,7 +865,7 @@ function addPricingEntry(sectionIdx: number) { cache_read_price: null, fast_multiplier: null, flex_multiplier: null, - max_reasoning_effort_multiplier: null, + reasoning_effort_multipliers: null, image_input_price: null, image_output_price: null, per_request_price: null, @@ -903,7 +903,7 @@ async function syncLatestModels(sectionIdx: number) { cache_read_price: null, fast_multiplier: null, flex_multiplier: null, - max_reasoning_effort_multiplier: null, + reasoning_effort_multipliers: null, image_input_price: null, image_output_price: null, per_request_price: null, @@ -1089,6 +1089,7 @@ function accountStatsRulesToAPI(): AccountStatsPricingRule[] { cache_write_price: mTokToPerToken(p.cache_write_price), cache_write_1h_price: mTokToPerToken(p.cache_write_1h_price), cache_read_price: mTokToPerToken(p.cache_read_price), + reasoning_effort_multipliers: formReasoningEffortMultipliersToAPI(p.reasoning_effort_multipliers), image_input_price: mTokToPerToken(p.image_input_price), image_output_price: mTokToPerToken(p.image_output_price), per_request_price: p.per_request_price != null && p.per_request_price !== '' ? Number(p.per_request_price) : null, @@ -1134,7 +1135,7 @@ function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[ cache_read_price: mTokToPerToken(entry.cache_read_price), fast_multiplier: entry.fast_multiplier != null && entry.fast_multiplier !== '' ? Number(entry.fast_multiplier) : null, flex_multiplier: entry.flex_multiplier != null && entry.flex_multiplier !== '' ? Number(entry.flex_multiplier) : null, - max_reasoning_effort_multiplier: entry.max_reasoning_effort_multiplier != null && entry.max_reasoning_effort_multiplier !== '' ? Number(entry.max_reasoning_effort_multiplier) : null, + reasoning_effort_multipliers: formReasoningEffortMultipliersToAPI(entry.reasoning_effort_multipliers), image_input_price: mTokToPerToken(entry.image_input_price), image_output_price: mTokToPerToken(entry.image_output_price), per_request_price: entry.per_request_price != null && entry.per_request_price !== '' ? Number(entry.per_request_price) : null, @@ -1237,7 +1238,7 @@ function apiToForm(channel: Channel): PlatformSection[] { cache_read_price: perTokenToMTok(p.cache_read_price), fast_multiplier: p.fast_multiplier, flex_multiplier: p.flex_multiplier, - max_reasoning_effort_multiplier: p.max_reasoning_effort_multiplier, + reasoning_effort_multipliers: p.reasoning_effort_multipliers ? { ...p.reasoning_effort_multipliers } : null, image_input_price: perTokenToMTok(p.image_input_price), image_output_price: perTokenToMTok(p.image_output_price), per_request_price: p.per_request_price, @@ -1429,6 +1430,7 @@ function distributeRulesToPlatforms(apiRules: AccountStatsPricingRule[]) { cache_write_price: perTokenToMTok(p.cache_write_price), cache_write_1h_price: perTokenToMTok(p.cache_write_1h_price), cache_read_price: perTokenToMTok(p.cache_read_price), + reasoning_effort_multipliers: p.reasoning_effort_multipliers ? { ...p.reasoning_effort_multipliers } : null, image_input_price: perTokenToMTok(p.image_input_price), image_output_price: perTokenToMTok(p.image_output_price), per_request_price: p.per_request_price, @@ -1541,12 +1543,28 @@ async function handleSubmit() { } } + // 思考等级倍率同时适用于渠道计价和独立的账号统计计价规则。 + for (const section of form.platforms.filter(s => s.enabled)) { + const entries = [ + ...section.model_pricing, + ...section.account_stats_pricing_rules.flatMap(rule => rule.pricing), + ] + for (const entry of entries) { + const error = validateReasoningEffortMultipliers(entry.reasoning_effort_multipliers, t) + if (!error) continue + const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform) + const modelLabel = entry.models.join(', ') || t('admin.channels.form.unnamed') + appStore.showError(`${platformLabel} - ${modelLabel}: ${error}`) + activeTab.value = section.platform + return + } + } + // 校验区间合法性(范围、重叠等) for (const section of form.platforms.filter(s => s.enabled)) { for (const entry of section.model_pricing) { if (!isValidPositiveMultiplier(entry.fast_multiplier) || - !isValidPositiveMultiplier(entry.flex_multiplier) || - !isValidPositiveMultiplier(entry.max_reasoning_effort_multiplier)) { + !isValidPositiveMultiplier(entry.flex_multiplier)) { const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform) const modelLabel = entry.models.join(', ') || t('admin.channels.form.unnamed') appStore.showError(`${platformLabel} - ${modelLabel}: ${t('admin.channels.form.multiplierPositive')}`) diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 85da60ed41..471c4be44c 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -4309,9 +4309,11 @@ import { apiIntervalsToForm, createDefaultTimePricingForm, formIntervalsToAPI, + formReasoningEffortMultipliersToAPI, mTokToPerToken, perTokenToMTok, toNullableNumber, + validateReasoningEffortMultipliers, } from "@/components/admin/channel/types"; import type { ChannelModelPricing } from "@/api/admin/channels"; import { VueDraggable } from "vue-draggable-plus"; @@ -4386,6 +4388,7 @@ const emptyGroupPricing = (): PricingFormEntry => ({ cache_write_price: null, cache_write_1h_price: null, cache_read_price: null, + reasoning_effort_multipliers: null, image_input_price: null, image_output_price: null, per_request_price: null, @@ -4407,6 +4410,9 @@ const groupPricingFromAPI = ( cache_write_price: perTokenToMTok(entry.cache_write_price), cache_write_1h_price: perTokenToMTok(entry.cache_write_1h_price), cache_read_price: perTokenToMTok(entry.cache_read_price), + reasoning_effort_multipliers: entry.reasoning_effort_multipliers + ? { ...entry.reasoning_effort_multipliers } + : null, image_input_price: perTokenToMTok(entry.image_input_price), image_output_price: perTokenToMTok(entry.image_output_price), per_request_price: entry.per_request_price, @@ -4429,6 +4435,9 @@ const groupPricingToAPI = ( cache_write_price: mTokToPerToken(entry.cache_write_price), cache_write_1h_price: mTokToPerToken(entry.cache_write_1h_price), cache_read_price: mTokToPerToken(entry.cache_read_price), + reasoning_effort_multipliers: formReasoningEffortMultipliersToAPI( + entry.reasoning_effort_multipliers, + ), image_input_price: mTokToPerToken(entry.image_input_price), image_output_price: mTokToPerToken(entry.image_output_price), per_request_price: toNullableNumber(entry.per_request_price), @@ -5857,6 +5866,17 @@ const validateProfitControlForm = (form: ProfitControlFormState): boolean => { return true; }; +const validateGroupReasoningMultipliers = (pricing: PricingFormEntry[]): boolean => { + for (const entry of pricing) { + const error = validateReasoningEffortMultipliers(entry.reasoning_effort_multipliers, t); + if (error) { + appStore.showError(`${entry.models.join(", ") || t("admin.channels.form.unnamed")}: ${error}`); + return false; + } + } + return true; +}; + const handleCreateGroup = async () => { if (!createForm.name.trim()) { appStore.showError(t("admin.groups.nameRequired")); @@ -5872,6 +5892,7 @@ const handleCreateGroup = async () => { if (!validateProfitControlForm(createForm)) { return; } + if (!validateGroupReasoningMultipliers(createForm.model_pricing)) return; // 模型白名单:开启且没有任何条目时阻止提交,与后端 400 对齐。 if ( createModelAllowlistState.enabled && @@ -6203,6 +6224,7 @@ const handleUpdateGroup = async () => { if (!validateProfitControlForm(editForm)) { return; } + if (!validateGroupReasoningMultipliers(editForm.model_pricing)) return; // 模型白名单:开启且没有任何条目时阻止提交,与后端 400 对齐。 if ( editModelAllowlistState.enabled && diff --git a/frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts b/frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts index e10e5b9869..d125725228 100644 --- a/frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts +++ b/frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts @@ -325,6 +325,65 @@ describe('GroupsView duplicate action', () => { wrapper.unmount() }) + it('loads, edits, and saves custom reasoning multipliers for group pricing', async () => { + const group = { + ...sourceGroup, + model_pricing: [{ + platform: 'openai', models: ['example-model'], billing_mode: 'token', + input_price: 3e-6, output_price: 15e-6, cache_write_price: null, cache_read_price: null, + image_input_price: null, image_output_price: null, per_request_price: null, + reasoning_effort_multipliers: { high: 1.5, max: 3 }, intervals: [], time_pricing: null, + }], + } + listGroups.mockResolvedValue({ items: [group], total: 1, page: 1, page_size: 20, pages: 1 }) + updateGroup.mockResolvedValue(group) + const wrapper = mountView() + await flushPromises() + await wrapper.findAll('button').find(button => button.text() === 'common.edit')!.trigger('click') + await flushPromises() + + expect(wrapper.get('[data-reasoning-effort="high"]').element.value).toBe('1.5') + await wrapper.get('[data-reasoning-effort="high"]').setValue('0.5') + await wrapper.get('[data-reasoning-effort="max"]').setValue('') + await wrapper.get('#edit-group-form').trigger('submit') + await flushPromises() + expect(updateGroup).toHaveBeenCalledWith(42, expect.objectContaining({ + model_pricing: [expect.objectContaining({ reasoning_effort_multipliers: { high: 0.5 } })], + })) + wrapper.unmount() + }) + + it('blocks saving an invalid group reasoning multiplier and allows clearing it', async () => { + const group = { + ...sourceGroup, + model_pricing: [{ + platform: 'openai', models: ['example-model'], billing_mode: 'token', + input_price: 3e-6, output_price: 15e-6, cache_write_price: null, cache_read_price: null, + image_input_price: null, image_output_price: null, per_request_price: null, + reasoning_effort_multipliers: { high: 1.5 }, intervals: [], time_pricing: null, + }], + } + listGroups.mockResolvedValue({ items: [group], total: 1, page: 1, page_size: 20, pages: 1 }) + updateGroup.mockResolvedValue(group) + const wrapper = mountView() + await flushPromises() + await wrapper.findAll('button').find(button => button.text() === 'common.edit')!.trigger('click') + await flushPromises() + + await wrapper.get('[data-reasoning-effort="high"]').setValue('0') + await wrapper.get('#edit-group-form').trigger('submit') + expect(updateGroup).not.toHaveBeenCalled() + expect(showError).toHaveBeenCalledWith(expect.stringContaining('reasoningEffortMultiplierPositive')) + + await wrapper.get('[data-testid="reasoning-effort-multipliers"] button').trigger('click') + await wrapper.get('#edit-group-form').trigger('submit') + await flushPromises() + expect(updateGroup).toHaveBeenCalledWith(42, expect.objectContaining({ + model_pricing: [expect.objectContaining({ reasoning_effort_multipliers: null })], + })) + wrapper.unmount() + }) + it('updates manifest controls immediately and submits the displayed selection', async () => { vi.useFakeTimers() vi.mocked(adminAPI.accounts.list).mockResolvedValue({