feat(billing): support configurable reasoning effort multipliers

This commit is contained in:
haruka
2026-09-20 16:41:49 +08:00
parent acdf3c54da
commit b8d52fad34
52 changed files with 1709 additions and 354 deletions
@@ -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,
})
}
@@ -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) {
@@ -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,
}
}
@@ -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)
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
@@ -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
@@ -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())
}
@@ -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))
@@ -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)
@@ -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) {
+46 -61
View File
@@ -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
@@ -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) {
@@ -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
+23 -21
View File
@@ -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)
+18 -18
View File
@@ -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 {
@@ -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)
}
})
}
}
@@ -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
+4 -1
View File
@@ -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"
@@ -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)
})
}
}
}
}
@@ -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
@@ -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))
@@ -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)
})
}
}
@@ -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
@@ -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) {
@@ -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
@@ -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
@@ -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) {
@@ -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"
@@ -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: "缺失字段时从模型后缀推导",
@@ -726,17 +726,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(
@@ -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"))
}
@@ -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';
@@ -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")
}