mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
Merge pull request #7412 from StarryKira/codex/custom-reasoning-effort-billing
feat: 支持自定义思考等级倍率计费
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+78
-5
@@ -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([{
|
||||
|
||||
@@ -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 时价格列为折后实付价,官方价列保持原价', () => {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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: '缓存写倍率',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')}`)
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user