mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 20:12:39 +08:00
Merge pull request #6514 from StarryKira/codex/anthropic-reasoning-pricing
feat: add Anthropic reasoning effort and Fable 5.1 max pricing
This commit is contained in:
@@ -57,21 +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"`
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type channelTimePricingRequest struct {
|
||||
@@ -129,22 +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"`
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type channelTimePricingResponse struct {
|
||||
@@ -258,22 +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,
|
||||
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,
|
||||
MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingToResponse(p.TimePricing),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,27 +353,29 @@ func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMult
|
||||
SortOrder: iv.SortOrder,
|
||||
})
|
||||
}
|
||||
var fastMultiplier, flexMultiplier *float64
|
||||
var fastMultiplier, flexMultiplier, maxReasoningEffortMultiplier *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,
|
||||
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,
|
||||
MaxReasoningEffortMultiplier: maxReasoningEffortMultiplier,
|
||||
ImageInputPrice: r.ImageInputPrice,
|
||||
ImageOutputPrice: r.ImageOutputPrice,
|
||||
PerRequestPrice: r.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingRequestToService(r.TimePricing),
|
||||
})
|
||||
}
|
||||
return result
|
||||
@@ -615,14 +620,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,
|
||||
"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,
|
||||
"max_reasoning_effort_multiplier": pricing.MaxReasoningEffortMultiplier,
|
||||
"image_input_price": pricing.ImageInputPricePerToken,
|
||||
"image_output_price": pricing.ImageOutputPricePerToken,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -453,10 +453,11 @@ func TestPricingRequestToService_TimePricingNil(t *testing.T) {
|
||||
// 避免渠道倍率意外污染账号成本口径。
|
||||
func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
|
||||
req := channelModelPricingRequest{
|
||||
Models: []string{"gpt-5"},
|
||||
BillingMode: "token",
|
||||
FastMultiplier: float64Ptr(2.5),
|
||||
FlexMultiplier: float64Ptr(0.5),
|
||||
Models: []string{"gpt-5"},
|
||||
BillingMode: "token",
|
||||
FastMultiplier: float64Ptr(2.5),
|
||||
FlexMultiplier: float64Ptr(0.5),
|
||||
MaxReasoningEffortMultiplier: float64Ptr(3),
|
||||
Intervals: []pricingIntervalRequest{{
|
||||
MinTokens: 272000,
|
||||
InputMultiplier: float64Ptr(2),
|
||||
@@ -469,6 +470,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, 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)
|
||||
@@ -477,6 +479,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.Nil(t, dropped[0].Intervals[0].InputMultiplier)
|
||||
require.Nil(t, dropped[0].Intervals[0].OutputMultiplier)
|
||||
require.Nil(t, dropped[0].Intervals[0].CacheWriteMultiplier)
|
||||
@@ -580,9 +583,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"`
|
||||
Found bool `json:"found"`
|
||||
CacheWritePrice float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
@@ -590,6 +594,8 @@ 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)
|
||||
}
|
||||
|
||||
func TestGetModelDefaultPricing_OmitsUnsupportedCache1hPrice(t *testing.T) {
|
||||
|
||||
@@ -158,11 +158,11 @@ type CreateGroupRequest struct {
|
||||
CodexModelsManifestConfig service.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
|
||||
// 分组 RPM 上限(0 = 不限制)
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
// OpenAI/Codex 请求推理强度上限,空字符串表示不限制。
|
||||
// Anthropic/OpenAI 请求推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort"`
|
||||
// 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
|
||||
// OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。
|
||||
// Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。
|
||||
ReasoningEffortMappings []service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
// 从指定分组复制账号(创建后自动绑定)
|
||||
CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"`
|
||||
@@ -233,7 +233,7 @@ type UpdateGroupRequest struct {
|
||||
CodexModelsManifestConfig *service.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
|
||||
// 分组 RPM 上限(0 = 不限制);nil 表示未提供不改动
|
||||
RPMLimit *int `json:"rpm_limit"`
|
||||
// OpenAI/Codex 请求推理强度上限;空字符串清除,nil 不修改。
|
||||
// Anthropic/OpenAI 请求推理强度上限;空字符串清除,nil 不修改。
|
||||
MaxReasoningEffort *string `json:"max_reasoning_effort"`
|
||||
// 超过上限时的访问控制;空字符串视为 downgrade,nil 不修改。
|
||||
MaxReasoningEffortOverLimit *string `json:"max_reasoning_effort_over_limit"`
|
||||
|
||||
@@ -66,16 +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"`
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// userPricingIntervalDTO 定价区间白名单(去掉内部 ID、SortOrder 等前端不渲染的字段)。
|
||||
@@ -323,15 +324,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,
|
||||
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,
|
||||
MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,13 +64,45 @@ func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKe
|
||||
if apiKey == nil || apiKey.Group == nil {
|
||||
return "", nil, "", false
|
||||
}
|
||||
if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
|
||||
if apiKey.Group.Platform != service.PlatformAnthropic && apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
|
||||
return "", nil, "", false
|
||||
}
|
||||
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformOpenAI {
|
||||
effectivePlatform := effectiveAPIKeyPlatform(c, apiKey)
|
||||
if effectivePlatform != service.PlatformAnthropic && effectivePlatform != service.PlatformOpenAI {
|
||||
return "", nil, "", false
|
||||
}
|
||||
return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, apiKey.Group.MaxReasoningEffortOverLimit, true
|
||||
maxEffort, mappings := apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings
|
||||
if effectivePlatform == service.PlatformAnthropic {
|
||||
maxEffort, mappings = anthropicCompatibleReasoningEffortPolicy(maxEffort, mappings)
|
||||
}
|
||||
return maxEffort, mappings, apiKey.Group.MaxReasoningEffortOverLimit, true
|
||||
}
|
||||
|
||||
func anthropicReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, string, bool) {
|
||||
if apiKey == nil || apiKey.Group == nil {
|
||||
return "", nil, "", false
|
||||
}
|
||||
if apiKey.Group.Platform != service.PlatformAnthropic && apiKey.Group.Platform != service.PlatformComposite {
|
||||
return "", nil, "", false
|
||||
}
|
||||
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformAnthropic {
|
||||
return "", nil, "", false
|
||||
}
|
||||
maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy(apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings)
|
||||
return maxEffort, mappings, apiKey.Group.MaxReasoningEffortOverLimit, true
|
||||
}
|
||||
|
||||
func anthropicCompatibleReasoningEffortPolicy(maxEffort string, mappings []service.ReasoningEffortMapping) (string, []service.ReasoningEffortMapping) {
|
||||
if service.NormalizeMaxReasoningEffort(maxEffort) == "minimal" {
|
||||
maxEffort = "low"
|
||||
}
|
||||
normalizedMappings := append([]service.ReasoningEffortMapping(nil), mappings...)
|
||||
for i := range normalizedMappings {
|
||||
if service.NormalizeMaxReasoningEffort(normalizedMappings[i].To) == "minimal" {
|
||||
normalizedMappings[i].To = "low"
|
||||
}
|
||||
}
|
||||
return maxEffort, normalizedMappings
|
||||
}
|
||||
|
||||
func bindRequestedReasoningEffort(c *gin.Context, body []byte, model string) {
|
||||
@@ -118,6 +150,14 @@ func respondOpenAIReasoningEffortPolicyError(c *gin.Context, err error, write fu
|
||||
write(c, http.StatusForbidden, "permission_error", err.Error())
|
||||
}
|
||||
|
||||
func applyAnthropicReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool, error) {
|
||||
maxEffort, mappings, overLimit, ok := anthropicReasoningEffortPolicyForRequest(c, apiKey)
|
||||
if !ok {
|
||||
return body, false, nil
|
||||
}
|
||||
return service.ApplyReasoningEffortPolicy(body, maxEffort, mappings, overLimit)
|
||||
}
|
||||
|
||||
func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *service.APIKey, body []byte) {
|
||||
if c == nil || c.Request == nil {
|
||||
return
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestCompositeTargetPlatformAllowedResolvesKnownAllowedModel(t *testing.T) {
|
||||
@@ -160,6 +161,75 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
|
||||
func TestOpenAIShapedReasoningEffortPolicyForAnthropicGroup(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
apiKey := &service.APIKey{Group: &service.Group{
|
||||
Platform: service.PlatformAnthropic,
|
||||
MaxReasoningEffort: "xhigh",
|
||||
}}
|
||||
|
||||
got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, []byte(`{"reasoning":{"effort":"max"}}`))
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "xhigh", gjson.GetBytes(got, "reasoning.effort").String())
|
||||
}
|
||||
|
||||
func TestAnthropicReasoningEffortPolicyForConcreteAndCompositeTargets(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"claude-fable-5-1","output_config":{"effort":"max"}}`)
|
||||
policy := func(platform string) *service.APIKey {
|
||||
return &service.APIKey{Group: &service.Group{
|
||||
Platform: platform,
|
||||
MaxReasoningEffort: "xhigh",
|
||||
ReasoningEffortMappings: []service.ReasoningEffortMapping{
|
||||
{From: "max", To: "xhigh"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
concrete, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
concrete.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
got, changed, err := applyAnthropicReasoningEffortPolicyForRequest(concrete, policy(service.PlatformAnthropic), body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{"model":"claude-fable-5-1","output_config":{"effort":"xhigh"}}`, string(got))
|
||||
|
||||
denyMax := policy(service.PlatformAnthropic)
|
||||
denyMax.Group.ReasoningEffortMappings = nil
|
||||
denyMax.Group.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny
|
||||
_, changed, err = applyAnthropicReasoningEffortPolicyForRequest(concrete, denyMax, body)
|
||||
require.Error(t, err)
|
||||
require.False(t, changed)
|
||||
var overLimit *service.ReasoningEffortOverLimitError
|
||||
require.ErrorAs(t, err, &overLimit)
|
||||
require.Equal(t, "max", overLimit.Requested)
|
||||
require.Equal(t, "xhigh", overLimit.Max)
|
||||
|
||||
composite, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
composite.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
composite.Request = composite.Request.WithContext(service.WithResolvedTargetPlatform(composite.Request.Context(), service.PlatformAnthropic))
|
||||
got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(composite, policy(service.PlatformComposite), body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "xhigh", gjson.GetBytes(got, "output_config.effort").String())
|
||||
|
||||
openAI, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
openAI.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
openAI.Request = openAI.Request.WithContext(service.WithResolvedTargetPlatform(openAI.Request.Context(), service.PlatformOpenAI))
|
||||
got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(openAI, policy(service.PlatformComposite), body)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
|
||||
func TestAnthropicCompositePolicyClampsUnsupportedMinimalToLow(t *testing.T) {
|
||||
maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy("minimal", []service.ReasoningEffortMapping{{From: "max", To: "minimal"}})
|
||||
require.Equal(t, "low", maxEffort)
|
||||
require.Equal(t, []service.ReasoningEffortMapping{{From: "max", To: "low"}}, mappings)
|
||||
}
|
||||
|
||||
func TestClientRequestedModelUsesCompositePublicModel(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
|
||||
@@ -151,11 +151,11 @@ type Group struct {
|
||||
|
||||
// RPMLimit 分组级每分钟请求数上限(0 = 不限制),设置后覆盖用户级 rpm_limit。
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
// MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。
|
||||
// MaxReasoningEffort Anthropic/OpenAI 请求的推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort"`
|
||||
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
|
||||
// ReasoningEffortMappings OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。
|
||||
// ReasoningEffortMappings Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。
|
||||
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
@@ -171,6 +171,19 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
reqStream := parsedReq.Stream
|
||||
bindRequestedReasoningEffort(c, body, reqModel)
|
||||
ensureCompositeTargetPlatform(c, apiKey, reqModel)
|
||||
if policyBody, changed, err := applyAnthropicReasoningEffortPolicyForRequest(c, apiKey, body); err != nil {
|
||||
respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse)
|
||||
return
|
||||
} else if changed {
|
||||
if err := parsedReq.ReplaceBody(policyBody); err != nil {
|
||||
reqLog.Warn("gateway.reasoning_effort_policy_parse_failed", zap.Error(err))
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to apply reasoning effort policy")
|
||||
return
|
||||
}
|
||||
body = parsedReq.Body.Bytes()
|
||||
reqModel = parsedReq.Model
|
||||
reqStream = parsedReq.Stream
|
||||
}
|
||||
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
|
||||
|
||||
// 解析渠道级模型映射
|
||||
|
||||
@@ -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, 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, max_reasoning_effort_multiplier, 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 {
|
||||
@@ -61,10 +61,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, image_input_price = $10, image_output_price = $11, per_request_price = $12, time_pricing = $13, platform = $14, updated_at = NOW()
|
||||
WHERE id = $15`,
|
||||
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()
|
||||
WHERE id = $16`,
|
||||
modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice,
|
||||
pricing.FastMultiplier, pricing.FlexMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice,
|
||||
pricing.FastMultiplier, pricing.FlexMultiplier, pricing.MaxReasoningEffortMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice,
|
||||
timePricingJSON, pricing.Platform, pricing.ID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -96,7 +96,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, 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, max_reasoning_effort_multiplier, 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),
|
||||
)
|
||||
@@ -180,7 +180,7 @@ func scanModelPricingRows(rows *sql.Rows) ([]service.ChannelModelPricing, []int6
|
||||
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.FastMultiplier, &p.FlexMultiplier, &p.MaxReasoningEffortMultiplier,
|
||||
&p.ImageInputPrice, &p.ImageOutputPrice, &p.PerRequestPrice, &timePricingJSON, &p.CreatedAt, &p.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("scan model pricing: %w", err)
|
||||
@@ -247,11 +247,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, 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) RETURNING id, created_at, updated_at`,
|
||||
`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)
|
||||
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.ImageInputPrice, pricing.ImageOutputPrice,
|
||||
pricing.FastMultiplier, pricing.FlexMultiplier, pricing.MaxReasoningEffortMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice,
|
||||
pricing.PerRequestPrice, timePricingJSON,
|
||||
).Scan(&pricing.ID, &pricing.CreatedAt, &pricing.UpdatedAt)
|
||||
if err != nil {
|
||||
|
||||
@@ -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", "image_input_price", "image_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",
|
||||
}
|
||||
|
||||
@@ -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, timePricing,
|
||||
nil, 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, 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, max_reasoning_effort_multiplier, 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, channelModelPricingTimePricingJSON,
|
||||
nil, 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{}))
|
||||
|
||||
@@ -118,10 +118,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateRoundTrip(t *testing.T) {
|
||||
|
||||
t.Run("update writes JSON and entry ID", func(t *testing.T) {
|
||||
repo, mock := newChannelModelPricingTimePricingRepo(t)
|
||||
mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$12, time_pricing = \$13, platform = \$14.*WHERE id = \$15`).
|
||||
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, channelModelPricingTimePricingJSON, "openai", int64(11),
|
||||
nil, 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, 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, max_reasoning_effort_multiplier, 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{}))
|
||||
|
||||
@@ -166,10 +166,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateWriteNullWhenDisabled(t *t
|
||||
|
||||
t.Run("update writes SQL NULL", func(t *testing.T) {
|
||||
repo, mock := newChannelModelPricingTimePricingRepo(t)
|
||||
mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$12, time_pricing = \$13, platform = \$14.*WHERE id = \$15`).
|
||||
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, "openai", int64(11),
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11),
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
// upstreamModel 是最终发往上游的模型 ID。
|
||||
// totalCost 是本次请求的客户计费(倍率前),用于优先级 2。
|
||||
// serviceTier 是最终参与用户计费的 OpenAI 服务层级,用于优先级 3。
|
||||
// reasoningEffort 是最终转发等级;Fable 5.1 max 默认按 3 倍额度消耗。
|
||||
func resolveAccountStatsCost(
|
||||
ctx context.Context,
|
||||
channelService *ChannelService,
|
||||
@@ -28,7 +29,12 @@ func resolveAccountStatsCost(
|
||||
requestCount int,
|
||||
totalCost float64,
|
||||
serviceTier string,
|
||||
reasoningEfforts ...string,
|
||||
) *float64 {
|
||||
reasoningEffort := ""
|
||||
if len(reasoningEfforts) > 0 {
|
||||
reasoningEffort = reasoningEfforts[0]
|
||||
}
|
||||
if channelService == nil || upstreamModel == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -40,7 +46,7 @@ func resolveAccountStatsCost(
|
||||
platform := channelService.GetGroupPlatform(ctx, groupID)
|
||||
|
||||
// 优先级 1:自定义规则(始终尝试)
|
||||
if cost := tryCustomRules(channel, accountID, groupID, platform, upstreamModel, tokens, requestCount); cost != nil {
|
||||
if cost := tryCustomRules(channel, accountID, groupID, platform, upstreamModel, tokens, requestCount, reasoningEffort); cost != nil {
|
||||
return cost
|
||||
}
|
||||
|
||||
@@ -55,7 +61,7 @@ func resolveAccountStatsCost(
|
||||
|
||||
// 优先级 3:模型定价文件(LiteLLM)默认价格
|
||||
if billingService != nil {
|
||||
return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier)
|
||||
return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, reasoningEffort)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -65,13 +71,18 @@ func resolveAccountStatsCost(
|
||||
// 与用户计费共用同一条定价管线,避免这里维护第二份"单价 × token 数"实现后,
|
||||
// 每加一个定价特性都要手工镜像一次。channelPricing 为 nil,保持优先级 3 的
|
||||
// 语义:只取模型定价文件,不引入渠道自定义定价。
|
||||
func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string) *float64 {
|
||||
func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string, reasoningEfforts ...string) *float64 {
|
||||
reasoningEffort := ""
|
||||
if len(reasoningEfforts) > 0 {
|
||||
reasoningEffort = reasoningEfforts[0]
|
||||
}
|
||||
breakdown, err := billingService.CalculateCostWithServiceTier(
|
||||
model, tokens, 1, normalizeBillingServiceTier(serviceTier),
|
||||
)
|
||||
if err != nil || breakdown == nil || breakdown.TotalCost <= 0 {
|
||||
return nil
|
||||
}
|
||||
applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(model, reasoningEffort, nil))
|
||||
return &breakdown.TotalCost
|
||||
}
|
||||
|
||||
@@ -79,7 +90,12 @@ func tryModelFilePricing(billingService *BillingService, model string, tokens Us
|
||||
func tryCustomRules(
|
||||
channel *Channel, accountID, groupID int64,
|
||||
platform, model string, tokens UsageTokens, requestCount int,
|
||||
reasoningEfforts ...string,
|
||||
) *float64 {
|
||||
reasoningEffort := ""
|
||||
if len(reasoningEfforts) > 0 {
|
||||
reasoningEffort = reasoningEfforts[0]
|
||||
}
|
||||
modelLower := strings.ToLower(model)
|
||||
for _, rule := range channel.AccountStatsPricingRules {
|
||||
if !matchAccountStatsRule(&rule, accountID, groupID) {
|
||||
@@ -89,7 +105,11 @@ func tryCustomRules(
|
||||
if pricing == nil {
|
||||
continue // 规则匹配但模型不在规则定价中,继续下一条
|
||||
}
|
||||
return calculateStatsCost(pricing, tokens, requestCount)
|
||||
cost := calculateStatsCost(pricing, tokens, requestCount)
|
||||
if cost != nil {
|
||||
*cost *= maxReasoningEffortBillingMultiplier(model, reasoningEffort, nil)
|
||||
}
|
||||
return cost
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -243,10 +263,14 @@ func applyAccountStatsCost(
|
||||
requestCount = usageLog.ImageCount
|
||||
}
|
||||
serviceTier := ""
|
||||
reasoningEffort := ""
|
||||
if usageLog != nil && usageLog.ServiceTier != nil {
|
||||
serviceTier = *usageLog.ServiceTier
|
||||
}
|
||||
if usageLog != nil && usageLog.ReasoningEffort != nil {
|
||||
reasoningEffort = *usageLog.ReasoningEffort
|
||||
}
|
||||
usageLog.AccountStatsCost = resolveAccountStatsCost(
|
||||
ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier,
|
||||
ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, reasoningEffort,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -478,6 +478,18 @@ func TestTryModelFilePricing_Success(t *testing.T) {
|
||||
require.InDelta(t, 0.2, *result, 1e-12)
|
||||
}
|
||||
|
||||
func TestTryModelFilePricing_Fable51MaxEffortUsesTripleQuota(t *testing.T) {
|
||||
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
|
||||
"claude-fable-5-1": {InputPricePerToken: 0.001},
|
||||
})
|
||||
tokens := UsageTokens{InputTokens: 100}
|
||||
standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", "xhigh")
|
||||
max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", "max")
|
||||
require.NotNil(t, standard)
|
||||
require.NotNil(t, max)
|
||||
require.InDelta(t, *standard*3, *max, 1e-12)
|
||||
}
|
||||
|
||||
func TestTryModelFilePricing_AppliesLongContextPricing(t *testing.T) {
|
||||
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
|
||||
"gpt-5.6-sol": {
|
||||
|
||||
@@ -277,11 +277,11 @@ type CreateGroupInput struct {
|
||||
CodexModelsManifestConfig GroupCodexModelsManifestConfig
|
||||
// RPMLimit 分组 RPM 上限(0 = 不限制)
|
||||
RPMLimit int
|
||||
// MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。
|
||||
// MaxReasoningEffort Anthropic/OpenAI 请求的推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string
|
||||
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string
|
||||
// ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。
|
||||
// ReasoningEffortMappings Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。
|
||||
ReasoningEffortMappings []ReasoningEffortMapping
|
||||
// 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数,nil 按 0 处理)
|
||||
ProfitControlEnabled bool
|
||||
|
||||
@@ -960,7 +960,7 @@ func TestAdminService_UpdateGroup_ClearsReasoningPolicyForUnsupportedPlatform(t
|
||||
repo := &groupRepoStubForAdmin{getByID: existing}
|
||||
svc := &adminServiceImpl{groupRepo: repo}
|
||||
|
||||
_, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{Platform: PlatformAnthropic})
|
||||
_, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{Platform: PlatformGemini})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, repo.updated.MaxReasoningEffort)
|
||||
|
||||
@@ -115,7 +115,7 @@ type APIKeyAuthGroupSnapshot struct {
|
||||
// RPMLimit 分组级每分钟请求数上限(0 = 不限制);用于 billing_cache_service.checkRPM 级联判断。
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
|
||||
// MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。
|
||||
// MaxReasoningEffort Anthropic/OpenAI 请求的推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"`
|
||||
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit,omitempty"`
|
||||
|
||||
@@ -105,6 +105,7 @@ type ModelPricing struct {
|
||||
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 // 是否支持详细的缓存分类
|
||||
@@ -214,6 +215,42 @@ 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"} {
|
||||
if at := strings.Index(model, marker); at >= 0 {
|
||||
after := at + len(marker)
|
||||
if after == len(model) || model[after] < '0' || model[after] > '9' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func defaultMaxReasoningEffortMultiplier(model string) *float64 {
|
||||
if !isClaudeFable51Model(model) {
|
||||
return nil
|
||||
}
|
||||
multiplier := claudeFable51MaxReasoningEffortMultiplier
|
||||
return &multiplier
|
||||
}
|
||||
|
||||
func maxReasoningEffortBillingMultiplier(model, effort string, pricing *ModelPricing) float64 {
|
||||
if NormalizeMaxReasoningEffort(effort) != "max" {
|
||||
return 1
|
||||
}
|
||||
if pricing != nil && pricing.MaxReasoningEffortMultiplier != nil && *pricing.MaxReasoningEffortMultiplier > 0 {
|
||||
return *pricing.MaxReasoningEffortMultiplier
|
||||
}
|
||||
if multiplier := defaultMaxReasoningEffortMultiplier(model); multiplier != nil {
|
||||
return *multiplier
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func resolvedChannelTimeMultiplier(resolved *ResolvedPricing, at time.Time) float64 {
|
||||
if resolved == nil || resolved.Source != PricingSourceChannel || resolved.channelPricing == nil {
|
||||
return 1
|
||||
@@ -803,8 +840,7 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
|
||||
modelLower := strings.ToLower(model)
|
||||
|
||||
// 按模型系列匹配
|
||||
if strings.Contains(modelLower, "fable-5-1") || strings.Contains(modelLower, "fable-5.1") ||
|
||||
strings.Contains(modelLower, "fable5.1") || strings.Contains(modelLower, "fable51") {
|
||||
if isClaudeFable51Model(modelLower) {
|
||||
return s.fallbackPrices["claude-fable-5-1"]
|
||||
}
|
||||
if strings.Contains(modelLower, "fable-5") || strings.Contains(modelLower, "fable5") {
|
||||
@@ -1169,6 +1205,9 @@ 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
|
||||
}
|
||||
if channelPricing.ImageOutputPrice != nil {
|
||||
pricing.ImageOutputPricePerToken = *channelPricing.ImageOutputPrice
|
||||
} else {
|
||||
@@ -1241,6 +1280,7 @@ type CostInput struct {
|
||||
RateMultiplier float64
|
||||
PricingAt time.Time // 渠道分时定价使用的计费时刻
|
||||
ServiceTier string // "priority","flex","" 等
|
||||
ReasoningEffort string // 最终转发的推理等级;max 可触发模型/渠道倍率
|
||||
Resolver *ModelPricingResolver // 定价解析器
|
||||
Resolved *ResolvedPricing // 可选:预解析的定价结果(避免重复 Resolve 调用)
|
||||
LongContextBillingEnabled *bool
|
||||
@@ -1255,7 +1295,7 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown,
|
||||
if input.LongContextBillingEnabled != nil {
|
||||
applyLongContextBilling = *input.LongContextBillingEnabled
|
||||
}
|
||||
return s.calculateCostInternalWithPolicy(
|
||||
breakdown, err := s.calculateCostInternalWithPolicy(
|
||||
input.Model,
|
||||
input.Tokens,
|
||||
input.RateMultiplier,
|
||||
@@ -1263,6 +1303,10 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown,
|
||||
nil,
|
||||
applyLongContextBilling,
|
||||
)
|
||||
if err == nil {
|
||||
applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(input.Model, input.ReasoningEffort, nil))
|
||||
}
|
||||
return breakdown, err
|
||||
}
|
||||
|
||||
// 优先使用预解析结果,避免重复 Resolve 调用
|
||||
@@ -1346,6 +1390,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))
|
||||
return breakdown, nil
|
||||
}
|
||||
|
||||
@@ -1627,13 +1672,17 @@ 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 {
|
||||
if !needsCacheCreationPolicy && fastRatio <= 0 && !needsMaxReasoningEffortMultiplier {
|
||||
return pricing
|
||||
}
|
||||
cloned := *pricing
|
||||
if needsMaxReasoningEffortMultiplier {
|
||||
cloned.MaxReasoningEffortMultiplier = defaultMaxReasoningEffortMultiplier(model)
|
||||
}
|
||||
if isGPT56 && !cloned.CacheCreationPriceExplicit {
|
||||
if cloned.CacheCreationPricePerToken <= 0 {
|
||||
cloned.CacheCreationPricePerToken = cloned.InputPricePerToken * 1.25
|
||||
|
||||
@@ -1771,6 +1771,8 @@ 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)
|
||||
}
|
||||
|
||||
func TestGetModelPricingWithChannel_CacheReadPriceAffectsPriority(t *testing.T) {
|
||||
|
||||
@@ -7,14 +7,15 @@ import (
|
||||
|
||||
// TokenCostRequest 通用网关 token 计费请求。
|
||||
type TokenCostRequest struct {
|
||||
Ctx context.Context
|
||||
Model string
|
||||
Group *Group
|
||||
Tokens UsageTokens
|
||||
RateMultiplier float64
|
||||
PricingAt time.Time
|
||||
ServiceTier string
|
||||
Resolver *ModelPricingResolver
|
||||
Ctx context.Context
|
||||
Model string
|
||||
Group *Group
|
||||
Tokens UsageTokens
|
||||
RateMultiplier float64
|
||||
PricingAt time.Time
|
||||
ServiceTier string
|
||||
ReasoningEffort string
|
||||
Resolver *ModelPricingResolver
|
||||
// Resolved 为调用方预先解析的定价(Resolver.Resolve 的结果),nil 表示未解析。
|
||||
Resolved *ResolvedPricing
|
||||
}
|
||||
@@ -33,21 +34,25 @@ func (s *BillingService) CalculateTokenCostForRequest(req TokenCostRequest) (*Co
|
||||
if req.Resolver != nil && req.Group != nil {
|
||||
return s.CalculateCostUnified(s.tokenCostInput(req, resolved))
|
||||
}
|
||||
if req.ReasoningEffort != "" {
|
||||
return s.CalculateCostUnified(s.tokenCostInput(req, resolved))
|
||||
}
|
||||
return s.CalculateCost(req.Model, req.Tokens, req.RateMultiplier)
|
||||
}
|
||||
|
||||
func (s *BillingService) tokenCostInput(req TokenCostRequest, resolved *ResolvedPricing) CostInput {
|
||||
input := CostInput{
|
||||
Ctx: req.Ctx,
|
||||
Model: req.Model,
|
||||
Group: req.Group,
|
||||
Tokens: req.Tokens,
|
||||
RequestCount: 1,
|
||||
RateMultiplier: req.RateMultiplier,
|
||||
PricingAt: req.PricingAt,
|
||||
ServiceTier: req.ServiceTier,
|
||||
Resolver: req.Resolver,
|
||||
Resolved: resolved,
|
||||
Ctx: req.Ctx,
|
||||
Model: req.Model,
|
||||
Group: req.Group,
|
||||
Tokens: req.Tokens,
|
||||
RequestCount: 1,
|
||||
RateMultiplier: req.RateMultiplier,
|
||||
PricingAt: req.PricingAt,
|
||||
ServiceTier: req.ServiceTier,
|
||||
ReasoningEffort: req.ReasoningEffort,
|
||||
Resolver: req.Resolver,
|
||||
Resolved: resolved,
|
||||
}
|
||||
if req.Group != nil {
|
||||
gid := req.Group.ID
|
||||
|
||||
@@ -216,3 +216,41 @@ func TestCalculateTokenCostForRequest_NoResolverFallsBackToCatalog(t *testing.T)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestCalculateTokenCostForRequest_Fable51MaxEffortUsesDefaultMultiplier(t *testing.T) {
|
||||
bs := NewBillingService(&config.Config{}, nil)
|
||||
tokens := UsageTokens{InputTokens: 1000, OutputTokens: 10}
|
||||
standard, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||||
Model: "claude-fable-5-1", Tokens: tokens, RateMultiplier: 1, ReasoningEffort: "xhigh",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
max, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||||
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)
|
||||
}
|
||||
|
||||
func TestCalculateTokenCostForRequest_ChannelOverridesFable51MaxEffortMultiplier(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,
|
||||
}}, nil)
|
||||
group := &Group{ID: 100, Platform: PlatformAnthropic}
|
||||
gid := group.ID
|
||||
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "claude-fable-5-1", GroupID: &gid, Group: group})
|
||||
|
||||
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||||
Ctx: context.Background(), Model: "claude-fable-5-1", Group: group,
|
||||
Tokens: UsageTokens{InputTokens: 1000}, RateMultiplier: 1, ReasoningEffort: "max",
|
||||
Resolver: resolver, Resolved: resolved,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 1000*10e-6*configured, got.TotalCost, 1e-12)
|
||||
require.InDelta(t, got.TotalCost, got.ActualCost, 1e-12)
|
||||
}
|
||||
|
||||
@@ -87,25 +87,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"`
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// ChannelTimePricing 渠道模型定价的分时倍率配置。
|
||||
|
||||
@@ -117,20 +117,16 @@ 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) {
|
||||
if pricingService == nil {
|
||||
return
|
||||
}
|
||||
for i := range models {
|
||||
if !pricingNeedsFallback(models[i].Pricing) {
|
||||
continue
|
||||
if pricingService != nil && pricingNeedsFallback(models[i].Pricing) {
|
||||
if lp := pricingService.GetModelPricing(models[i].Name); lp != nil {
|
||||
models[i].Pricing = synthesizePricingFromLiteLLM(lp, models[i].Pricing)
|
||||
}
|
||||
}
|
||||
lp := pricingService.GetModelPricing(models[i].Name)
|
||||
if lp == nil {
|
||||
continue
|
||||
}
|
||||
models[i].Pricing = synthesizePricingFromLiteLLM(lp, models[i].Pricing)
|
||||
models[i].Pricing = withDefaultMaxReasoningEffortMultiplier(models[i].Pricing, models[i].Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,24 +176,33 @@ 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),
|
||||
BillingMode: mode,
|
||||
PerRequestPrice: nonZeroPtr(lp.OutputCostPerImage),
|
||||
ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken),
|
||||
InputPrice: nonZeroPtr(lp.InputCostPerToken),
|
||||
OutputPrice: nonZeroPtr(lp.OutputCostPerToken),
|
||||
MaxReasoningEffortMultiplier: maxReasoningEffortMultiplierFromPricing(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),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
func maxReasoningEffortMultiplierFromPricing(pricing *ChannelModelPricing) *float64 {
|
||||
if pricing == nil {
|
||||
return nil
|
||||
}
|
||||
return pricing.MaxReasoningEffortMultiplier
|
||||
}
|
||||
|
||||
func nonZeroPtr(v float64) *float64 {
|
||||
if v == 0 {
|
||||
return nil
|
||||
|
||||
@@ -1092,15 +1092,16 @@ func (s *GatewayService) calculateTokenCost(
|
||||
}
|
||||
|
||||
cost, err := s.billingService.CalculateTokenCostForRequest(TokenCostRequest{
|
||||
Ctx: ctx,
|
||||
Model: billingModel,
|
||||
Group: apiKey.Group,
|
||||
Tokens: tokens,
|
||||
RateMultiplier: multiplier,
|
||||
PricingAt: pricingAt,
|
||||
ServiceTier: optionalStringValue(result.ServiceTier),
|
||||
Resolver: s.resolver,
|
||||
Resolved: resolved,
|
||||
Ctx: ctx,
|
||||
Model: billingModel,
|
||||
Group: apiKey.Group,
|
||||
Tokens: tokens,
|
||||
RateMultiplier: multiplier,
|
||||
PricingAt: pricingAt,
|
||||
ServiceTier: optionalStringValue(result.ServiceTier),
|
||||
ReasoningEffort: optionalStringValue(result.ReasoningEffort),
|
||||
Resolver: s.resolver,
|
||||
Resolved: resolved,
|
||||
})
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.gateway", "Calculate cost failed: %v", err)
|
||||
|
||||
@@ -116,7 +116,7 @@ type Group struct {
|
||||
// 一旦设置即接管该分组用户的限流(覆盖用户级 rpm_limit),可被 user-group rpm_override 进一步覆盖。
|
||||
RPMLimit int
|
||||
|
||||
// MaxReasoningEffort limits the effective OpenAI/Codex reasoning effort.
|
||||
// MaxReasoningEffort limits the effective Anthropic/OpenAI reasoning effort.
|
||||
// Empty means unlimited; supported values are minimal/low/medium/high/xhigh/max.
|
||||
MaxReasoningEffort string
|
||||
// MaxReasoningEffortOverLimit is the access control when an explicit effort
|
||||
|
||||
@@ -231,7 +231,7 @@ func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaMode
|
||||
Platform: m.Platform,
|
||||
})
|
||||
if err == nil && sched != nil && len(sched.Tiers) > 0 {
|
||||
m.Pricing = plazaPricingFromSchedule(m.Pricing, sched)
|
||||
m.Pricing = withDefaultMaxReasoningEffortMultiplier(plazaPricingFromSchedule(m.Pricing, sched), m.Name)
|
||||
if len(sched.Tiers) > 1 {
|
||||
m.LongContextBasis = sched.Basis
|
||||
}
|
||||
@@ -239,7 +239,20 @@ func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaMode
|
||||
return
|
||||
}
|
||||
}
|
||||
m.Pricing = plazaImageDisplayPricing(m.Pricing, g)
|
||||
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
|
||||
}
|
||||
|
||||
// plazaPricingFromSchedule 把阶梯表压成展示用的 ChannelModelPricing:
|
||||
@@ -250,6 +263,7 @@ func plazaPricingFromSchedule(raw *ChannelModelPricing, sched *ContextPricingSch
|
||||
out.ImageInputPrice = raw.ImageInputPrice
|
||||
out.ImageOutputPrice = raw.ImageOutputPrice
|
||||
out.PerRequestPrice = raw.PerRequestPrice
|
||||
out.MaxReasoningEffortMultiplier = raw.MaxReasoningEffortMultiplier
|
||||
}
|
||||
first := sched.Tiers[0]
|
||||
out.InputPrice = first.Input
|
||||
|
||||
@@ -57,6 +57,19 @@ 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_DedupFirstWinsWithPricingUpgrade(t *testing.T) {
|
||||
// 同名模型:先见者胜;仅当已存条目无定价而新条目有定价时升级替换。
|
||||
unpriced := Channel{
|
||||
|
||||
@@ -247,6 +247,9 @@ 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
|
||||
}
|
||||
// 渠道定价覆盖一切:显式配置则用配置值,未配置则归零(不回退到 LiteLLM)
|
||||
if chPricing.ImageOutputPrice != nil {
|
||||
resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice
|
||||
|
||||
@@ -611,6 +611,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost(
|
||||
pricingAt,
|
||||
tokens,
|
||||
serviceTier,
|
||||
optionalStringValue(result.ReasoningEffort),
|
||||
longContextBillingGate,
|
||||
)
|
||||
if err == nil {
|
||||
@@ -704,6 +705,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost(
|
||||
pricingAt time.Time,
|
||||
tokens UsageTokens,
|
||||
serviceTier string,
|
||||
reasoningEffort string,
|
||||
longContextBillingGate *bool,
|
||||
) (*CostBreakdown, error) {
|
||||
if s.resolver != nil && apiKey.Group != nil {
|
||||
@@ -711,17 +713,21 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost(
|
||||
return s.billingService.CalculateCostUnified(CostInput{
|
||||
Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group,
|
||||
Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, PricingAt: pricingAt,
|
||||
ServiceTier: serviceTier, Resolver: s.resolver,
|
||||
ServiceTier: serviceTier, ReasoningEffort: reasoningEffort, Resolver: s.resolver,
|
||||
LongContextBillingEnabled: longContextBillingGate,
|
||||
})
|
||||
}
|
||||
return s.billingService.calculateCostWithServiceTierPolicy(
|
||||
breakdown, err := 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(
|
||||
|
||||
@@ -22,6 +22,7 @@ const (
|
||||
)
|
||||
|
||||
var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"}
|
||||
var anthropicReasoningEffortValues = []string{"low", "medium", "high", "xhigh", "max"}
|
||||
|
||||
func normalizeReasoningEffortMappingSource(raw string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(raw), "none") {
|
||||
@@ -90,10 +91,14 @@ func NormalizeMaxReasoningEffort(raw string) string {
|
||||
}
|
||||
|
||||
func reasoningEffortValuesForPlatform(platform string) []string {
|
||||
if platform != PlatformOpenAI && platform != PlatformComposite {
|
||||
switch platform {
|
||||
case PlatformOpenAI, PlatformComposite:
|
||||
return openAIReasoningEffortValues
|
||||
case PlatformAnthropic:
|
||||
return anthropicReasoningEffortValues
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return openAIReasoningEffortValues
|
||||
}
|
||||
|
||||
func normalizeMaxReasoningEffortForPlatform(platform, raw string) (string, error) {
|
||||
@@ -104,7 +109,8 @@ func normalizeMaxReasoningEffortForPlatform(platform, raw string) (string, error
|
||||
allowedValues := reasoningEffortValuesForPlatform(platform)
|
||||
if len(allowedValues) == 0 {
|
||||
return "", fmt.Errorf(
|
||||
"reasoning effort policy is only supported for platforms %q and %q",
|
||||
"reasoning effort policy is only supported for platforms %q, %q, and %q",
|
||||
PlatformAnthropic,
|
||||
PlatformOpenAI,
|
||||
PlatformComposite,
|
||||
)
|
||||
@@ -152,7 +158,8 @@ func normalizeMaxReasoningEffortOverLimitForPlatform(platform, raw string) (stri
|
||||
}
|
||||
if len(reasoningEffortValuesForPlatform(platform)) == 0 {
|
||||
return "", fmt.Errorf(
|
||||
"reasoning effort policy is only supported for platforms %q and %q",
|
||||
"reasoning effort policy is only supported for platforms %q, %q, and %q",
|
||||
PlatformAnthropic,
|
||||
PlatformOpenAI,
|
||||
PlatformComposite,
|
||||
)
|
||||
@@ -279,7 +286,7 @@ func selectReasoningEffortMapping(mappings []ReasoningEffortMapping, from, reque
|
||||
}
|
||||
|
||||
// NormalizeReasoningEffortMappings validates group mapping rules against the
|
||||
// fixed effort values supported by OpenAI routes. Optional model scopes are
|
||||
// fixed effort values supported by the selected platform. Optional model scopes are
|
||||
// canonicalized to exact/prefix/suffix match; empty type and model keep the
|
||||
// global all-models rule.
|
||||
func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapping) ([]ReasoningEffortMapping, error) {
|
||||
@@ -421,7 +428,7 @@ func sanitizeGroupReasoningEffortPolicy(group *Group) {
|
||||
if mappingsErr != nil {
|
||||
mappings = []ReasoningEffortMapping{}
|
||||
}
|
||||
if overLimit == "" || (group.Platform != PlatformOpenAI && group.Platform != PlatformComposite) {
|
||||
if overLimit == "" || (group.Platform != PlatformAnthropic && group.Platform != PlatformOpenAI && group.Platform != PlatformComposite) {
|
||||
overLimit = ReasoningEffortOverLimitDowngrade
|
||||
}
|
||||
group.MaxReasoningEffort = maxEffort
|
||||
@@ -429,12 +436,13 @@ func sanitizeGroupReasoningEffortPolicy(group *Group) {
|
||||
group.ReasoningEffortMappings = mappings
|
||||
}
|
||||
|
||||
// ApplyOpenAIReasoningEffortPolicy applies one mapping (optionally scoped to
|
||||
// ApplyReasoningEffortPolicy applies one mapping (optionally scoped to
|
||||
// the request model by exact name, prefix, or suffix) and then either caps
|
||||
// known effort levels or rejects the request when the group is configured to
|
||||
// deny values above the ceiling. Omitted values remain untouched so upstream
|
||||
// defaults stay in control.
|
||||
func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) {
|
||||
// defaults stay in control. It understands both OpenAI and Anthropic request
|
||||
// field shapes.
|
||||
func ApplyReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) {
|
||||
maxRank, hasMax := reasoningEffortRank(maxEffort)
|
||||
if len(body) == 0 || (!hasMax && len(mappings) == 0) {
|
||||
return body, false, nil
|
||||
@@ -445,7 +453,7 @@ func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []
|
||||
requestModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
|
||||
result := body
|
||||
changed := false
|
||||
for _, path := range []string{"reasoning.effort", "reasoning_effort"} {
|
||||
for _, path := range []string{"reasoning.effort", "reasoning_effort", "output_config.effort"} {
|
||||
field := gjson.GetBytes(result, path)
|
||||
if !field.Exists() || field.Type != gjson.String {
|
||||
continue
|
||||
@@ -492,3 +500,8 @@ func applyOpenAIWSReasoningEffortPolicy(payload []byte, hooks *OpenAIWSIngressHo
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// ApplyOpenAIReasoningEffortPolicy is retained for OpenAI forwarding callers.
|
||||
func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) {
|
||||
return ApplyReasoningEffortPolicy(body, maxEffort, mappings, overLimit)
|
||||
}
|
||||
|
||||
@@ -147,9 +147,9 @@ func TestNormalizeReasoningEffortMappings(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("rejects mappings for non OpenAI platforms", func(t *testing.T) {
|
||||
for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok} {
|
||||
for _, platform := range []string{PlatformGemini, PlatformAntigravity, PlatformGrok} {
|
||||
_, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{{From: "low", To: "high"}})
|
||||
require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"")
|
||||
require.ErrorContains(t, err, "only supported for platforms")
|
||||
}
|
||||
|
||||
_, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "ultra", To: "high"}})
|
||||
@@ -166,6 +166,15 @@ func TestNormalizeReasoningEffortMappings(t *testing.T) {
|
||||
_, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "low", To: "none"}})
|
||||
require.ErrorContains(t, err, "empty or unknown")
|
||||
})
|
||||
|
||||
t.Run("supports Anthropic values except minimal", func(t *testing.T) {
|
||||
got, err := NormalizeReasoningEffortMappings(PlatformAnthropic, []ReasoningEffortMapping{{From: " MAX ", To: " x-high "}})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, got)
|
||||
|
||||
_, err = NormalizeReasoningEffortMappings(PlatformAnthropic, []ReasoningEffortMapping{{From: "minimal", To: "low"}})
|
||||
require.ErrorContains(t, err, "not supported for platform \"anthropic\"")
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeMaxReasoningEffortForPlatform(t *testing.T) {
|
||||
@@ -176,9 +185,15 @@ func TestNormalizeMaxReasoningEffortForPlatform(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "max", value)
|
||||
|
||||
for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok} {
|
||||
value, err = normalizeMaxReasoningEffortForPlatform(PlatformAnthropic, "xhigh")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "xhigh", value)
|
||||
_, err = normalizeMaxReasoningEffortForPlatform(PlatformAnthropic, "minimal")
|
||||
require.ErrorContains(t, err, "not supported")
|
||||
|
||||
for _, platform := range []string{PlatformGemini, PlatformAntigravity, PlatformGrok} {
|
||||
_, err = normalizeMaxReasoningEffortForPlatform(platform, "low")
|
||||
require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"")
|
||||
require.ErrorContains(t, err, "only supported for platforms")
|
||||
}
|
||||
|
||||
_, err = normalizeMaxReasoningEffortForPlatform(PlatformOpenAI, "none")
|
||||
@@ -205,8 +220,9 @@ func TestNormalizeMaxReasoningEffortOverLimitForPlatform(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ReasoningEffortOverLimitDowngrade, value)
|
||||
|
||||
_, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformAnthropic, "deny")
|
||||
require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"")
|
||||
value, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformAnthropic, "deny")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ReasoningEffortOverLimitDeny, value)
|
||||
|
||||
_, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformOpenAI, "block")
|
||||
require.ErrorContains(t, err, "not supported")
|
||||
@@ -251,6 +267,8 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) {
|
||||
}{
|
||||
{name: "nested caps high", body: `{"reasoning":{"effort":"xhigh"}}`, max: "medium", path: "reasoning.effort", want: "medium", changed: true},
|
||||
{name: "flat caps high", body: `{"reasoning_effort":"high"}`, max: "low", path: "reasoning_effort", want: "low", changed: true},
|
||||
{name: "Anthropic output config caps max", body: `{"output_config":{"effort":"max"}}`, max: "xhigh", path: "output_config.effort", want: "xhigh", changed: true},
|
||||
{name: "Anthropic output config maps before cap", body: `{"output_config":{"effort":"max"}}`, max: "high", mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, path: "output_config.effort", want: "high", changed: true},
|
||||
{name: "does not raise omitted", body: `{"model":"gpt-5"}`, max: "low", path: "reasoning_effort", want: "", changed: false},
|
||||
{name: "keeps lower value", body: `{"reasoning_effort":"low"}`, max: "high", path: "reasoning_effort", want: "low", changed: false},
|
||||
{name: "normalizes request alias", body: `{"reasoning_effort":"x-high"}`, max: "xhigh", path: "reasoning_effort", want: "xhigh", changed: true},
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE channel_model_pricing
|
||||
ADD COLUMN IF NOT EXISTS max_reasoning_effort_multiplier NUMERIC(10,4);
|
||||
|
||||
COMMENT ON COLUMN channel_model_pricing.max_reasoning_effort_multiplier IS
|
||||
'Billing/quota multiplier applied when the forwarded reasoning effort is max';
|
||||
|
||||
ALTER TABLE channel_model_pricing
|
||||
DROP CONSTRAINT IF EXISTS chk_channel_model_pricing_max_reasoning_effort_multiplier_positive;
|
||||
ALTER TABLE channel_model_pricing
|
||||
ADD CONSTRAINT chk_channel_model_pricing_max_reasoning_effort_multiplier_positive
|
||||
CHECK (max_reasoning_effort_multiplier IS NULL OR max_reasoning_effort_multiplier > 0);
|
||||
@@ -50,6 +50,7 @@ export interface ChannelModelPricing {
|
||||
cache_read_price: number | null
|
||||
fast_multiplier?: number | null
|
||||
flex_multiplier?: number | null
|
||||
max_reasoning_effort_multiplier?: number | null
|
||||
image_input_price: number | null
|
||||
image_output_price: number | null
|
||||
per_request_price: number | null
|
||||
@@ -179,6 +180,7 @@ export interface ModelDefaultPricing {
|
||||
cache_read_price?: number
|
||||
image_input_price?: number
|
||||
image_output_price?: number
|
||||
max_reasoning_effort_multiplier?: number | null
|
||||
}
|
||||
|
||||
export async function getModelDefaultPricing(model: string): Promise<ModelDefaultPricing> {
|
||||
|
||||
@@ -41,6 +41,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
|
||||
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-md grid-cols-2 gap-2">
|
||||
<div v-if="enableTierMultipliers" class="mt-3 grid max-w-2xl grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<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,6 +155,11 @@
|
||||
<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. -->
|
||||
@@ -308,6 +313,12 @@ 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')
|
||||
)
|
||||
|
||||
function emitField(field: keyof PricingFormEntry, value: string) {
|
||||
emit('update', { ...props.entry, [field]: value === '' ? null : value })
|
||||
}
|
||||
@@ -383,6 +394,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,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
|
||||
+4
-2
@@ -18,6 +18,7 @@ function createEntry(billingMode: PricingFormEntry['billing_mode'] = 'token'): P
|
||||
cache_read_price: null,
|
||||
fast_multiplier: null,
|
||||
flex_multiplier: null,
|
||||
max_reasoning_effort_multiplier: null,
|
||||
image_input_price: null,
|
||||
image_output_price: null,
|
||||
per_request_price: null,
|
||||
@@ -72,8 +73,8 @@ describe('PricingEntryCard time pricing visibility', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('PricingEntryCard service tier multipliers', () => {
|
||||
it('shows Fast and Flex controls only when explicitly enabled', () => {
|
||||
describe('PricingEntryCard request multipliers', () => {
|
||||
it('shows Fast, Flex, and Max effort controls only when explicitly enabled', () => {
|
||||
const hidden = shallowMount(PricingEntryCard, { props: { entry: createEntry() } })
|
||||
expect(hidden.text()).not.toContain('admin.channels.form.fastMultiplier')
|
||||
|
||||
@@ -82,5 +83,6 @@ describe('PricingEntryCard service tier 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,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
|
||||
image_input_price: number | string | null
|
||||
image_output_price: number | string | null
|
||||
per_request_price: number | string | null
|
||||
|
||||
@@ -99,6 +99,13 @@
|
||||
>
|
||||
{{ t('modelPlaza.table.marginalBadge') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="m.pricing?.max_reasoning_effort_multiplier"
|
||||
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 })"
|
||||
>
|
||||
{{ t('modelPlaza.table.maxReasoningMultiplierBadge', { multiplier: m.pricing.max_reasoning_effort_multiplier }) }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
|
||||
@@ -68,6 +68,15 @@ describe('PlazaModelPricingTable', () => {
|
||||
expect(text).toContain('1x')
|
||||
})
|
||||
|
||||
it('shows the Max reasoning billing multiplier', () => {
|
||||
const model = tokenModel()
|
||||
model.pricing!.max_reasoning_effort_multiplier = 3
|
||||
const wrapper = mountTable([model], 1)
|
||||
|
||||
expect(wrapper.text()).toContain('modelPlaza.table.maxReasoningMultiplierBadge')
|
||||
expect(wrapper.find('[title="modelPlaza.table.maxReasoningMultiplierHint"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('倍率 ≠ 1 时价格列为折后实付价,官方价列保持原价', () => {
|
||||
const wrapper = mountTable([tokenModel()], 0.5)
|
||||
const text = wrapper.text()
|
||||
|
||||
@@ -138,8 +138,10 @@ export default {
|
||||
pricePlaceholder: 'Default',
|
||||
fastMultiplier: 'Fast Multiplier',
|
||||
flexMultiplier: 'Flex Multiplier',
|
||||
maxReasoningEffortMultiplier: 'Max Effort Multiplier',
|
||||
fable51DefaultMaxReasoningMultiplier: 'Default: 3',
|
||||
multiplierPlaceholder: 'Not set',
|
||||
multiplierPositive: 'Fast/Flex multipliers must be greater than 0',
|
||||
multiplierPositive: 'Fast/Flex/Max effort multipliers must be greater than 0',
|
||||
inputMultiplier: 'Input Mult.',
|
||||
outputMultiplier: 'Output Mult.',
|
||||
cacheWriteMultiplier: 'Cache Write Mult.',
|
||||
|
||||
@@ -859,7 +859,7 @@ export default {
|
||||
rpmLimitHint: 'Max requests per minute for each user in this group; 0 = unlimited. Once set, it takes over per-user rate limiting in this group (overrides the user-level rpm_limit fallback).',
|
||||
maxReasoningEffort: 'Max reasoning effort',
|
||||
maxReasoningEffortUnlimited: 'Unlimited (follow request)',
|
||||
maxReasoningEffortHint: 'Limits explicit OpenAI reasoning effort requests only. For Composite groups, it applies only to requests resolved to OpenAI. Omitted effort stays omitted. The ceiling takes precedence over reasoning effort mappings.',
|
||||
maxReasoningEffortHint: 'Limits explicit Anthropic and OpenAI reasoning effort requests. For Composite groups, it applies to the resolved target platform. Omitted effort stays omitted. The ceiling takes precedence over reasoning effort mappings.',
|
||||
maxReasoningEffortOverLimit: 'Over-limit access control',
|
||||
maxReasoningEffortOverLimitDowngrade: 'Automatically downgrade when over limit',
|
||||
maxReasoningEffortOverLimitDeny: 'Deny access',
|
||||
|
||||
@@ -636,6 +636,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}',
|
||||
marginalBadge: 'excess-only tiers',
|
||||
timePricingRowHint: 'Requests made within this period ({timezone} time) are billed at the prices in this row',
|
||||
timePricingRowHintWeekdays:
|
||||
|
||||
@@ -138,8 +138,10 @@ export default {
|
||||
pricePlaceholder: '默认',
|
||||
fastMultiplier: 'Fast 倍率',
|
||||
flexMultiplier: 'Flex 倍率',
|
||||
maxReasoningEffortMultiplier: 'Max 推理倍率',
|
||||
fable51DefaultMaxReasoningMultiplier: '默认 3',
|
||||
multiplierPlaceholder: '未配置',
|
||||
multiplierPositive: 'Fast/Flex 倍率必须大于 0',
|
||||
multiplierPositive: 'Fast/Flex/Max 推理倍率必须大于 0',
|
||||
inputMultiplier: '输入倍率',
|
||||
outputMultiplier: '输出倍率',
|
||||
cacheWriteMultiplier: '缓存写倍率',
|
||||
|
||||
@@ -841,7 +841,7 @@ export default {
|
||||
rpmLimitHint: '每用户在本分组每分钟最大请求数,0 = 不限制;一旦设置即接管该用户的限流(覆盖用户级 rpm_limit)',
|
||||
maxReasoningEffort: '推理强度上限',
|
||||
maxReasoningEffortUnlimited: '不限制(跟随请求)',
|
||||
maxReasoningEffortHint: '仅限制客户端主动请求的 OpenAI reasoning effort;Composite 分组仅对解析到 OpenAI 的请求生效。不会为缺省请求主动开启推理。上限优先级高于推理强度映射。',
|
||||
maxReasoningEffortHint: '仅限制客户端主动请求的 Anthropic/OpenAI 推理强度;Composite 分组按实际目标平台生效。不会为缺省请求主动开启推理。上限优先级高于推理强度映射。',
|
||||
maxReasoningEffortOverLimit: '超限访问控制',
|
||||
maxReasoningEffortOverLimitDowngrade: '超过上限时自动降档',
|
||||
maxReasoningEffortOverLimitDeny: '拒绝访问',
|
||||
|
||||
@@ -641,6 +641,8 @@ export default {
|
||||
cacheReadShort: '读',
|
||||
tierHint: '按单次请求的总上下文(输入 + 缓存写入 + 缓存读取)所在档位对整单计价',
|
||||
tierHintMarginal: '仅超过阈值的部分按该档计价,输出不加价',
|
||||
maxReasoningMultiplierBadge: 'Max ×{multiplier}',
|
||||
maxReasoningMultiplierHint: '最终转发的推理强度为 max 时,整次请求的计费与额度消耗乘以 {multiplier}',
|
||||
marginalBadge: '超出部分计价',
|
||||
timePricingRowHint: '按 {timezone} 时间,在该时段内发起的请求按本行价格计费',
|
||||
timePricingRowHintWeekdays:
|
||||
|
||||
@@ -560,7 +560,7 @@ export interface Group {
|
||||
platform: GroupPlatform
|
||||
rate_multiplier: number
|
||||
rpm_limit?: number // Group-level RPM cap (0 = unlimited); overrides user-level rpm_limit when set
|
||||
max_reasoning_effort?: string // OpenAI/Codex reasoning ceiling; empty means unlimited
|
||||
max_reasoning_effort?: string // Anthropic/OpenAI reasoning ceiling; empty means unlimited
|
||||
max_reasoning_effort_over_limit?: string // downgrade (default) or deny when over the ceiling
|
||||
reasoning_effort_mappings?: ReasoningEffortMapping[]
|
||||
is_exclusive: boolean
|
||||
|
||||
@@ -865,6 +865,7 @@ function addPricingEntry(sectionIdx: number) {
|
||||
cache_read_price: null,
|
||||
fast_multiplier: null,
|
||||
flex_multiplier: null,
|
||||
max_reasoning_effort_multiplier: null,
|
||||
image_input_price: null,
|
||||
image_output_price: null,
|
||||
per_request_price: null,
|
||||
@@ -902,6 +903,7 @@ async function syncLatestModels(sectionIdx: number) {
|
||||
cache_read_price: null,
|
||||
fast_multiplier: null,
|
||||
flex_multiplier: null,
|
||||
max_reasoning_effort_multiplier: null,
|
||||
image_input_price: null,
|
||||
image_output_price: null,
|
||||
per_request_price: null,
|
||||
@@ -1132,6 +1134,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,
|
||||
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,
|
||||
@@ -1234,6 +1237,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,
|
||||
image_input_price: perTokenToMTok(p.image_input_price),
|
||||
image_output_price: perTokenToMTok(p.image_output_price),
|
||||
per_request_price: p.per_request_price,
|
||||
@@ -1541,7 +1545,8 @@ async function handleSubmit() {
|
||||
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.flex_multiplier) ||
|
||||
!isValidPositiveMultiplier(entry.max_reasoning_effort_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')}`)
|
||||
|
||||
@@ -40,12 +40,14 @@ describe("groupsReasoningEffort", () => {
|
||||
).toEqual(["none", ...expected]);
|
||||
expect(supportsReasoningEffortPolicyPlatform(platform)).toBe(true);
|
||||
}
|
||||
for (const platform of [
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"antigravity",
|
||||
"grok",
|
||||
] as const) {
|
||||
expect(
|
||||
reasoningEffortOptionsForPlatform("anthropic").map(
|
||||
(option) => option.value,
|
||||
),
|
||||
).toEqual(["low", "medium", "high", "xhigh", "max"]);
|
||||
expect(supportsReasoningEffortPolicyPlatform("anthropic")).toBe(true);
|
||||
|
||||
for (const platform of ["gemini", "antigravity", "grok"] as const) {
|
||||
expect(reasoningEffortOptionsForPlatform(platform)).toEqual([]);
|
||||
expect(reasoningEffortSourceOptionsForPlatform(platform)).toEqual([]);
|
||||
expect(supportsReasoningEffortPolicyPlatform(platform)).toBe(false);
|
||||
@@ -158,6 +160,12 @@ describe("groupsReasoningEffort", () => {
|
||||
expect(normalizeReasoningEffortForPlatform("composite", " MAX ")).toBe(
|
||||
"max",
|
||||
);
|
||||
expect(normalizeReasoningEffortForPlatform("anthropic", "xhigh")).toBe(
|
||||
"xhigh",
|
||||
);
|
||||
expect(normalizeReasoningEffortForPlatform("anthropic", "minimal")).toBe(
|
||||
"",
|
||||
);
|
||||
expect(normalizeReasoningEffortForPlatform("grok", "max")).toBe("");
|
||||
expect(normalizeReasoningEffortForPlatform("openai", "none")).toBe("");
|
||||
});
|
||||
|
||||
@@ -17,6 +17,14 @@ const openAIReasoningEffortSourceValues = [
|
||||
...openAIReasoningEffortValues,
|
||||
] as const;
|
||||
|
||||
const anthropicReasoningEffortValues = [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
] as const;
|
||||
|
||||
const reasoningEffortMatchTypes: readonly ReasoningEffortMatchType[] = [
|
||||
"exact",
|
||||
"prefix",
|
||||
@@ -29,14 +37,16 @@ export const reasoningEffortOverLimitDeny = "deny";
|
||||
const reasoningEffortValuesForPlatform = (
|
||||
platform: GroupPlatform,
|
||||
): readonly string[] =>
|
||||
supportsReasoningEffortPolicyPlatform(platform)
|
||||
? openAIReasoningEffortValues
|
||||
: [];
|
||||
platform === "anthropic"
|
||||
? anthropicReasoningEffortValues
|
||||
: supportsReasoningEffortPolicyPlatform(platform)
|
||||
? openAIReasoningEffortValues
|
||||
: [];
|
||||
|
||||
export function supportsReasoningEffortPolicyPlatform(
|
||||
platform: GroupPlatform,
|
||||
): boolean {
|
||||
return platform === "openai" || platform === "composite";
|
||||
return platform === "anthropic" || platform === "openai" || platform === "composite";
|
||||
}
|
||||
|
||||
export function reasoningEffortOptionsForPlatform(platform: GroupPlatform) {
|
||||
|
||||
Reference in New Issue
Block a user