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")
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

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