Merge pull request #7412 from StarryKira/codex/custom-reasoning-effort-billing

feat: 支持自定义思考等级倍率计费
This commit is contained in:
Wesley Liddick
2026-09-21 22:55:17 +08:00
committed by GitHub
63 changed files with 2261 additions and 403 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) {
@@ -467,6 +467,7 @@ handleSuccess:
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
Stream: stream,
ReasoningEffort: extractGeminiReasoningEffortFromBody(injectedBody),
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ClientDisconnect: clientDisconnect,
+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,143 @@
//go:build unit
package service
import (
"context"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestRecordUsage_ImageReasoningPricing(t *testing.T) {
for _, platform := range []string{PlatformAnthropic, PlatformOpenAI} {
for _, source := range []string{PricingSourceChannel, PricingSourceGroup} {
for _, mode := range []BillingMode{BillingModeImage, BillingModePerRequest} {
for _, independent := range []bool{false, true} {
for _, effort := range []string{"high", "low", ""} {
name := fmt.Sprintf("%s/%s/%s/independent=%t/effort=%s", platform, source, mode, independent, effort)
t.Run(name, func(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
userRepo := &openAIRecordUsageUserRepoStub{}
subRepo := &openAIRecordUsageSubRepoStub{}
groupID := int64(77)
model := "gemini-3-pro-image-preview"
price := 0.25
pricing := ChannelModelPricing{
Models: []string{model}, BillingMode: mode, PerRequestPrice: &price,
ReasoningEffortMultipliers: map[string]float64{"high": 2},
}
resolver := newOpenAIImageChannelPricingResolverForTest(t, groupID, model, price)
cache := resolver.channelService.cache.Load().(*channelCache)
cache.pricingByGroupModel[channelModelKey{groupID: groupID, model: model}] = &pricing
group := &Group{
ID: groupID, Platform: platform, Status: StatusActive, Hydrated: true,
RateMultiplier: 0.5, ImageRateIndependent: independent, ImageRateMultiplier: 0.25,
}
if source == PricingSourceGroup {
group.ModelPricing = []ChannelModelPricing{pricing}
// A matching group card owns the multiplier; the channel must not stack on top.
pricing.ReasoningEffortMultipliers = map[string]float64{"high": 3}
}
apiKey := &APIKey{ID: 10, GroupID: &groupID, Group: group}
account := &Account{ID: 30, Platform: platform, Type: AccountTypeAPIKey}
user := &User{ID: 20}
var forwardedEffort *string
if effort != "" {
forwardedEffort = &effort
}
if platform == PlatformAnthropic {
svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, subRepo)
svc.resolver = resolver
require.NoError(t, svc.RecordUsage(context.Background(), &RecordUsageInput{
Result: &ForwardResult{
RequestID: "image_reasoning_pricing", Model: model, Duration: time.Second,
ImageCount: 2, ImageSize: "1K", ReasoningEffort: forwardedEffort,
},
APIKey: apiKey, User: user, Account: account,
}))
} else {
svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil)
svc.resolver = resolver
require.NoError(t, svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{
RequestID: "image_reasoning_pricing", Model: model, Duration: time.Second,
ImageCount: 2, ImageSize: "1K", ReasoningEffort: forwardedEffort,
},
APIKey: apiKey, User: user, Account: account,
}))
}
wantTotal := 0.5
if effort == "high" {
wantTotal *= 2
}
wantRate := 0.5
if independent {
wantRate = 0.25
}
require.NotNil(t, usageRepo.lastLog)
require.InDelta(t, wantTotal, usageRepo.lastLog.TotalCost, 1e-12)
require.InDelta(t, wantTotal*wantRate, usageRepo.lastLog.ActualCost, 1e-12)
require.InDelta(t, wantTotal*wantRate, userRepo.lastAmount, 1e-12)
require.Equal(t, forwardedEffort, usageRepo.lastLog.ReasoningEffort)
require.Equal(t, 2, usageRepo.lastLog.ImageCount)
require.Equal(t, string(mode), *usageRepo.lastLog.BillingMode)
})
}
}
}
}
}
}
func TestCalculateRecordUsageCost_MediaReasoningPricing(t *testing.T) {
for _, source := range []string{PricingSourceChannel, PricingSourceGroup} {
for _, media := range []string{"gateway_audio", "openai_audio", "openai_video"} {
t.Run(source+"/"+media, func(t *testing.T) {
groupID := int64(77)
model, effort := "media-model", "high"
price := 0.25
resolver := newOpenAIImageChannelPricingResolverForTest(t, groupID, model, price)
cache := resolver.channelService.cache.Load().(*channelCache)
pricing := cache.pricingByGroupModel[channelModelKey{groupID: groupID, model: model}]
pricing.BillingMode = BillingModePerRequest
pricing.ReasoningEffortMultipliers = map[string]float64{"high": 2}
wantTotal := 1.0 // Two units at $0.25, with high=2.
if media == "openai_video" {
pricing.BillingMode = BillingModeVideo
wantTotal = 5 // Two five-second videos at $0.25/second, with high=2.
}
apiKey := &APIKey{GroupID: &groupID, Group: &Group{ID: groupID, Hydrated: true}}
if source == PricingSourceGroup {
pricing.Models = []string{model}
apiKey.Group.ModelPricing = []ChannelModelPricing{*pricing}
pricing.ReasoningEffortMultipliers = map[string]float64{"high": 3}
}
var cost *CostBreakdown
if media == "gateway_audio" {
svc := &GatewayService{billingService: resolver.billingService, resolver: resolver}
cost = svc.calculateRecordUsageCost(context.Background(), &ForwardResult{
ReasoningEffort: &effort, AudioUsage: &AudioUsage{Mode: "tts", DurationOrUnits: 2},
}, apiKey, model, 0.5, 0.5, time.Time{})
} else {
svc := &OpenAIGatewayService{billingService: resolver.billingService, resolver: resolver}
result := &OpenAIForwardResult{ReasoningEffort: &effort}
if media == "openai_audio" {
result.AudioUsage = &AudioUsage{Mode: "tts", DurationOrUnits: 2}
} else {
result.VideoCount, result.VideoDurationSeconds = 2, 5
}
var err error
cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey,
[]string{model}, 0.5, 0.5, 0.5, 0.5, UsageTokens{}, "", nil, time.Time{})
require.NoError(t, err)
}
require.NotNil(t, cost)
require.InDelta(t, wantTotal, cost.TotalCost, 1e-12)
require.InDelta(t, wantTotal*0.5, cost.ActualCost, 1e-12)
})
}
}
}
@@ -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)
})
}
}
@@ -903,6 +903,7 @@ func (s *GatewayService) calculateRecordUsageCost(
Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group,
UsageUnits: result.AudioUsage.DurationOrUnits, SizeTier: result.AudioUsage.Mode,
RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved,
ReasoningEffort: optionalStringValue(result.ReasoningEffort),
})
if err == nil {
return cost
@@ -1027,6 +1028,7 @@ func (s *GatewayService) calculateImageCost(
Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group,
RequestCount: result.ImageCount, SizeTier: sizeTier,
RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved,
ReasoningEffort: optionalStringValue(result.ReasoningEffort),
})
if err == nil {
return cost
@@ -1044,16 +1046,17 @@ func (s *GatewayService) calculateImageCost(
}
gid := apiKey.Group.ID
cost, err := s.billingService.CalculateCostUnified(CostInput{
Ctx: ctx,
Model: billingModel,
GroupID: &gid,
Group: apiKey.Group,
Tokens: tokens,
RequestCount: result.ImageCount,
SizeTier: sizeTier,
RateMultiplier: multiplier,
Resolver: s.resolver,
Resolved: resolved,
Ctx: ctx,
Model: billingModel,
GroupID: &gid,
Group: apiKey.Group,
Tokens: tokens,
RequestCount: result.ImageCount,
SizeTier: sizeTier,
ReasoningEffort: optionalStringValue(result.ReasoningEffort),
RateMultiplier: multiplier,
Resolver: s.resolver,
Resolved: resolved,
})
if err != nil {
logger.LegacyPrintf("service.gateway", "Calculate image token cost failed: %v", err)
@@ -58,7 +58,7 @@ func (s *GeminiMessagesCompatService) ForwardAsChatCompletions(
return nil, fmt.Errorf("marshal chat completions compat request: %w", err)
}
return s.forwardClaudeBodyAsChatCompletions(ctx, c, account, claudeBody, originalModel, clientStream, includeUsage, startTime, body)
return s.forwardClaudeBodyAsChatCompletions(ctx, c, account, claudeBody, originalModel, clientStream, includeUsage, startTime)
}
func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions(
@@ -70,7 +70,6 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions(
clientStream bool,
includeUsage bool,
startTime time.Time,
originalChatBody []byte,
) (*ForwardResult, error) {
var req struct {
Model string `json:"model"`
@@ -208,10 +207,7 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions(
c.Header("x-request-id", requestID)
}
reasoningEffort := extractCCReasoningEffortFromBody(originalChatBody, mappedModel)
// 国产模型默认 effort 补充(本路径上游是 Gemini,不会命中 passback-required)。
// 保持与 OpenAI 网关路径调用模式一致,便于未来上游变异时语义一致。
reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, originalChatBody, mappedModel)
reasoningEffort := extractGeminiReasoningEffortFromBody(geminiReq)
if resp.StatusCode >= 400 {
respBody := s.readUpstreamErrorBody(resp)
@@ -1668,6 +1668,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
Stream: stream,
ReasoningEffort: extractGeminiReasoningEffortFromBody(body),
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ImageCount: imageCount,
@@ -0,0 +1,190 @@
//go:build unit
package service
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestGeminiNativeReasoningPricingUsesExplicitForwardedLevel(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, provider := range []string{"api_key", "oauth_ai_studio", "oauth_code_assist", "antigravity"} {
for _, stream := range []bool{false, true} {
for _, tc := range []struct {
name string
thinking map[string]any
wantEffort string
multiplier float64
}{
{"high", map[string]any{"thinkingLevel": "high"}, "high", 2},
{"uppercase_high", map[string]any{"thinkingLevel": "HIGH"}, "high", 2},
{"snake_case_high", map[string]any{"thinking_level": "high"}, "high", 2},
{"minimal", map[string]any{"thinkingLevel": "minimal"}, "minimal", 1.1},
{"low", map[string]any{"thinkingLevel": "low"}, "low", 1.2},
{"medium", map[string]any{"thinkingLevel": "medium"}, "medium", 1.5},
{"missing_level", nil, "", 1},
{"budget_only", map[string]any{"thinkingBudget": 24576}, "", 1},
{"unspecified_level", map[string]any{"thinkingLevel": "THINKING_LEVEL_UNSPECIFIED"}, "", 1},
} {
mode := "buffered"
if stream {
mode = "streaming"
}
t.Run(provider+"/"+mode+"/"+tc.name, func(t *testing.T) {
const model = "gemini-3.1-pro-high"
request := map[string]any{
"contents": []any{map[string]any{"role": "user", "parts": []any{map[string]string{"text": "hello"}}}},
}
if tc.name == "snake_case_high" {
request["generation_config"] = map[string]any{"thinking_config": tc.thinking}
} else if tc.thinking != nil {
request["generationConfig"] = map[string]any{"thinkingConfig": tc.thinking}
}
body, err := json.Marshal(request)
require.NoError(t, err)
action := "generateContent"
if stream {
action = "streamGenerateContent"
}
path := "/v1beta/models/" + model + ":" + action
c, recorder := newAntigravityCompatContext(http.MethodPost, path, body)
responseBody := `{"candidates":[{"content":{"parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}`
wrapped := provider == "oauth_code_assist" || provider == "antigravity"
if wrapped {
responseBody = `{"response":` + responseBody + `}`
}
contentType := "application/json"
if stream || wrapped {
contentType = "text/event-stream"
responseBody = "data: " + responseBody + "\n\n"
}
upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{contentType}},
Body: io.NopCloser(strings.NewReader(responseBody)),
}}}
account := geminiSignalTestAccount()
var result *ForwardResult
if provider == "antigravity" {
account = newAntigravityCompatAccount(AccountTypeOAuth)
svc := newAntigravityCompatService(config.GatewayConfig{}, upstream)
result, err = svc.ForwardGemini(context.Background(), c, account, model, action, stream, body, false)
} else {
if provider != "api_key" {
account.Type = AccountTypeOAuth
account.Credentials["access_token"] = "test-token"
}
if provider == "oauth_code_assist" {
account.Credentials["project_id"] = "test-project"
}
svc := &GeminiMessagesCompatService{
httpUpstream: upstream, cfg: &config.Config{}, tokenProvider: &GeminiTokenProvider{},
}
result, err = svc.ForwardNative(context.Background(), c, account, model, action, stream, body)
}
require.NoError(t, err)
require.Equal(t, http.StatusOK, recorder.Code)
require.Len(t, upstream.requestBodies, 1)
forwardedPath := "generationConfig.thinkingConfig.thinkingLevel"
requestedLevel, _ := tc.thinking["thinkingLevel"].(string)
if tc.name == "snake_case_high" {
forwardedPath = "generation_config.thinking_config.thinking_level"
requestedLevel, _ = tc.thinking["thinking_level"].(string)
}
if wrapped {
forwardedPath = "request." + forwardedPath
}
require.Equal(t, requestedLevel, gjson.GetBytes(upstream.requestBodies[0], forwardedPath).String())
require.Equal(t, tc.wantEffort, optionalStringValue(result.ReasoningEffort))
if tc.wantEffort == "" {
require.Nil(t, result.ReasoningEffort)
}
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
userRepo := &openAIRecordUsageUserRepoStub{}
billing := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{})
billing.resolver = NewModelPricingResolver(nil, billing.billingService)
groupID := int64(77)
apiKey := &APIKey{ID: 10, GroupID: &groupID, Group: &Group{
ID: groupID, Platform: account.Platform, Status: StatusActive, Hydrated: true, RateMultiplier: 0.5,
ModelPricing: []ChannelModelPricing{{
Models: []string{model}, BillingMode: BillingModeToken,
InputPrice: testPtrFloat64(0.05), OutputPrice: testPtrFloat64(0.1),
ReasoningEffortMultipliers: map[string]float64{"minimal": 1.1, "low": 1.2, "medium": 1.5, "high": 2},
}},
}}
require.NoError(t, billing.RecordUsage(context.Background(), &RecordUsageInput{
Result: result, APIKey: apiKey, User: &User{ID: 20}, Account: account,
}))
require.NotNil(t, usageRepo.lastLog)
require.Equal(t, tc.wantEffort, optionalStringValue(usageRepo.lastLog.ReasoningEffort))
require.InDelta(t, tc.multiplier, usageRepo.lastLog.TotalCost, 1e-12)
require.InDelta(t, tc.multiplier*0.5, usageRepo.lastLog.ActualCost, 1e-12)
require.InDelta(t, tc.multiplier*0.5, userRepo.lastAmount, 1e-12)
})
}
}
}
}
func TestGeminiChatCompatReasoningPricingIgnoresUnforwardedEffort(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, stream := range []bool{false, true} {
mode := "buffered"
if stream {
mode = "streaming"
}
t.Run(mode, func(t *testing.T) {
const model = "gemini-3.1-pro"
body, err := json.Marshal(map[string]any{
"model": model, "stream": stream, "reasoning_effort": "high",
"messages": []map[string]string{{"role": "user", "content": "hello"}},
})
require.NoError(t, err)
responseBody := `{"candidates":[{"content":{"parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}`
contentType := "application/json"
if stream {
contentType = "text/event-stream"
responseBody = "data: " + responseBody + "\n\n"
}
upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{contentType}},
Body: io.NopCloser(strings.NewReader(responseBody)),
}}}
svc := &GeminiMessagesCompatService{httpUpstream: upstream, cfg: &config.Config{}}
c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/chat/completions", body)
result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiSignalTestAccount(), body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, recorder.Code)
require.Len(t, upstream.requestBodies, 1)
require.False(t, gjson.GetBytes(upstream.requestBodies[0], "generationConfig.thinkingConfig").Exists())
require.Nil(t, result.ReasoningEffort)
billing := NewBillingService(&config.Config{}, nil)
cost, err := billing.CalculateTokenCostForRequest(TokenCostRequest{
Ctx: context.Background(), Model: model,
Group: &Group{ID: 77, Platform: PlatformGemini, ModelPricing: []ChannelModelPricing{{
Models: []string{model}, BillingMode: BillingModeToken,
InputPrice: testPtrFloat64(0.05), OutputPrice: testPtrFloat64(0.1),
ReasoningEffortMultipliers: map[string]float64{"high": 2},
}}},
Tokens: UsageTokens{InputTokens: result.Usage.InputTokens, OutputTokens: result.Usage.OutputTokens},
ReasoningEffort: optionalStringValue(result.ReasoningEffort), RateMultiplier: 1,
Resolver: NewModelPricingResolver(nil, billing),
})
require.NoError(t, err)
require.InDelta(t, 1, cost.TotalCost, 1e-12)
})
}
}
@@ -0,0 +1,32 @@
package service
import (
"strings"
"github.com/tidwall/gjson"
)
// extractGeminiReasoningEffortFromBody reads only an explicit level from the
// forwarded Gemini request. A budget or an omitted level does not define a
// billing level, even when the upstream model enables thinking by default.
func extractGeminiReasoningEffortFromBody(body []byte) *string {
config := gjson.GetBytes(body, "generationConfig")
if !config.Exists() {
config = gjson.GetBytes(body, "generation_config")
}
thinking := config.Get("thinkingConfig")
if !thinking.Exists() {
thinking = config.Get("thinking_config")
}
level := thinking.Get("thinkingLevel")
if !level.Exists() {
level = thinking.Get("thinking_level")
}
effort := strings.ToLower(strings.TrimSpace(level.String()))
switch effort {
case "minimal", "low", "medium", "high":
return &effort
default:
return nil
}
}
@@ -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
@@ -23,6 +23,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"go.uber.org/zap"
)
@@ -111,7 +112,7 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic(
}
upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream)
upstreamReq, _, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL, body)
upstreamReq, forwardedBody, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL, body)
releaseUpstreamCtx()
if err != nil {
return nil, fmt.Errorf("build upstream request: %w", err)
@@ -132,8 +133,9 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic(
return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg)
}
reasoningEffort := extractCCReasoningEffortFromBody(body, upstreamModel, billingModel, originalModel)
reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel)
// Bill the Anthropic effort actually sent upstream (for example xhigh → max).
reasoningEffort := NormalizeClaudeOutputEffort(gjson.GetBytes(forwardedBody, "output_config.effort").String())
reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, forwardedBody, upstreamModel)
if clientStream {
return s.handleCCStreamingFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime, includeUsage)
@@ -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) {
@@ -0,0 +1,133 @@
//go:build unit
package service
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestOpenAINativeAnthropicReasoningPricingUsesForwardedEffort(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, endpoint := range []string{"responses", "chat/completions"} {
for _, stream := range []bool{false, true} {
for _, tc := range []struct {
effort string
wantEffort string
wantCost float64
}{
{"xhigh", "max", 3},
{"high", "high", 2},
{"", "", 1},
} {
t.Run(fmt.Sprintf("%s/stream=%t/effort=%s", endpoint, stream, tc.effort), func(t *testing.T) {
request := map[string]any{"model": "k3", "stream": stream}
if endpoint == "responses" {
request["input"] = "hello"
if tc.effort != "" {
request["reasoning"] = map[string]string{"effort": tc.effort}
}
} else {
request["messages"] = []map[string]string{{"role": "user", "content": "hello"}}
if tc.effort != "" {
request["reasoning_effort"] = tc.effort
}
}
body, err := json.Marshal(request)
require.NoError(t, err)
resp := nativeAnthropicStreamResponse()
responseBody, err := io.ReadAll(resp.Body)
require.NoError(t, err)
resp.Body = io.NopCloser(strings.NewReader(strings.ReplaceAll(string(responseBody), `"input_tokens":93`, `"input_tokens":1000000`)))
upstream := &httpUpstreamRecorder{resp: resp}
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
c := adaptiveProtocolTestContext("/v1/"+endpoint, body)
account := nativeAnthropicTestAccount()
account.Credentials["base_url"] = "http://anthropic.example"
var result *OpenAIForwardResult
if endpoint == "responses" {
result, err = svc.Forward(context.Background(), c, account, body)
} else {
result, err = svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
}
require.NoError(t, err)
require.Equal(t, "/v1/messages", upstream.lastReq.URL.Path)
require.Equal(t, tc.wantEffort, gjson.GetBytes(upstream.lastBody, "output_config.effort").String())
require.Equal(t, tc.wantEffort, optionalStringValue(result.ReasoningEffort))
assertConvertedReasoningPrice(t, result, tc.wantCost)
})
}
}
}
}
func TestOpenAIMessagesGLMReasoningPricingUsesForwardedEffort(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, stream := range []bool{false, true} {
for _, tc := range []struct {
effort string
maxPolicy string
wantEffort string
wantCost float64
}{
{"medium", "", "high", 2},
{"xhigh", "", "max", 3},
{"xhigh", "medium", "medium", 1.5},
} {
t.Run(fmt.Sprintf("stream=%t/effort=%s/policy=%s", stream, tc.effort, tc.maxPolicy), func(t *testing.T) {
body := []byte(fmt.Sprintf(`{"model":"glm-5.2","stream":%t,"max_tokens":32,"output_config":{"effort":%q},"messages":[{"role":"user","content":"hello"}]}`, stream, tc.effort))
responseBody := `{"id":"chatcmpl_glm","object":"chat.completion","model":"glm-5.2","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1000000,"completion_tokens":2,"total_tokens":1000002}}`
contentType := "application/json"
if stream {
contentType = "text/event-stream"
responseBody = "data: " + `{"id":"chatcmpl_glm","object":"chat.completion.chunk","model":"glm-5.2","choices":[{"index":0,"delta":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1000000,"completion_tokens":2,"total_tokens":1000002}}` + "\n\ndata: [DONE]\n\n"
}
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{contentType}},
Body: io.NopCloser(strings.NewReader(responseBody)),
}}
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
ctx := context.Background()
if tc.maxPolicy != "" {
ctx = WithOpenAIReasoningEffortPolicy(ctx, tc.maxPolicy, nil, "")
}
result, err := svc.ForwardAsAnthropic(ctx, adaptiveProtocolTestContext("/v1/messages", body), forceChatMessagesFallbackAccount(), body, "", "")
require.NoError(t, err)
require.Equal(t, "/v1/chat/completions", upstream.lastReq.URL.Path)
require.Equal(t, tc.wantEffort, gjson.GetBytes(upstream.lastBody, "reasoning_effort").String())
require.Equal(t, tc.wantEffort, optionalStringValue(result.ReasoningEffort))
assertConvertedReasoningPrice(t, result, tc.wantCost)
})
}
}
}
func assertConvertedReasoningPrice(t *testing.T, result *OpenAIForwardResult, wantCost float64) {
t.Helper()
require.Equal(t, 1_000_000, result.Usage.InputTokens)
billing := NewBillingService(rawChatCompletionsTestConfig(), nil)
group := &Group{ID: 1, Platform: PlatformOpenAI, ModelPricing: []ChannelModelPricing{{
Models: []string{result.BillingModel}, BillingMode: BillingModeToken,
InputPrice: testPtrFloat64(1e-6), OutputPrice: testPtrFloat64(0),
ReasoningEffortMultipliers: map[string]float64{"medium": 1.5, "high": 2, "xhigh": 2, "max": 3},
}}}
cost, err := billing.CalculateTokenCostForRequest(TokenCostRequest{
Ctx: context.Background(), Model: result.BillingModel, Group: group,
Tokens: UsageTokens{InputTokens: result.Usage.InputTokens, OutputTokens: result.Usage.OutputTokens},
ReasoningEffort: optionalStringValue(result.ReasoningEffort), RateMultiplier: 1,
Resolver: NewModelPricingResolver(nil, billing),
})
require.NoError(t, err)
require.InDelta(t, wantCost, cost.TotalCost, 1e-12)
require.InDelta(t, wantCost, cost.ActualCost, 1e-12)
}
@@ -12,7 +12,6 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"go.uber.org/zap"
)
@@ -67,9 +66,6 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions(
chatReq.StreamOptions = &apicompat.ChatStreamOptions{IncludeUsage: true}
}
convertedEffort := chatReq.ReasoningEffort
reasoningEffort := &convertedEffort
reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel)
serviceTier := extractOpenAIServiceTierFromBody(body)
chatBody, err := json.Marshal(chatReq)
@@ -90,11 +86,12 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions(
}
if changed {
chatBody = policyBody
if effectiveEffort := strings.TrimSpace(gjson.GetBytes(chatBody, "reasoning_effort").String()); effectiveEffort != "" {
reasoningEffort = &effectiveEffort
}
}
}
// Provider normalization and policy caps can both change the converted effort.
// Use the final outbound value for usage logs and billing.
reasoningEffort := extractOpenAIReasoningEffortFromBody(chatBody, upstreamModel, billingModel, originalModel)
reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, chatBody, upstreamModel)
// Unlike forwardResponsesViaRawChatCompletions, applyOpenAIFastPolicyToBody
// is intentionally skipped: Anthropic Messages bodies carry no service_tier,
// so the converted Chat Completions body never contains one and the policy
@@ -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"
@@ -75,9 +75,6 @@ func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic(
upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel)
anthropicReq.Model = upstreamModel
reasoningEffort := ExtractResponsesReasoningEffortFromBody(body, upstreamModel, billingModel, originalModel)
reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel)
// 5. Force upstream streaming(客户端原始终决定响应格式;
// 上游恒为流式,非流式由缓冲路径组装)。
anthropicReq.Stream = true
@@ -116,11 +113,14 @@ func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic(
}
upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream)
upstreamReq, _, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL, body)
upstreamReq, forwardedBody, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL, body)
releaseUpstreamCtx()
if err != nil {
return nil, fmt.Errorf("build upstream request: %w", err)
}
// Bill the Anthropic effort actually sent upstream (for example xhigh → max).
reasoningEffort := NormalizeClaudeOutputEffort(gjson.GetBytes(forwardedBody, "output_config.effort").String())
reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, forwardedBody, upstreamModel)
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
if err != nil {
@@ -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: "缺失字段时从模型后缀推导",
@@ -590,6 +590,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost(
Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group,
UsageUnits: result.AudioUsage.DurationOrUnits, SizeTier: result.AudioUsage.Mode,
RateMultiplier: webSearchMultiplier, Resolver: s.resolver, Resolved: resolved,
ReasoningEffort: optionalStringValue(result.ReasoningEffort),
})
}
cfg := groupAudioPriceConfigFromAPIKey(apiKey)
@@ -726,17 +727,13 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost(
LongContextBillingEnabled: longContextBillingGate,
})
}
breakdown, err := s.billingService.calculateCostWithServiceTierPolicy(
return s.billingService.calculateCostWithServiceTierPolicy(
billingModel,
tokens,
multiplier,
serviceTier,
longContextBillingGate == nil || *longContextBillingGate,
)
if err == nil {
applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(billingModel, reasoningEffort, nil))
}
return breakdown, err
}
func (s *OpenAIGatewayService) calculateOpenAIImageCost(
@@ -755,6 +752,7 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost(
Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group,
RequestCount: result.ImageCount, SizeTier: sizeTier,
RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved,
ReasoningEffort: optionalStringValue(result.ReasoningEffort),
})
if err == nil {
return cost
@@ -775,15 +773,16 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost(
(resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) {
gid := apiKey.Group.ID
cost, err := s.billingService.CalculateCostUnified(CostInput{
Ctx: ctx,
Model: billingModel,
GroupID: &gid,
Group: apiKey.Group,
RequestCount: result.ImageCount,
SizeTier: sizeTier,
RateMultiplier: multiplier,
Resolver: s.resolver,
Resolved: resolved,
Ctx: ctx,
Model: billingModel,
GroupID: &gid,
Group: apiKey.Group,
RequestCount: result.ImageCount,
SizeTier: sizeTier,
ReasoningEffort: optionalStringValue(result.ReasoningEffort),
RateMultiplier: multiplier,
Resolver: s.resolver,
Resolved: resolved,
})
if err == nil {
return cost
@@ -814,6 +813,7 @@ func (s *OpenAIGatewayService) calculateOpenAIVideoCost(
Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group,
UsageUnits: float64(videoCount * durationSeconds), SizeTier: resolution,
RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved,
ReasoningEffort: optionalStringValue(result.ReasoningEffort),
})
if err == nil {
return cost
@@ -839,16 +839,17 @@ func (s *OpenAIGatewayService) calculateOpenAIVideoCost(
units = float64(videoCount * durationSeconds)
}
cost, err := s.billingService.CalculateCostUnified(CostInput{
Ctx: ctx,
Model: billingModel,
GroupID: &gid,
Group: apiKey.Group,
RequestCount: videoCount,
UsageUnits: units,
SizeTier: resolution,
RateMultiplier: multiplier,
Resolver: s.resolver,
Resolved: resolved,
Ctx: ctx,
Model: billingModel,
GroupID: &gid,
Group: apiKey.Group,
RequestCount: videoCount,
UsageUnits: units,
SizeTier: resolution,
ReasoningEffort: optionalStringValue(result.ReasoningEffort),
RateMultiplier: multiplier,
Resolver: s.resolver,
Resolved: resolved,
})
if err == nil {
cost.BillingMode = string(BillingModeVideo)
@@ -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")
}