mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-28 16:09:34 +00:00
merge: resolve conflicts with upstream/main
Keep model-scoped reasoning effort mappings together with the deny-or-downgrade ceiling control that landed on main.
This commit is contained in:
@@ -667,6 +667,8 @@ type PricingConfig struct {
|
||||
DataDir string `mapstructure:"data_dir"`
|
||||
// 回退文件路径
|
||||
FallbackFile string `mapstructure:"fallback_file"`
|
||||
// 覆盖补丁文件路径(可选):条目按字段浅合并覆盖目录/回退数据,优先级最高
|
||||
OverrideFile string `mapstructure:"override_file"`
|
||||
// 更新间隔(小时)
|
||||
UpdateIntervalHours int `mapstructure:"update_interval_hours"`
|
||||
// 哈希校验间隔(分钟)
|
||||
@@ -2279,11 +2281,12 @@ func setDefaults() {
|
||||
viper.SetDefault("rate_limit.overload_cooldown_minutes", 10)
|
||||
viper.SetDefault("rate_limit.oauth_401_cooldown_minutes", 10)
|
||||
|
||||
// Pricing - 从 model-price-repo 同步模型定价和上下文窗口数据(固定到 commit,避免分支漂移)
|
||||
// Pricing - 从 model-price-repo main 分支同步模型定价和上下文窗口数据
|
||||
viper.SetDefault("pricing.remote_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.json")
|
||||
viper.SetDefault("pricing.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.sha256")
|
||||
viper.SetDefault("pricing.data_dir", "./data")
|
||||
viper.SetDefault("pricing.fallback_file", "./resources/model-pricing/model_prices_and_context_window.json")
|
||||
viper.SetDefault("pricing.override_file", "")
|
||||
viper.SetDefault("pricing.update_interval_hours", 24)
|
||||
viper.SetDefault("pricing.hash_check_interval_minutes", 10)
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ const (
|
||||
const (
|
||||
APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认)
|
||||
APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code)
|
||||
APIProtocolResponses = "responses" // OpenAI Responses(仅 deepseek,适配 Codex)
|
||||
APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi 原生端点,适配 Codex)
|
||||
APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点
|
||||
)
|
||||
|
||||
@@ -98,6 +98,7 @@ const AntigravityGemini31ProAgentModel = "gemini-pro-agent"
|
||||
// 与前端 useModelWhitelist.ts 中的 antigravityDefaultMappings 保持一致
|
||||
var DefaultAntigravityModelMapping = map[string]string{
|
||||
// Claude 白名单
|
||||
"claude-fable-5-1": "claude-fable-5-1", // 官方模型
|
||||
"claude-fable-5": "claude-fable-5", // 官方模型
|
||||
"claude-opus-4-8": "claude-opus-4-8", // 官方模型
|
||||
"claude-opus-4-7": "claude-opus-4-7", // 官方模型
|
||||
@@ -158,7 +159,8 @@ var DefaultAntigravityModelMapping = map[string]string{
|
||||
// aws_region 自动调整为匹配的区域前缀(如 eu.、apac.、jp. 等)
|
||||
var DefaultBedrockModelMapping = map[string]string{
|
||||
// Claude Fable
|
||||
"claude-fable-5": "anthropic.claude-fable-5",
|
||||
"claude-fable-5-1": "anthropic.claude-fable-5-1",
|
||||
"claude-fable-5": "anthropic.claude-fable-5",
|
||||
// Claude Opus
|
||||
"claude-opus-5": "us.anthropic.claude-opus-5-v1",
|
||||
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
|
||||
|
||||
@@ -29,8 +29,9 @@ func TestDefaultAntigravityModelMapping_ContainsNewClaudeModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]string{
|
||||
"claude-fable-5": "claude-fable-5",
|
||||
"claude-opus-4-8": "claude-opus-4-8",
|
||||
"claude-fable-5-1": "claude-fable-5-1",
|
||||
"claude-fable-5": "claude-fable-5",
|
||||
"claude-opus-4-8": "claude-opus-4-8",
|
||||
}
|
||||
for from, want := range cases {
|
||||
got, ok := DefaultAntigravityModelMapping[from]
|
||||
@@ -92,8 +93,9 @@ func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]string{
|
||||
"claude-fable-5": "anthropic.claude-fable-5",
|
||||
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
|
||||
"claude-fable-5-1": "anthropic.claude-fable-5-1",
|
||||
"claude-fable-5": "anthropic.claude-fable-5",
|
||||
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
|
||||
}
|
||||
for from, want := range cases {
|
||||
got, ok := DefaultBedrockModelMapping[from]
|
||||
|
||||
@@ -57,20 +57,21 @@ 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"`
|
||||
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"`
|
||||
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 {
|
||||
@@ -92,6 +93,7 @@ type pricingIntervalRequest struct {
|
||||
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"`
|
||||
InputMultiplier *float64 `json:"input_multiplier" binding:"omitempty,gt=0"`
|
||||
OutputMultiplier *float64 `json:"output_multiplier" binding:"omitempty,gt=0"`
|
||||
@@ -127,21 +129,22 @@ 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"`
|
||||
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"`
|
||||
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 {
|
||||
@@ -164,6 +167,7 @@ type pricingIntervalResponse struct {
|
||||
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"`
|
||||
InputMultiplier *float64 `json:"input_multiplier"`
|
||||
OutputMultiplier *float64 `json:"output_multiplier"`
|
||||
@@ -254,21 +258,22 @@ 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,
|
||||
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,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingToResponse(p.TimePricing),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +305,7 @@ func intervalToResponse(iv service.PricingInterval) pricingIntervalResponse {
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheWrite1hPrice: iv.CacheWrite1hPrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
InputMultiplier: iv.InputMultiplier,
|
||||
OutputMultiplier: iv.OutputMultiplier,
|
||||
@@ -334,6 +340,7 @@ func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMult
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheWrite1hPrice: iv.CacheWrite1hPrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
InputMultiplier: inputMultiplier,
|
||||
OutputMultiplier: outputMultiplier,
|
||||
@@ -349,20 +356,21 @@ func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMult
|
||||
flexMultiplier = r.FlexMultiplier
|
||||
}
|
||||
result = append(result, service.ChannelModelPricing{
|
||||
Platform: platform,
|
||||
Models: r.Models,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: r.InputPrice,
|
||||
OutputPrice: r.OutputPrice,
|
||||
CacheWritePrice: r.CacheWritePrice,
|
||||
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,
|
||||
ImageInputPrice: r.ImageInputPrice,
|
||||
ImageOutputPrice: r.ImageOutputPrice,
|
||||
PerRequestPrice: r.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingRequestToService(r.TimePricing),
|
||||
})
|
||||
}
|
||||
return result
|
||||
@@ -597,15 +605,24 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) {
|
||||
response.Success(c, gin.H{"found": false})
|
||||
return
|
||||
}
|
||||
cacheWritePrice := pricing.CacheCreationPricePerToken
|
||||
var cacheWrite1hPrice *float64
|
||||
if pricing.SupportsCacheBreakdown {
|
||||
if pricing.CacheCreation5mPrice > 0 {
|
||||
cacheWritePrice = pricing.CacheCreation5mPrice
|
||||
}
|
||||
cacheWrite1hPrice = &pricing.CacheCreation1hPrice
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"found": true,
|
||||
"input_price": pricing.InputPricePerToken,
|
||||
"output_price": pricing.OutputPricePerToken,
|
||||
"cache_write_price": pricing.CacheCreationPricePerToken,
|
||||
"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,
|
||||
"image_input_price": pricing.ImageInputPricePerToken,
|
||||
"image_output_price": pricing.ImageOutputPricePerToken,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -320,15 +320,16 @@ func TestPricingRequestToService_Defaults(t *testing.T) {
|
||||
func TestPricingRequestToService_WithAllFields(t *testing.T) {
|
||||
reqs := []channelModelPricingRequest{
|
||||
{
|
||||
Platform: "openai",
|
||||
Models: []string{"gpt-4", "gpt-4o"},
|
||||
BillingMode: "per_request",
|
||||
InputPrice: float64Ptr(0.01),
|
||||
OutputPrice: float64Ptr(0.03),
|
||||
CacheWritePrice: float64Ptr(0.005),
|
||||
CacheReadPrice: float64Ptr(0.002),
|
||||
ImageOutputPrice: float64Ptr(0.04),
|
||||
PerRequestPrice: float64Ptr(0.5),
|
||||
Platform: "openai",
|
||||
Models: []string{"gpt-4", "gpt-4o"},
|
||||
BillingMode: "per_request",
|
||||
InputPrice: float64Ptr(0.01),
|
||||
OutputPrice: float64Ptr(0.03),
|
||||
CacheWritePrice: float64Ptr(0.005),
|
||||
CacheWrite1hPrice: float64Ptr(0.008),
|
||||
CacheReadPrice: float64Ptr(0.002),
|
||||
ImageOutputPrice: float64Ptr(0.04),
|
||||
PerRequestPrice: float64Ptr(0.5),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -341,6 +342,7 @@ func TestPricingRequestToService_WithAllFields(t *testing.T) {
|
||||
require.Equal(t, float64Ptr(0.01), r.InputPrice)
|
||||
require.Equal(t, float64Ptr(0.03), r.OutputPrice)
|
||||
require.Equal(t, float64Ptr(0.005), r.CacheWritePrice)
|
||||
require.Equal(t, float64Ptr(0.008), r.CacheWrite1hPrice)
|
||||
require.Equal(t, float64Ptr(0.002), r.CacheReadPrice)
|
||||
require.Equal(t, float64Ptr(0.04), r.ImageOutputPrice)
|
||||
require.Equal(t, float64Ptr(0.5), r.PerRequestPrice)
|
||||
@@ -353,15 +355,16 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) {
|
||||
BillingMode: "per_request",
|
||||
Intervals: []pricingIntervalRequest{
|
||||
{
|
||||
MinTokens: 0,
|
||||
MaxTokens: intPtr(2000),
|
||||
TierLabel: "small",
|
||||
InputPrice: float64Ptr(0.01),
|
||||
OutputPrice: float64Ptr(0.02),
|
||||
CacheWritePrice: float64Ptr(0.003),
|
||||
CacheReadPrice: float64Ptr(0.001),
|
||||
PerRequestPrice: float64Ptr(0.1),
|
||||
SortOrder: 1,
|
||||
MinTokens: 0,
|
||||
MaxTokens: intPtr(2000),
|
||||
TierLabel: "small",
|
||||
InputPrice: float64Ptr(0.01),
|
||||
OutputPrice: float64Ptr(0.02),
|
||||
CacheWritePrice: float64Ptr(0.003),
|
||||
CacheWrite1hPrice: float64Ptr(0.006),
|
||||
CacheReadPrice: float64Ptr(0.001),
|
||||
PerRequestPrice: float64Ptr(0.1),
|
||||
SortOrder: 1,
|
||||
},
|
||||
{
|
||||
MinTokens: 2000,
|
||||
@@ -384,6 +387,7 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) {
|
||||
require.Equal(t, float64Ptr(0.01), iv0.InputPrice)
|
||||
require.Equal(t, float64Ptr(0.02), iv0.OutputPrice)
|
||||
require.Equal(t, float64Ptr(0.003), iv0.CacheWritePrice)
|
||||
require.Equal(t, float64Ptr(0.006), iv0.CacheWrite1hPrice)
|
||||
require.Equal(t, float64Ptr(0.001), iv0.CacheReadPrice)
|
||||
require.Equal(t, float64Ptr(0.1), iv0.PerRequestPrice)
|
||||
require.Equal(t, 1, iv0.SortOrder)
|
||||
@@ -558,3 +562,50 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) {
|
||||
require.NotNil(t, body.Data.Models, "models must not be null for platform=%s", platform)
|
||||
}
|
||||
}
|
||||
|
||||
func setupModelDefaultPricingRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
h := &ChannelHandler{billingService: service.NewBillingService(nil, nil)}
|
||||
router.GET("/channels/model-pricing", h.GetModelDefaultPricing)
|
||||
return router
|
||||
}
|
||||
|
||||
func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) {
|
||||
router := setupModelDefaultPricingRouter()
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels/model-pricing?model=claude-fable-5-1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
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"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.True(t, body.Data.Found)
|
||||
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)
|
||||
}
|
||||
|
||||
func TestGetModelDefaultPricing_OmitsUnsupportedCache1hPrice(t *testing.T) {
|
||||
router := setupModelDefaultPricingRouter()
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels/model-pricing?model=claude-sonnet-4", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var body struct {
|
||||
Data struct {
|
||||
Found bool `json:"found"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.True(t, body.Data.Found)
|
||||
require.Nil(t, body.Data.CacheWrite1hPrice)
|
||||
}
|
||||
|
||||
@@ -147,6 +147,8 @@ type CreateGroupRequest struct {
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch bool `json:"allow_messages_dispatch"`
|
||||
AllowLive bool `json:"allow_live"`
|
||||
ForceOpenAIFast bool `json:"force_openai_fast"`
|
||||
FreeOpenAIFast bool `json:"free_openai_fast"`
|
||||
RequireOAuthOnly bool `json:"require_oauth_only"`
|
||||
RequirePrivacySet bool `json:"require_privacy_set"`
|
||||
DefaultMappedModel string `json:"default_mapped_model"`
|
||||
@@ -156,6 +158,8 @@ type CreateGroupRequest struct {
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
// OpenAI/Codex 请求推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort"`
|
||||
// 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
|
||||
// OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。
|
||||
ReasoningEffortMappings []service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
// 从指定分组复制账号(创建后自动绑定)
|
||||
@@ -216,6 +220,8 @@ type UpdateGroupRequest struct {
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch *bool `json:"allow_messages_dispatch"`
|
||||
AllowLive *bool `json:"allow_live"`
|
||||
ForceOpenAIFast *bool `json:"force_openai_fast"`
|
||||
FreeOpenAIFast *bool `json:"free_openai_fast"`
|
||||
RequireOAuthOnly *bool `json:"require_oauth_only"`
|
||||
RequirePrivacySet *bool `json:"require_privacy_set"`
|
||||
DefaultMappedModel *string `json:"default_mapped_model"`
|
||||
@@ -225,6 +231,8 @@ type UpdateGroupRequest struct {
|
||||
RPMLimit *int `json:"rpm_limit"`
|
||||
// OpenAI/Codex 请求推理强度上限;空字符串清除,nil 不修改。
|
||||
MaxReasoningEffort *string `json:"max_reasoning_effort"`
|
||||
// 超过上限时的访问控制;空字符串视为 downgrade,nil 不修改。
|
||||
MaxReasoningEffortOverLimit *string `json:"max_reasoning_effort_over_limit"`
|
||||
// nil 不修改,空数组清空,非空数组替换。
|
||||
ReasoningEffortMappings *[]service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
|
||||
@@ -550,6 +558,8 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
SupportedModelScopes: req.SupportedModelScopes,
|
||||
AllowMessagesDispatch: req.AllowMessagesDispatch,
|
||||
AllowLive: req.AllowLive,
|
||||
ForceOpenAIFast: req.ForceOpenAIFast,
|
||||
FreeOpenAIFast: req.FreeOpenAIFast,
|
||||
RequireOAuthOnly: req.RequireOAuthOnly,
|
||||
RequirePrivacySet: req.RequirePrivacySet,
|
||||
DefaultMappedModel: req.DefaultMappedModel,
|
||||
@@ -557,6 +567,7 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
ModelsListConfig: req.ModelsListConfig,
|
||||
RPMLimit: req.RPMLimit,
|
||||
MaxReasoningEffort: req.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: req.ReasoningEffortMappings,
|
||||
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
|
||||
})
|
||||
@@ -679,6 +690,8 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
SupportedModelScopes: req.SupportedModelScopes,
|
||||
AllowMessagesDispatch: req.AllowMessagesDispatch,
|
||||
AllowLive: req.AllowLive,
|
||||
ForceOpenAIFast: req.ForceOpenAIFast,
|
||||
FreeOpenAIFast: req.FreeOpenAIFast,
|
||||
RequireOAuthOnly: req.RequireOAuthOnly,
|
||||
RequirePrivacySet: req.RequirePrivacySet,
|
||||
DefaultMappedModel: req.DefaultMappedModel,
|
||||
@@ -686,6 +699,7 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
ModelsListConfig: req.ModelsListConfig,
|
||||
RPMLimit: req.RPMLimit,
|
||||
MaxReasoningEffort: req.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: req.ReasoningEffortMappings,
|
||||
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGroupRequestsDecodeForceOpenAIFast(t *testing.T) {
|
||||
var createReq CreateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"name":"fast","force_openai_fast":true,"free_openai_fast":true}`), &createReq))
|
||||
require.True(t, createReq.ForceOpenAIFast)
|
||||
require.True(t, createReq.FreeOpenAIFast)
|
||||
|
||||
var updateReq UpdateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"force_openai_fast":false,"free_openai_fast":false}`), &updateReq))
|
||||
require.NotNil(t, updateReq.ForceOpenAIFast)
|
||||
require.False(t, *updateReq.ForceOpenAIFast)
|
||||
require.NotNil(t, updateReq.FreeOpenAIFast)
|
||||
require.False(t, *updateReq.FreeOpenAIFast)
|
||||
|
||||
var omitted UpdateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{}`), &omitted))
|
||||
require.Nil(t, omitted.ForceOpenAIFast)
|
||||
require.Nil(t, omitted.FreeOpenAIFast)
|
||||
}
|
||||
@@ -66,27 +66,29 @@ 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"`
|
||||
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"`
|
||||
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 等前端不渲染的字段)。
|
||||
type userPricingIntervalDTO struct {
|
||||
MinTokens int `json:"min_tokens"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
TierLabel string `json:"tier_label,omitempty"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
MinTokens int `json:"min_tokens"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
TierLabel string `json:"tier_label,omitempty"`
|
||||
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"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
}
|
||||
|
||||
// userSupportedModel 用户可见的支持模型条目。
|
||||
@@ -292,14 +294,15 @@ func toUserPricingIntervals(src []service.PricingInterval) []userPricingInterval
|
||||
intervals := make([]userPricingIntervalDTO, 0, len(src))
|
||||
for _, iv := range src {
|
||||
intervals = append(intervals, userPricingIntervalDTO{
|
||||
MinTokens: iv.MinTokens,
|
||||
MaxTokens: iv.MaxTokens,
|
||||
TierLabel: iv.TierLabel,
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
MinTokens: iv.MinTokens,
|
||||
MaxTokens: iv.MaxTokens,
|
||||
TierLabel: iv.TierLabel,
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheWrite1hPrice: iv.CacheWrite1hPrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
})
|
||||
}
|
||||
return intervals
|
||||
@@ -320,14 +323,15 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
|
||||
billingMode = string(service.BillingModeToken)
|
||||
}
|
||||
return &userSupportedModelPricing{
|
||||
BillingMode: billingMode,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
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,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -59,17 +60,17 @@ func effectiveAPIKeyPlatform(c *gin.Context, apiKey *service.APIKey) string {
|
||||
return apiKey.Group.Platform
|
||||
}
|
||||
|
||||
func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, bool) {
|
||||
func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, string, bool) {
|
||||
if apiKey == nil || apiKey.Group == nil {
|
||||
return "", nil, false
|
||||
return "", nil, "", false
|
||||
}
|
||||
if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
|
||||
return "", nil, false
|
||||
return "", nil, "", false
|
||||
}
|
||||
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformOpenAI {
|
||||
return "", nil, false
|
||||
return "", nil, "", false
|
||||
}
|
||||
return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, true
|
||||
return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, apiKey.Group.MaxReasoningEffortOverLimit, true
|
||||
}
|
||||
|
||||
func bindRequestedReasoningEffort(c *gin.Context, body []byte, model string) {
|
||||
@@ -100,13 +101,21 @@ func stampForwardRequestedReasoningEffort(result *service.ForwardResult, request
|
||||
result.RequestedReasoningEffort = requested
|
||||
}
|
||||
|
||||
func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool) {
|
||||
func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool, error) {
|
||||
bindRequestedReasoningEffort(c, body, strings.TrimSpace(gjson.GetBytes(body, "model").String()))
|
||||
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
maxEffort, mappings, overLimit, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
if !ok {
|
||||
return body, false
|
||||
return body, false, nil
|
||||
}
|
||||
return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings)
|
||||
return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings, overLimit)
|
||||
}
|
||||
|
||||
func respondOpenAIReasoningEffortPolicyError(c *gin.Context, err error, write func(*gin.Context, int, string, string)) {
|
||||
if c == nil || err == nil || write == nil {
|
||||
return
|
||||
}
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied)
|
||||
write(c, http.StatusForbidden, "permission_error", err.Error())
|
||||
}
|
||||
|
||||
func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *service.APIKey, body []byte) {
|
||||
@@ -121,9 +130,9 @@ func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *s
|
||||
if !effort.Exists() || effort.Type != gjson.String || strings.TrimSpace(effort.String()) == "" {
|
||||
return
|
||||
}
|
||||
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
maxEffort, mappings, overLimit, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings))
|
||||
c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings, overLimit))
|
||||
}
|
||||
|
||||
@@ -117,7 +117,8 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
|
||||
openAICtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
openAICtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
openAICtx.Request = openAICtx.Request.WithContext(service.WithResolvedTargetPlatform(openAICtx.Request.Context(), service.PlatformOpenAI))
|
||||
got, changed := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body)
|
||||
got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(got))
|
||||
requested := service.RequestedReasoningEffortFromContext(openAICtx.Request.Context())
|
||||
@@ -125,7 +126,8 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
|
||||
require.Equal(t, "max", *requested)
|
||||
|
||||
bindOpenAIReasoningEffortPolicyForMessagesRequest(openAICtx, apiKey, []byte(`{"output_config":{"effort":"max"}}`))
|
||||
bound, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body)
|
||||
bound, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(bound))
|
||||
|
||||
@@ -133,14 +135,27 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
|
||||
omittedCtx.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
omittedCtx.Request = omittedCtx.Request.WithContext(service.WithResolvedTargetPlatform(omittedCtx.Request.Context(), service.PlatformOpenAI))
|
||||
bindOpenAIReasoningEffortPolicyForMessagesRequest(omittedCtx, apiKey, []byte(`{"model":"gpt-5"}`))
|
||||
omitted, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body)
|
||||
omitted, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, omitted)
|
||||
|
||||
denyGroup := *group
|
||||
denyGroup.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny
|
||||
denyAPIKey := &service.APIKey{Group: &denyGroup}
|
||||
denyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
denyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
denyCtx.Request = denyCtx.Request.WithContext(service.WithResolvedTargetPlatform(denyCtx.Request.Context(), service.PlatformOpenAI))
|
||||
_, _, err = applyOpenAIReasoningEffortPolicyForRequest(denyCtx, denyAPIKey, body)
|
||||
require.Error(t, err)
|
||||
var overLimit *service.ReasoningEffortOverLimitError
|
||||
require.ErrorAs(t, err, &overLimit)
|
||||
|
||||
grokCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
grokCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
grokCtx.Request = grokCtx.Request.WithContext(service.WithResolvedTargetPlatform(grokCtx.Request.Context(), service.PlatformGrok))
|
||||
got, changed = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body)
|
||||
got, changed, err = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGroupMapperExposesForceOpenAIFastOnlyToAdmins(t *testing.T) {
|
||||
group := &service.Group{
|
||||
ID: 7, Name: "fast", Platform: service.PlatformOpenAI, Status: service.StatusActive, ForceOpenAIFast: true, FreeOpenAIFast: true,
|
||||
}
|
||||
|
||||
userJSON, err := json.Marshal(GroupFromService(group))
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(userJSON), "force_openai_fast")
|
||||
require.NotContains(t, string(userJSON), "free_openai_fast")
|
||||
|
||||
adminJSON, err := json.Marshal(GroupFromServiceAdmin(group))
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(adminJSON), `"force_openai_fast":true`)
|
||||
require.Contains(t, string(adminJSON), `"free_openai_fast":true`)
|
||||
}
|
||||
@@ -148,6 +148,8 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup {
|
||||
}
|
||||
out := &AdminGroup{
|
||||
Group: groupFromServiceBase(g),
|
||||
ForceOpenAIFast: g.ForceOpenAIFast,
|
||||
FreeOpenAIFast: g.FreeOpenAIFast,
|
||||
ProfitControlEnabled: g.ProfitControlEnabled,
|
||||
ProfitMinMargin: g.ProfitMinMargin,
|
||||
ProfitSafetyBuffer: g.ProfitSafetyBuffer,
|
||||
@@ -221,6 +223,7 @@ func groupFromServiceBase(g *service.Group) Group {
|
||||
RequirePrivacySet: g.RequirePrivacySet,
|
||||
RPMLimit: g.RPMLimit,
|
||||
MaxReasoningEffort: g.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: g.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: g.ReasoningEffortMappings,
|
||||
CreatedAt: g.CreatedAt,
|
||||
UpdatedAt: g.UpdatedAt,
|
||||
|
||||
@@ -153,6 +153,8 @@ type Group struct {
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
// MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort"`
|
||||
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
|
||||
// ReasoningEffortMappings OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。
|
||||
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
|
||||
@@ -164,6 +166,10 @@ type Group struct {
|
||||
// 注意:普通用户接口不得返回 model_routing/account_count/account_groups 等内部信息。
|
||||
type AdminGroup struct {
|
||||
Group
|
||||
// ForceOpenAIFast 是管理端请求策略,用户侧分组 DTO 无需暴露。
|
||||
ForceOpenAIFast bool `json:"force_openai_fast"`
|
||||
// FreeOpenAIFast 是管理端计费策略,用户侧分组 DTO 无需暴露。
|
||||
FreeOpenAIFast bool `json:"free_openai_fast"`
|
||||
|
||||
// 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数存储)。
|
||||
// 仅管理员可见:这三个字段与同响应中的 rate_multiplier 相乘即可反推出
|
||||
|
||||
@@ -279,7 +279,7 @@ func InboundEndpointMiddleware() gin.HandlerFunc {
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Context helpers — used by handlers before building
|
||||
// RecordUsageInput / RecordUsageLongContextInput.
|
||||
// RecordUsageInput.
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// GetInboundEndpoint returns the canonical inbound endpoint stored by
|
||||
|
||||
@@ -72,6 +72,13 @@ func (s *gatewayModelsAccountRepoStub) ListByGroup(ctx context.Context, groupID
|
||||
return s.ListSchedulableByGroupID(ctx, groupID)
|
||||
}
|
||||
|
||||
func (s *gatewayModelsAccountRepoStub) ListModelAvailabilityCandidates(ctx context.Context, groupID *int64, _ []string, _ bool) ([]service.Account, error) {
|
||||
if groupID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.ListSchedulableByGroupID(ctx, *groupID)
|
||||
}
|
||||
|
||||
func newGatewayModelsHandlerForTest(repo service.AccountRepository) *GatewayHandler {
|
||||
return &GatewayHandler{
|
||||
gatewayService: service.NewGatewayService(
|
||||
|
||||
@@ -569,33 +569,25 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
forceCacheBilling := fs.ForceCacheBilling
|
||||
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
||||
sessionID := service.ExtractClientSessionID(c)
|
||||
// 长上下文规则由计费服务统一持有(模型广场展示同源),入口只负责声明自己适用该规则。
|
||||
var longContextThreshold int
|
||||
var longContextMultiplier float64
|
||||
if rule := h.gatewayService.LegacyLongContextRule(service.PlatformGemini); rule != nil {
|
||||
longContextThreshold = rule.Threshold
|
||||
longContextMultiplier = rule.Multiplier
|
||||
}
|
||||
// 长上下文阶梯由目录数据驱动,统一在计费路径内生效,入口无需声明。
|
||||
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
|
||||
if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{
|
||||
Result: result,
|
||||
QuotaPlatform: quotaPlatform,
|
||||
APIKey: apiKey,
|
||||
User: apiKey.User,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
PricingAt: pricingAt,
|
||||
InboundEndpoint: inboundEndpoint,
|
||||
UpstreamEndpoint: upstreamEndpoint,
|
||||
UserAgent: userAgent,
|
||||
IPAddress: clientIP,
|
||||
RequestPayloadHash: requestPayloadHash,
|
||||
LongContextThreshold: longContextThreshold,
|
||||
LongContextMultiplier: longContextMultiplier,
|
||||
ForceCacheBilling: forceCacheBilling,
|
||||
APIKeyService: h.apiKeyService,
|
||||
SessionID: sessionID,
|
||||
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||
Result: result,
|
||||
QuotaPlatform: quotaPlatform,
|
||||
APIKey: apiKey,
|
||||
User: apiKey.User,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
PricingAt: pricingAt,
|
||||
InboundEndpoint: inboundEndpoint,
|
||||
UpstreamEndpoint: upstreamEndpoint,
|
||||
UserAgent: userAgent,
|
||||
IPAddress: clientIP,
|
||||
RequestPayloadHash: requestPayloadHash,
|
||||
ForceCacheBilling: forceCacheBilling,
|
||||
APIKeyService: h.apiKeyService,
|
||||
SessionID: sessionID,
|
||||
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
|
||||
}); err != nil {
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.gemini_v1beta.models"),
|
||||
|
||||
@@ -42,6 +42,23 @@ func classifySelectionFailureError(err error, fallback noAccountErrorClassificat
|
||||
if err == nil {
|
||||
return fallback
|
||||
}
|
||||
// A 404 model_not_found fallback is authoritative and must not be downgraded
|
||||
// to a rate-limit verdict. classifyNoAccountError only reaches it through
|
||||
// DiagnoseModelAvailabilityForPlatform, a dedicated database query over
|
||||
// persistent eligibility (active + schedulable + model_mapping) that already
|
||||
// established no account in the group can serve this model at all. A transient
|
||||
// per-model cooldown on one of the remaining candidates does not make "all
|
||||
// available accounts are rate-limited" true.
|
||||
//
|
||||
// Reporting 429 here is actively harmful: retrying can never succeed, and
|
||||
// clients that treat 429 as a rate limit retry hard and swallow the body
|
||||
// (Codex surfaces only "exceeded retry limit, last status: 429"), losing the
|
||||
// one message that names the real problem. It also flips the ops attribution
|
||||
// from a local model-configuration issue to routing capacity, because call
|
||||
// sites gate markOpsRoutingCapacityLimitedIfNoAvailable on ModelNotFound.
|
||||
if fallback.ModelNotFound {
|
||||
return fallback
|
||||
}
|
||||
match := selectionModelRateLimitedPattern.FindStringSubmatch(strings.ToLower(err.Error()))
|
||||
if len(match) != 2 {
|
||||
return fallback
|
||||
|
||||
@@ -234,3 +234,72 @@ func TestClassifyNoAccountError_FromGin_NilContextStillSafe(t *testing.T) {
|
||||
require.False(t, service.HasOpsClientBusinessLimited(nil))
|
||||
require.Empty(t, service.OpsClientBusinessLimitedReason(nil))
|
||||
}
|
||||
|
||||
// 权威的 404 model_not_found 不能被"账号被限流"的 429 盖掉。
|
||||
//
|
||||
// 选号失败的错误串同时携带多种过滤原因,例如
|
||||
// "pool=9, filtered: model_not_supported=8 model_rate_limited=1":8 个账号根本不支持该模型,
|
||||
// 剩下 1 个恰好处于模型级冷却。此时 classifyNoAccountError 已通过持久化判据确认整个分组
|
||||
// 没有账号能服务该模型(ModelNotFound=true),改判成 429 "All available accounts are
|
||||
// currently rate-limited" 是错误诊断——重试永远不会成功,而把 429 当限流的客户端会反复
|
||||
// 重试并吞掉 body(Codex 只显示 "exceeded retry limit"),恰好丢掉唯一说明真实原因的信息。
|
||||
func TestClassifySelectionFailureError_ModelNotFoundIsNotOverriddenByRateLimited(t *testing.T) {
|
||||
modelNotFound := noAccountErrorClassification{
|
||||
Status: http.StatusNotFound,
|
||||
ErrType: "model_not_found",
|
||||
Message: `Model "gpt-5.3-codex" is not supported by any configured account in this group`,
|
||||
ModelNotFound: true,
|
||||
}
|
||||
|
||||
got := classifySelectionFailureError(
|
||||
fmt.Errorf("no available OpenAI accounts supporting model: gpt-5.3-codex "+
|
||||
"(pool=9, filtered: model_not_supported=8 model_rate_limited=1)"),
|
||||
modelNotFound,
|
||||
)
|
||||
|
||||
require.Equal(t, modelNotFound, got,
|
||||
"分组里没有任何账号能服务该模型时,模型级冷却不该把 404 改判成 429")
|
||||
}
|
||||
|
||||
// 真实调用点的顺序:先 classifyNoAccountErrorFromGin,再 classifySelectionFailureError。
|
||||
// 覆盖这条链路是为了同时锁住 ops 归因——调用点用 ModelNotFound 决定是否标记
|
||||
// routing capacity limited,一旦 404 被改判成 429,同一个请求会既被标成
|
||||
// local model configuration 又被标成容量问题,自相矛盾。
|
||||
func TestClassifySelectionFailureError_CallSiteChainKeepsModelNotFoundAttribution(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||||
apiKey := &service.APIKey{GroupID: ptrInt64(43)}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5.3-codex", "gpt-5.3-codex", service.PlatformOpenAI)
|
||||
cls = classifySelectionFailureError(
|
||||
fmt.Errorf("no available OpenAI accounts supporting model: gpt-5.3-codex "+
|
||||
"(pool=9, filtered: model_not_supported=8 model_rate_limited=1)"),
|
||||
cls,
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, cls.Status)
|
||||
require.Equal(t, "model_not_found", cls.ErrType)
|
||||
require.True(t, cls.ModelNotFound)
|
||||
require.Contains(t, cls.Message, "gpt-5.3-codex")
|
||||
require.True(t, service.HasOpsClientBusinessLimited(c))
|
||||
require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c))
|
||||
}
|
||||
|
||||
// 池子里确实存在能服务该模型、只是全部在冷却的账号时,429 改判仍需保留:
|
||||
// 这种情况 fallback 是 503(HasModelSupport=true),重试是有意义的。
|
||||
func TestClassifySelectionFailureError_StillUpgradesNonModelNotFoundFallback(t *testing.T) {
|
||||
fallback := noAccountErrorClassification{
|
||||
Status: http.StatusServiceUnavailable,
|
||||
ErrType: "api_error",
|
||||
Message: "Service temporarily unavailable",
|
||||
}
|
||||
|
||||
got := classifySelectionFailureError(
|
||||
fmt.Errorf("no available accounts supporting model: gpt-5.6-sol (total=3 eligible=0 model_rate_limited=3)"),
|
||||
fallback,
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusTooManyRequests, got.Status)
|
||||
require.Equal(t, "rate_limit_error", got.ErrType)
|
||||
require.False(t, got.ModelNotFound)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const automationBootstrapPrompt = "Review the project and report any important changes."
|
||||
|
||||
func codexAutomationBootstrap(automationID, lastRun, prompt string) string {
|
||||
return "Automation: Scheduled project review\n" +
|
||||
"Automation ID: " + automationID + "\n" +
|
||||
"Automation memory: $CODEX_HOME/automations/" + automationID + "/memory.md\n" +
|
||||
"Last run: " + lastRun + "\n\n" + prompt
|
||||
}
|
||||
|
||||
func codexAutomationBootstrapBody(t *testing.T, output, callID string) []byte {
|
||||
t.Helper()
|
||||
return []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` +
|
||||
mustJSON(t, output) + callID + `}]}`)
|
||||
}
|
||||
|
||||
func TestNormalizeCodexAutomationBootstrapSupportedLastRunValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lastRun string
|
||||
crlf bool
|
||||
}{
|
||||
{name: "never", lastRun: "never"},
|
||||
{name: "timestamp", lastRun: "2026-09-01T02:06:34.536Z (1788228394536)"},
|
||||
{name: "crlf", lastRun: "never", crlf: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := codexAutomationBootstrap("wiki-maintenance", tt.lastRun, automationBootstrapPrompt)
|
||||
if tt.crlf {
|
||||
output = strings.ReplaceAll(output, "\n", "\r\n")
|
||||
}
|
||||
got, changed := normalizeCodexAutomationBootstrap(codexAutomationBootstrapBody(t, output, ""))
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String())
|
||||
require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
|
||||
require.Equal(t, output, gjson.GetBytes(got, "input.0.content.0.text").String())
|
||||
require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) {
|
||||
validOutput := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt)
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
}{
|
||||
{
|
||||
name: "ordinary missing call id",
|
||||
body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"other","output":` + mustJSON(t, validOutput) + `}]}`),
|
||||
},
|
||||
{
|
||||
name: "tui namespace",
|
||||
body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_tui","name":"automation_update","output":` + mustJSON(t, validOutput) + `}]}`),
|
||||
},
|
||||
{
|
||||
name: "valid call id",
|
||||
body: codexAutomationBootstrapBody(t, validOutput, `,"call_id":"call-1"`),
|
||||
},
|
||||
{
|
||||
name: "previous response",
|
||||
body: []byte(`{"model":"gpt-5","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `}]}`),
|
||||
},
|
||||
{
|
||||
name: "real call context",
|
||||
body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `},{"type":"function_call","call_id":"call-1"}]}`),
|
||||
},
|
||||
{
|
||||
name: "heartbeat output",
|
||||
body: codexAutomationBootstrapBody(t, `<heartbeat><automation_id>wiki</automation_id></heartbeat>`, ""),
|
||||
},
|
||||
{
|
||||
name: "mismatched memory id",
|
||||
body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "/wiki/memory.md", "/other/memory.md", 1), ""),
|
||||
},
|
||||
{
|
||||
name: "unsafe automation id",
|
||||
body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("../wiki", "never", automationBootstrapPrompt), ""),
|
||||
},
|
||||
{
|
||||
name: "invalid timestamp",
|
||||
body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "yesterday", automationBootstrapPrompt), ""),
|
||||
},
|
||||
{
|
||||
name: "mismatched timestamp epoch",
|
||||
body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "2026-09-01T02:06:34.536Z (1)", automationBootstrapPrompt), ""),
|
||||
},
|
||||
{
|
||||
name: "missing separator",
|
||||
body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "\n\n"+automationBootstrapPrompt, "\n"+automationBootstrapPrompt, 1), ""),
|
||||
},
|
||||
{
|
||||
name: "empty prompt",
|
||||
body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "never", " \n"), ""),
|
||||
},
|
||||
{
|
||||
name: "mixed missing call id output",
|
||||
body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `},{"type":"computer_call_output","output":"done"}]}`),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, changed := normalizeCodexAutomationBootstrap(tt.body)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, tt.body, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexAutomationBootstrapPreservesOrderAndIsIdempotent(t *testing.T) {
|
||||
output := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt)
|
||||
body := []byte(`{"model":"gpt-5","input":[{"type":"message","role":"user","content":"before"},{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, output) + `},{"type":"message","role":"user","content":"after"}]}`)
|
||||
|
||||
got, changed := normalizeCodexAutomationBootstrap(body)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "before", gjson.GetBytes(got, "input.0.content").String())
|
||||
require.Equal(t, output, gjson.GetBytes(got, "input.1.content.0.text").String())
|
||||
require.Equal(t, "after", gjson.GetBytes(got, "input.2.content").String())
|
||||
|
||||
again, changedAgain := normalizeCodexAutomationBootstrap(got)
|
||||
require.False(t, changedAgain)
|
||||
require.Equal(t, got, again)
|
||||
}
|
||||
@@ -81,7 +81,10 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
|
||||
return
|
||||
}
|
||||
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
|
||||
if cappedBody, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); err != nil {
|
||||
respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse)
|
||||
return
|
||||
} else if changed {
|
||||
body = cappedBody
|
||||
}
|
||||
reqStream, ok := parseOpenAICompatibleStream(body)
|
||||
|
||||
@@ -53,6 +53,10 @@ func (r codexModelsFailoverAccountRepo) ListByGroup(_ context.Context, _ int64)
|
||||
return append([]service.Account(nil), r.accounts...), nil
|
||||
}
|
||||
|
||||
func (r codexModelsFailoverAccountRepo) ListModelAvailabilityCandidates(_ context.Context, _ *int64, _ []string, _ bool) ([]service.Account, error) {
|
||||
return append([]service.Account(nil), r.accounts...), nil
|
||||
}
|
||||
|
||||
type codexModelsFailoverHTTPUpstream struct {
|
||||
service.HTTPUpstream
|
||||
mu sync.Mutex
|
||||
@@ -620,8 +624,8 @@ func requireCompleteCodexModelsHandlerResponse(t *testing.T, recorder *httptest.
|
||||
t.Fatalf("truncation_policy must be populated: %v", model["truncation_policy"])
|
||||
}
|
||||
modalities, ok := model["input_modalities"].([]any)
|
||||
if !ok || len(modalities) != 1 || modalities[0] != "text" {
|
||||
t.Fatalf("custom OpenAI-compatible endpoint modalities: got %v, want [text]", model["input_modalities"])
|
||||
if !ok || len(modalities) != 2 || modalities[0] != "text" || modalities[1] != "image" {
|
||||
t.Fatalf("known GPT model modalities: got %v, want [text image]", model["input_modalities"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const delegationEnvelope = `<codex_delegation><source_thread_id>thread-1</source_thread_id><input>do the work</input></codex_delegation>`
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapSupportedTools(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
namespace string
|
||||
tool string
|
||||
callID string
|
||||
}{
|
||||
{name: "app create missing call id", namespace: "codex_app", tool: "create_thread"},
|
||||
{name: "app send empty call id", namespace: "codex_app", tool: "send_message_to_thread", callID: `,"call_id":""`},
|
||||
{name: "tui create whitespace call id", namespace: "codex_tui", tool: "create_thread", callID: `,"call_id":" "`},
|
||||
{name: "tui send missing call id", namespace: "codex_tui", tool: "send_message_to_thread"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"` + tt.namespace + `","name":"` + tt.tool + `","output":"` + delegationEnvelope + `"` + tt.callID + `}]}`)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String())
|
||||
require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
|
||||
require.Equal(t, delegationEnvelope, gjson.GetBytes(got, "input.0.content.0.text").String())
|
||||
require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item string
|
||||
extra string
|
||||
}{
|
||||
{name: "ordinary missing call id", item: `{"type":"function_call_output","name":"other","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
|
||||
{name: "valid call id", item: `{"type":"function_call_output","call_id":"call-1","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
|
||||
{name: "previous response", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`, extra: `,"previous_response_id":"resp-1"`},
|
||||
{name: "real call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call","call_id":"call-1"}`},
|
||||
{name: "ambiguous call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call"}`},
|
||||
{name: "item reference context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"item_reference","id":"call-1"}`},
|
||||
{name: "built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call","call_id":"call-1"}`},
|
||||
{name: "ambiguous built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call"}`},
|
||||
{name: "mixed missing call id output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call_output","output":"done"}`},
|
||||
{name: "mixed tool search output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"tool_search_output","output":"done"}`},
|
||||
{name: "mixed invalid delegation output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"not an envelope"}`},
|
||||
{name: "non string call id", item: `{"type":"function_call_output","call_id":42,"name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
|
||||
{name: "padded type", item: `{"type":" function_call_output ","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
|
||||
{name: "padded name", item: `{"type":"function_call_output","name":" create_thread ","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
|
||||
{name: "padded namespace", item: `{"type":"function_call_output","name":"create_thread","namespace":" codex_app ","output":"` + delegationEnvelope + `"}`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5","input":[` + tt.item + `]` + tt.extra + `}`)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
changed bool
|
||||
}{
|
||||
{name: "absent", changed: true},
|
||||
{name: "blank string", value: `,"previous_response_id":" "`, changed: true},
|
||||
{name: "non-empty string", value: `,"previous_response_id":"resp-1"`},
|
||||
{name: "null", value: `,"previous_response_id":null`},
|
||||
{name: "number", value: `,"previous_response_id":42`},
|
||||
{name: "boolean", value: `,"previous_response_id":false`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]` + tt.value + `}`)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.Equal(t, tt.changed, changed)
|
||||
if !tt.changed {
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapRejectsDuplicateMembers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "top-level previous response id",
|
||||
body: `{"model":"gpt-5","previous_response_id":"","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`,
|
||||
},
|
||||
{
|
||||
name: "candidate discriminator",
|
||||
body: `{"model":"gpt-5","input":[{"type":"message","type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`,
|
||||
},
|
||||
{
|
||||
name: "candidate call id",
|
||||
body: `{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","call_id":"call-1","call_id":"","output":"` + delegationEnvelope + `"}]}`,
|
||||
},
|
||||
{
|
||||
name: "candidate output",
|
||||
body: `{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"not a delegation","output":"` + delegationEnvelope + `"}]}`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := []byte(tt.body)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapPreservesExactNumbers(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5","metadata":{"integer":9007199254740993},"input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "9007199254740993", gjson.GetBytes(got, "metadata.integer").Raw)
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapRequiresCompleteEnvelope(t *testing.T) {
|
||||
malformed := []string{
|
||||
`<codex_delegation><source_thread_id>thread-1</source_thread_id></codex_delegation>`,
|
||||
`<codex_delegation><source_thread_id></source_thread_id><input>work</input></codex_delegation>`,
|
||||
`<codex_delegation><source_thread_id>thread-1</source_thread_id><input> </input></codex_delegation>`,
|
||||
`prefix<codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>`,
|
||||
`<codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>suffix`,
|
||||
`<codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input><extra>x</extra></codex_delegation>`,
|
||||
`<codex_delegation><source_thread_id>thread-1</source_thread_id><input><nested>work</nested></input></codex_delegation>`,
|
||||
`<x:codex_delegation xmlns:x="urn:test"><source_thread_id>thread-1</source_thread_id><input>work</input></x:codex_delegation>`,
|
||||
`<x:codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input></x:codex_delegation>`,
|
||||
`<codex_delegation xmlns="urn:test"><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>`,
|
||||
`<codex_delegation xmlns:x="urn:test"><x:source_thread_id>thread-1</x:source_thread_id><input>work</input></codex_delegation>`,
|
||||
`<codex_delegation><x:source_thread_id>thread-1</x:source_thread_id><input>work</input></codex_delegation>`,
|
||||
`<codex_delegation xmlns=""><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>`,
|
||||
}
|
||||
for _, envelope := range malformed {
|
||||
body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":` + mustJSON(t, envelope) + `}]}`)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.False(t, changed, envelope)
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapPreservesOrderAndIsIdempotent(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5","input":[{"type":"message","role":"user","content":"before"},{"type":"function_call_output","namespace":"codex_tui","name":"send_message_to_thread","output":"` + delegationEnvelope + `"},{"type":"message","role":"user","content":"after"}]}`)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "before", gjson.GetBytes(got, "input.0.content").String())
|
||||
require.Equal(t, delegationEnvelope, gjson.GetBytes(got, "input.1.content.0.text").String())
|
||||
require.Equal(t, "after", gjson.GetBytes(got, "input.2.content").String())
|
||||
|
||||
again, changedAgain := normalizeCodexDelegationBootstrap(got)
|
||||
require.False(t, changedAgain)
|
||||
require.Equal(t, got, again)
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, value string) string {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
return string(encoded)
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
@@ -432,9 +435,24 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
|
||||
return
|
||||
}
|
||||
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
|
||||
if cappedBody, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); err != nil {
|
||||
respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse)
|
||||
return
|
||||
} else if changed {
|
||||
body = cappedBody
|
||||
}
|
||||
if normalizedBody, changed := normalizeCodexAutomationBootstrap(body); changed {
|
||||
body = normalizedBody
|
||||
reqLog.Info("openai.codex_automation_bootstrap_normalized",
|
||||
zap.String("normalization", "call_output_to_user_message"),
|
||||
)
|
||||
}
|
||||
if normalizedBody, changed := normalizeCodexDelegationBootstrap(body); changed {
|
||||
body = normalizedBody
|
||||
reqLog.Info("openai.codex_delegation_bootstrap_normalized",
|
||||
zap.String("normalization", "call_output_to_user_message"),
|
||||
)
|
||||
}
|
||||
|
||||
reqStream, ok := parseOpenAICompatibleStream(body)
|
||||
if !ok {
|
||||
@@ -1581,6 +1599,312 @@ func (h *OpenAIGatewayHandler) validateFunctionCallOutputRequest(c *gin.Context,
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) {
|
||||
return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate)
|
||||
}
|
||||
|
||||
func normalizeCodexAutomationBootstrap(body []byte) ([]byte, bool) {
|
||||
return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate)
|
||||
}
|
||||
|
||||
func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool) ([]byte, bool) {
|
||||
if !hasUniqueJSONMembers(body) {
|
||||
return body, false
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.UseNumber()
|
||||
var request map[string]any
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
return body, false
|
||||
}
|
||||
if previousResponseID, exists := request["previous_response_id"]; exists {
|
||||
value, ok := previousResponseID.(string)
|
||||
if !ok || strings.TrimSpace(value) != "" {
|
||||
return body, false
|
||||
}
|
||||
}
|
||||
input, ok := request["input"].([]any)
|
||||
if !ok {
|
||||
return body, false
|
||||
}
|
||||
|
||||
// Any call/reference anchor makes a call-less output ambiguous. Responses
|
||||
// built-ins follow the *_call / *_call_output naming convention, so classify
|
||||
// by the wire type shape instead of maintaining an incomplete allowlist.
|
||||
for _, raw := range input {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
typ := stringField(item, "type")
|
||||
if typ == "item_reference" || strings.HasSuffix(typ, "_call") {
|
||||
return body, false
|
||||
}
|
||||
if isResponsesCallOutputType(typ) {
|
||||
callIDValue, exists := item["call_id"]
|
||||
callID, isString := callIDValue.(string)
|
||||
if exists && (!isString || strings.TrimSpace(callID) != "") {
|
||||
return body, false
|
||||
}
|
||||
if !isCandidate(item) {
|
||||
return body, false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changed := false
|
||||
for i, raw := range input {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok || !isCandidate(item) {
|
||||
continue
|
||||
}
|
||||
output, ok := item["output"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
input[i] = map[string]any{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": []any{map[string]any{
|
||||
"type": "input_text",
|
||||
"text": output,
|
||||
}},
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return body, false
|
||||
}
|
||||
normalized, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return body, false
|
||||
}
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
func hasUniqueJSONMembers(body []byte) bool {
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
if !consumeUniqueJSONValue(decoder) {
|
||||
return false
|
||||
}
|
||||
_, err := decoder.Token()
|
||||
return err == io.EOF
|
||||
}
|
||||
|
||||
func consumeUniqueJSONValue(decoder *json.Decoder) bool {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
delim, ok := token.(json.Delim)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
switch delim {
|
||||
case '{':
|
||||
members := make(map[string]struct{})
|
||||
for decoder.More() {
|
||||
keyToken, err := decoder.Token()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
key, ok := keyToken.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if _, duplicate := members[key]; duplicate {
|
||||
return false
|
||||
}
|
||||
members[key] = struct{}{}
|
||||
if !consumeUniqueJSONValue(decoder) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
end, err := decoder.Token()
|
||||
return err == nil && end == json.Delim('}')
|
||||
case '[':
|
||||
for decoder.More() {
|
||||
if !consumeUniqueJSONValue(decoder) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
end, err := decoder.Token()
|
||||
return err == nil && end == json.Delim(']')
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isResponsesCallOutputType(typ string) bool {
|
||||
return strings.HasSuffix(typ, "_call_output") || typ == "tool_search_output"
|
||||
}
|
||||
|
||||
func isCodexDelegationCandidate(item map[string]any) bool {
|
||||
if stringField(item, "type") != "function_call_output" ||
|
||||
!isCodexDelegationTool(stringField(item, "namespace"), stringField(item, "name")) {
|
||||
return false
|
||||
}
|
||||
output, ok := item["output"].(string)
|
||||
return ok && validCodexDelegationEnvelope(output)
|
||||
}
|
||||
|
||||
func isCodexAutomationCandidate(item map[string]any) bool {
|
||||
if stringField(item, "type") != "function_call_output" ||
|
||||
stringField(item, "namespace") != "codex_app" ||
|
||||
stringField(item, "name") != "automation_update" {
|
||||
return false
|
||||
}
|
||||
output, ok := item["output"].(string)
|
||||
return ok && validCodexAutomationBootstrap(output)
|
||||
}
|
||||
|
||||
func stringField(item map[string]any, key string) string {
|
||||
value, _ := item[key].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
func isCodexDelegationTool(namespace, name string) bool {
|
||||
return (namespace == "codex_app" || namespace == "codex_tui") &&
|
||||
(name == "create_thread" || name == "send_message_to_thread")
|
||||
}
|
||||
|
||||
func validCodexAutomationBootstrap(value string) bool {
|
||||
normalized := strings.ReplaceAll(value, "\r\n", "\n")
|
||||
if strings.ContainsRune(normalized, '\r') {
|
||||
return false
|
||||
}
|
||||
lines := strings.Split(normalized, "\n")
|
||||
if len(lines) < 6 {
|
||||
return false
|
||||
}
|
||||
if _, ok := codexAutomationHeaderValue(lines[0], "Automation: "); !ok {
|
||||
return false
|
||||
}
|
||||
automationID, ok := codexAutomationHeaderValue(lines[1], "Automation ID: ")
|
||||
if !ok || !validCodexAutomationID(automationID) {
|
||||
return false
|
||||
}
|
||||
expectedMemory := "Automation memory: $CODEX_HOME/automations/" + automationID + "/memory.md"
|
||||
if lines[2] != expectedMemory {
|
||||
return false
|
||||
}
|
||||
lastRun, ok := codexAutomationHeaderValue(lines[3], "Last run: ")
|
||||
if !ok || !validCodexAutomationLastRun(lastRun) || lines[4] != "" {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines[5:], "\n")) != ""
|
||||
}
|
||||
|
||||
func codexAutomationHeaderValue(line, prefix string) (string, bool) {
|
||||
if !strings.HasPrefix(line, prefix) {
|
||||
return "", false
|
||||
}
|
||||
value := strings.TrimPrefix(line, prefix)
|
||||
return value, value != "" && strings.TrimSpace(value) == value
|
||||
}
|
||||
|
||||
func validCodexAutomationID(value string) bool {
|
||||
if len(value) == 0 || len(value) > 128 || value == "." || value == ".." {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(value); i++ {
|
||||
c := value[i]
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validCodexAutomationLastRun(value string) bool {
|
||||
if value == "never" {
|
||||
return true
|
||||
}
|
||||
separator := strings.LastIndex(value, " (")
|
||||
if separator <= 0 || !strings.HasSuffix(value, ")") {
|
||||
return false
|
||||
}
|
||||
runAt, err := time.Parse(time.RFC3339Nano, value[:separator])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
epochMillis, err := strconv.ParseInt(value[separator+2:len(value)-1], 10, 64)
|
||||
return err == nil && runAt.UnixMilli() == epochMillis
|
||||
}
|
||||
|
||||
func validCodexDelegationEnvelope(value string) bool {
|
||||
decoder := xml.NewDecoder(strings.NewReader(value))
|
||||
var rootSeen, sourceSeen, inputSeen bool
|
||||
var childName string
|
||||
var childText bytes.Buffer
|
||||
depth := 0
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
return rootSeen && depth == 0 && sourceSeen && inputSeen
|
||||
}
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
switch current := token.(type) {
|
||||
case xml.StartElement:
|
||||
depth++
|
||||
if current.Name.Space != "" || len(current.Attr) != 0 || (depth == 1 && current.Name.Local != "codex_delegation") || depth > 2 {
|
||||
return false
|
||||
}
|
||||
if depth == 1 {
|
||||
if rootSeen {
|
||||
return false
|
||||
}
|
||||
rootSeen = true
|
||||
continue
|
||||
}
|
||||
if current.Name.Local != "source_thread_id" && current.Name.Local != "input" {
|
||||
return false
|
||||
}
|
||||
childName = current.Name.Local
|
||||
childText.Reset()
|
||||
case xml.EndElement:
|
||||
if current.Name.Space != "" {
|
||||
return false
|
||||
}
|
||||
if depth == 2 {
|
||||
if current.Name.Local != childName || strings.TrimSpace(childText.String()) == "" {
|
||||
return false
|
||||
}
|
||||
if childName == "source_thread_id" {
|
||||
if sourceSeen {
|
||||
return false
|
||||
}
|
||||
sourceSeen = true
|
||||
} else {
|
||||
if inputSeen {
|
||||
return false
|
||||
}
|
||||
inputSeen = true
|
||||
}
|
||||
childName = ""
|
||||
}
|
||||
depth--
|
||||
if depth < 0 {
|
||||
return false
|
||||
}
|
||||
case xml.CharData:
|
||||
if depth == 2 {
|
||||
_, _ = childText.Write(current)
|
||||
} else if len(bytes.TrimSpace(current)) != 0 {
|
||||
return false
|
||||
}
|
||||
case xml.Comment:
|
||||
return false
|
||||
case xml.ProcInst, xml.Directive:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *OpenAIGatewayHandler) acquireResponsesUserSlot(
|
||||
c *gin.Context,
|
||||
userID int64,
|
||||
@@ -2298,7 +2622,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
zap.Int("candidate_count", scheduleDecision.CandidateCount),
|
||||
)
|
||||
|
||||
maxReasoningEffort, reasoningEffortMappings, _ := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
maxReasoningEffort, reasoningEffortMappings, maxReasoningEffortOverLimit, _ := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
var requestPayloadHash string
|
||||
var turnStartsMu sync.Mutex
|
||||
turnStarts := make(map[int]time.Time, 4)
|
||||
@@ -2325,12 +2649,13 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
// passthrough 没有 BeforeTurn 时,AfterTurn 回退到 TurnStarted 的所属 turn 时刻。
|
||||
var turnPricing openAIWSTurnPricing
|
||||
hooks := &service.OpenAIWSIngressHooks{
|
||||
ClientLifecycleContext: clientLifecycleCtx,
|
||||
InitialRequestModel: reqModel,
|
||||
InitialTurnStartedAt: firstTurnStartedAt,
|
||||
MaxReasoningEffort: maxReasoningEffort,
|
||||
ReasoningEffortMappings: reasoningEffortMappings,
|
||||
TurnStarted: recordTurnStart,
|
||||
ClientLifecycleContext: clientLifecycleCtx,
|
||||
InitialRequestModel: reqModel,
|
||||
InitialTurnStartedAt: firstTurnStartedAt,
|
||||
MaxReasoningEffort: maxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: maxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: reasoningEffortMappings,
|
||||
TurnStarted: recordTurnStart,
|
||||
BeforeRequest: func(turn int, payload []byte, originalModel string) error {
|
||||
c.Set(securityAuditWSTurnContextKey, turn)
|
||||
service.BeginOpsStreamTurn(c, turn)
|
||||
|
||||
@@ -2287,6 +2287,7 @@ func isOpsLocalBusinessLimitError(code string, msg string) bool {
|
||||
strings.Contains(msg, "this group is restricted to claude code clients") ||
|
||||
strings.Contains(msg, "this group does not allow /v1/messages dispatch") ||
|
||||
strings.Contains(msg, "image generation is not enabled for this group") ||
|
||||
(strings.Contains(msg, "reasoning effort") && strings.Contains(msg, "exceeds this group's limit")) ||
|
||||
strings.Contains(msg, "token counting is not supported for this platform") ||
|
||||
strings.Contains(msg, "images api is not supported for this platform") ||
|
||||
(strings.Contains(msg, "model ") && strings.Contains(msg, " not in whitelist")) ||
|
||||
|
||||
@@ -1243,6 +1243,15 @@ func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) {
|
||||
wantErrType: "permission_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "group reasoning effort over limit deny",
|
||||
errType: "permission_error",
|
||||
message: `reasoning effort "high" exceeds this group's limit of "low"`,
|
||||
code: "",
|
||||
status: http.StatusForbidden,
|
||||
wantErrType: "permission_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "route token counting platform unsupported",
|
||||
errType: "not_found_error",
|
||||
@@ -1504,6 +1513,12 @@ func TestClassifyOpsUpstreamAuthTextStillCountsForSLA(t *testing.T) {
|
||||
code: "403",
|
||||
status: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "provider reasoning effort over limit shaped error",
|
||||
message: `reasoning effort "high" exceeds this group's limit of "low"`,
|
||||
code: "403",
|
||||
status: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "provider token counting unsupported shaped error",
|
||||
message: "Token counting is not supported for this platform",
|
||||
|
||||
@@ -153,6 +153,7 @@ type modelDef struct {
|
||||
|
||||
// Antigravity 支持的 Claude 模型
|
||||
var claudeModels = []modelDef{
|
||||
{ID: "claude-fable-5-1", DisplayName: "Claude Fable 5.1", CreatedAt: "2026-09-01T00:00:00Z"},
|
||||
{ID: "claude-fable-5", DisplayName: "Claude Fable 5", CreatedAt: "2026-06-09T00:00:00Z"},
|
||||
{ID: "claude-opus-4-5-thinking", DisplayName: "Claude Opus 4.5 Thinking", CreatedAt: "2025-11-01T00:00:00Z"},
|
||||
{ID: "claude-sonnet-4-5", DisplayName: "Claude Sonnet 4.5", CreatedAt: "2025-09-29T00:00:00Z"},
|
||||
|
||||
@@ -12,6 +12,7 @@ func TestDefaultModels_ContainsNewAndLegacyImageModels(t *testing.T) {
|
||||
}
|
||||
|
||||
requiredIDs := []string{
|
||||
"claude-fable-5-1",
|
||||
"claude-fable-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4-6-thinking",
|
||||
|
||||
@@ -222,6 +222,7 @@ type modelInfo struct {
|
||||
// 只有在此映射表中的模型才会注入身份提示词
|
||||
// 注意:模型映射逻辑在网关层完成;这里仅用于按模型前缀判断是否注入身份提示词。
|
||||
var modelInfoMap = map[string]modelInfo{
|
||||
"claude-fable-5-1": {DisplayName: "Claude Fable 5.1", CanonicalID: "claude-fable-5-1"},
|
||||
"claude-fable-5": {DisplayName: "Claude Fable 5", CanonicalID: "claude-fable-5"},
|
||||
"claude-opus-4-8": {DisplayName: "Claude Opus 4.8", CanonicalID: "claude-opus-4-8"},
|
||||
"claude-opus-4-7": {DisplayName: "Claude Opus 4.7", CanonicalID: "claude-opus-4-7"},
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//go:build unit
|
||||
|
||||
package antigravity
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Gemini usageMetadata 只有 cachedContentTokenCount(缓存命中),没有缓存写入的 token 类别:
|
||||
// 两条转换路径产出的 Claude usage 中 cache_creation_input_tokens 恒为 0,缓存命中计入
|
||||
// cache_read_input_tokens 并从 input_tokens 中扣除。
|
||||
func TestGeminiUsageMapping_NoCacheCreationTokens(t *testing.T) {
|
||||
const geminiBody = `{"candidates":[{"content":{"parts":[{"text":"hi"}],"role":"model"},"finishReason":"STOP"}],` +
|
||||
`"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"cachedContentTokenCount":30,"thoughtsTokenCount":5}}`
|
||||
|
||||
t.Run("non-stream", func(t *testing.T) {
|
||||
_, usage, err := TransformGeminiToClaude([]byte(geminiBody), "gemini-3.1-pro-preview")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 70, usage.InputTokens)
|
||||
require.Equal(t, 25, usage.OutputTokens)
|
||||
require.Equal(t, 30, usage.CacheReadInputTokens)
|
||||
require.Zero(t, usage.CacheCreationInputTokens)
|
||||
})
|
||||
|
||||
t.Run("stream", func(t *testing.T) {
|
||||
p := NewStreamingProcessor("gemini-3.1-pro-preview")
|
||||
out := p.ProcessLine(`data: {"response":` + geminiBody + `}`)
|
||||
require.True(t, strings.Contains(string(out), `"message_start"`))
|
||||
require.NotContains(t, string(out), `"cache_creation_input_tokens"`, "message_start 的 usage 不应携带缓存写入分项")
|
||||
_, usage := p.Finish()
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 70, usage.InputTokens)
|
||||
require.Equal(t, 30, usage.CacheReadInputTokens)
|
||||
require.Zero(t, usage.CacheCreationInputTokens)
|
||||
})
|
||||
}
|
||||
@@ -25,6 +25,17 @@ const (
|
||||
BetaRedactThinking = "redact-thinking-2026-02-12"
|
||||
BetaContextManagement = "context-management-2025-06-27"
|
||||
BetaExtendedCacheTTL = "extended-cache-ttl-2025-04-11"
|
||||
|
||||
// server-side refusal fallback beta 字段族(beta Messages API 专有)。
|
||||
// 客户端(Claude Code / SDK / OpenCode 等)会默认透传 body.fallbacks /
|
||||
// body.fallback_credit_token,上游仅在 anthropic-beta 携带对应 token 时接受;
|
||||
// 缺 token 时 Pydantic 拒收:"fallbacks: Extra inputs are not permitted"。
|
||||
// 仅用于 sanitize 的条件判断(strip-or-keep),禁止加入
|
||||
// FullClaudeCodeMimicryBetas / DefaultBetaHeader / APIKeyBetaHeader /
|
||||
// Bedrock 白名单:server-side fallback 会换模型、改计费,不能默认打开。
|
||||
BetaServerSideFallback = "server-side-fallback-2026-07-01"
|
||||
BetaFallbackCredit = "fallback-credit-2026-07-01"
|
||||
BetaFallbackCreditLegacy = "fallback-credit-2026-06-01"
|
||||
)
|
||||
|
||||
// DroppedBetas 是转发时需要从 anthropic-beta header 中移除的 beta token 列表。
|
||||
@@ -117,6 +128,12 @@ type Model struct {
|
||||
|
||||
// DefaultModels Claude Code 客户端支持的默认模型列表
|
||||
var DefaultModels = []Model{
|
||||
{
|
||||
ID: "claude-fable-5-1",
|
||||
Type: "model",
|
||||
DisplayName: "Claude Fable 5.1",
|
||||
CreatedAt: "2026-09-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
ID: "claude-fable-5",
|
||||
Type: "model",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDefaultModelsContainsClaudeFable51(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, model := range DefaultModels {
|
||||
if model.ID == "claude-fable-5-1" {
|
||||
require.Equal(t, "Claude Fable 5.1", model.DisplayName)
|
||||
require.Equal(t, "2026-09-01T00:00:00Z", model.CreatedAt)
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("claude-fable-5-1 missing from DefaultModels")
|
||||
}
|
||||
@@ -210,11 +210,14 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
|
||||
group.FieldSupportedModelScopes,
|
||||
group.FieldAllowMessagesDispatch,
|
||||
group.FieldAllowLive,
|
||||
group.FieldForceOpenaiFast,
|
||||
group.FieldFreeOpenaiFast,
|
||||
group.FieldDefaultMappedModel,
|
||||
group.FieldMessagesDispatchModelConfig,
|
||||
group.FieldModelsListConfig,
|
||||
group.FieldRpmLimit,
|
||||
group.FieldMaxReasoningEffort,
|
||||
group.FieldMaxReasoningEffortOverLimit,
|
||||
group.FieldReasoningEffortMappings,
|
||||
group.FieldPeakRateEnabled,
|
||||
group.FieldPeakStart,
|
||||
@@ -1007,6 +1010,8 @@ func groupEntityToService(g *dbent.Group) *service.Group {
|
||||
SortOrder: g.SortOrder,
|
||||
AllowMessagesDispatch: g.AllowMessagesDispatch,
|
||||
AllowLive: g.AllowLive,
|
||||
ForceOpenAIFast: g.ForceOpenaiFast,
|
||||
FreeOpenAIFast: g.FreeOpenaiFast,
|
||||
RequireOAuthOnly: g.RequireOauthOnly,
|
||||
RequirePrivacySet: g.RequirePrivacySet,
|
||||
DefaultMappedModel: g.DefaultMappedModel,
|
||||
@@ -1014,6 +1019,7 @@ func groupEntityToService(g *dbent.Group) *service.Group {
|
||||
ModelsListConfig: g.ModelsListConfig,
|
||||
RPMLimit: g.RpmLimit,
|
||||
MaxReasoningEffort: g.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: g.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: g.ReasoningEffortMappings,
|
||||
PeakRateEnabled: g.PeakRateEnabled,
|
||||
PeakStart: g.PeakStart,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetByKeyForAuthCarriesGroupForceOpenAIFast(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
suffix := time.Now().UnixNano()
|
||||
group := mustCreateGroup(t, integrationEntClient, &service.Group{
|
||||
Name: fmt.Sprintf("fast-proj-group-%d", suffix), Platform: service.PlatformOpenAI,
|
||||
RateMultiplier: 1, ForceOpenAIFast: true, FreeOpenAIFast: true,
|
||||
})
|
||||
user := mustCreateUser(t, integrationEntClient, &service.User{
|
||||
Email: fmt.Sprintf("fast-proj-%d@example.com", suffix), Concurrency: 5,
|
||||
})
|
||||
groupID := group.ID
|
||||
keyValue := fmt.Sprintf("sk-fast-proj-%d", suffix)
|
||||
apiKeyRepo := NewAPIKeyRepository(integrationEntClient, integrationDB)
|
||||
key := &service.APIKey{UserID: user.ID, GroupID: &groupID, Key: keyValue, Name: "fast-proj", Status: service.StatusActive}
|
||||
require.NoError(t, apiKeyRepo.Create(ctx, key))
|
||||
t.Cleanup(func() {
|
||||
_, err := integrationDB.ExecContext(ctx, "DELETE FROM auth_cache_invalidation_outbox WHERE cache_key = encode(sha256(convert_to($1, 'UTF8')), 'hex')", keyValue)
|
||||
require.NoError(t, err)
|
||||
_, err = integrationDB.ExecContext(ctx, "DELETE FROM api_keys WHERE id = $1", key.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = integrationDB.ExecContext(ctx, "DELETE FROM users WHERE id = $1", user.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = integrationDB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", group.ID)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
got, err := apiKeyRepo.GetByKeyForAuth(ctx, keyValue)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.Group)
|
||||
require.True(t, got.Group.ForceOpenAIFast)
|
||||
require.True(t, got.Group.FreeOpenAIFast)
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func (r *channelRepository) batchLoadAccountStatsModelPricing(ctx context.Contex
|
||||
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, rule_id, platform, models, billing_mode, input_price, output_price,
|
||||
cache_write_price, cache_read_price, image_output_price, per_request_price, created_at, updated_at
|
||||
cache_write_price, cache_write_1h_price, cache_read_price, image_output_price, per_request_price, created_at, updated_at
|
||||
FROM channel_account_stats_model_pricing WHERE rule_id = ANY($1) ORDER BY rule_id, id`,
|
||||
pq.Array(ruleIDs),
|
||||
)
|
||||
@@ -83,7 +83,7 @@ func (r *channelRepository) batchLoadAccountStatsModelPricing(ctx context.Contex
|
||||
var modelsJSON []byte
|
||||
if err := rows.Scan(
|
||||
&p.ID, &ruleID, &p.Platform, &modelsJSON, &p.BillingMode,
|
||||
&p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheReadPrice,
|
||||
&p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheWrite1hPrice, &p.CacheReadPrice,
|
||||
&p.ImageOutputPrice, &p.PerRequestPrice, &p.CreatedAt, &p.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan account stats model pricing: %w", err)
|
||||
@@ -178,10 +178,10 @@ func createAccountStatsModelPricingTx(ctx context.Context, tx *sql.Tx, ruleID in
|
||||
}
|
||||
platform := pricing.Platform
|
||||
err = tx.QueryRowContext(ctx,
|
||||
`INSERT INTO channel_account_stats_model_pricing (rule_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_output_price, per_request_price)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at`,
|
||||
`INSERT INTO channel_account_stats_model_pricing (rule_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, image_output_price, per_request_price)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at`,
|
||||
ruleID, platform, modelsJSON, billingMode,
|
||||
pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheReadPrice,
|
||||
pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice,
|
||||
pricing.ImageOutputPrice, pricing.PerRequestPrice,
|
||||
).Scan(&pricing.ID, &pricing.CreatedAt, &pricing.UpdatedAt)
|
||||
if err != nil {
|
||||
@@ -202,10 +202,10 @@ func createAccountStatsModelPricingTx(ctx context.Context, tx *sql.Tx, ruleID in
|
||||
func createAccountStatsIntervalTx(ctx context.Context, tx *sql.Tx, iv *service.PricingInterval) error {
|
||||
return tx.QueryRowContext(ctx,
|
||||
`INSERT INTO channel_account_stats_pricing_intervals
|
||||
(pricing_id, min_tokens, max_tokens, tier_label, input_price, output_price, cache_write_price, cache_read_price, per_request_price, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at`,
|
||||
(pricing_id, min_tokens, max_tokens, tier_label, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, per_request_price, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at`,
|
||||
iv.PricingID, iv.MinTokens, iv.MaxTokens, iv.TierLabel,
|
||||
iv.InputPrice, iv.OutputPrice, iv.CacheWritePrice, iv.CacheReadPrice,
|
||||
iv.InputPrice, iv.OutputPrice, iv.CacheWritePrice, iv.CacheWrite1hPrice, iv.CacheReadPrice,
|
||||
iv.PerRequestPrice, iv.SortOrder,
|
||||
).Scan(&iv.ID, &iv.CreatedAt, &iv.UpdatedAt)
|
||||
}
|
||||
@@ -217,7 +217,7 @@ func (r *channelRepository) batchLoadAccountStatsIntervals(ctx context.Context,
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, pricing_id, min_tokens, max_tokens, tier_label,
|
||||
input_price, output_price, cache_write_price, cache_read_price,
|
||||
input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price,
|
||||
per_request_price, sort_order, created_at, updated_at
|
||||
FROM channel_account_stats_pricing_intervals
|
||||
WHERE pricing_id = ANY($1) ORDER BY pricing_id, sort_order, id`,
|
||||
@@ -233,7 +233,7 @@ func (r *channelRepository) batchLoadAccountStatsIntervals(ctx context.Context,
|
||||
var iv service.PricingInterval
|
||||
if err := rows.Scan(
|
||||
&iv.ID, &iv.PricingID, &iv.MinTokens, &iv.MaxTokens, &iv.TierLabel,
|
||||
&iv.InputPrice, &iv.OutputPrice, &iv.CacheWritePrice, &iv.CacheReadPrice,
|
||||
&iv.InputPrice, &iv.OutputPrice, &iv.CacheWritePrice, &iv.CacheWrite1hPrice, &iv.CacheReadPrice,
|
||||
&iv.PerRequestPrice, &iv.SortOrder, &iv.CreatedAt, &iv.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan account stats pricing interval: %w", err)
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
func (r *channelRepository) ListModelPricing(ctx context.Context, channelID int64) ([]service.ChannelModelPricing, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_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, 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,9 +61,9 @@ 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_read_price = $6, fast_multiplier = $7, flex_multiplier = $8, image_input_price = $9, image_output_price = $10, per_request_price = $11, time_pricing = $12, platform = $13, updated_at = NOW()
|
||||
WHERE id = $14`,
|
||||
modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheReadPrice,
|
||||
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`,
|
||||
modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice,
|
||||
pricing.FastMultiplier, pricing.FlexMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice,
|
||||
timePricingJSON, pricing.Platform, pricing.ID,
|
||||
)
|
||||
@@ -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_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, 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),
|
||||
)
|
||||
@@ -136,7 +136,7 @@ func (r *channelRepository) batchLoadModelPricing(ctx context.Context, channelID
|
||||
func (r *channelRepository) batchLoadIntervals(ctx context.Context, pricingIDs []int64) (map[int64][]service.PricingInterval, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, pricing_id, min_tokens, max_tokens, tier_label,
|
||||
input_price, output_price, cache_write_price, cache_read_price,
|
||||
input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price,
|
||||
input_multiplier, output_multiplier, cache_write_multiplier, cache_read_multiplier,
|
||||
per_request_price, sort_order, created_at, updated_at
|
||||
FROM channel_pricing_intervals
|
||||
@@ -153,7 +153,7 @@ func (r *channelRepository) batchLoadIntervals(ctx context.Context, pricingIDs [
|
||||
var iv service.PricingInterval
|
||||
if err := rows.Scan(
|
||||
&iv.ID, &iv.PricingID, &iv.MinTokens, &iv.MaxTokens, &iv.TierLabel,
|
||||
&iv.InputPrice, &iv.OutputPrice, &iv.CacheWritePrice, &iv.CacheReadPrice,
|
||||
&iv.InputPrice, &iv.OutputPrice, &iv.CacheWritePrice, &iv.CacheWrite1hPrice, &iv.CacheReadPrice,
|
||||
&iv.InputMultiplier, &iv.OutputMultiplier, &iv.CacheWriteMultiplier, &iv.CacheReadMultiplier,
|
||||
&iv.PerRequestPrice, &iv.SortOrder, &iv.CreatedAt, &iv.UpdatedAt,
|
||||
); err != nil {
|
||||
@@ -179,7 +179,7 @@ func scanModelPricingRows(rows *sql.Rows) ([]service.ChannelModelPricing, []int6
|
||||
var timePricingJSON []byte
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.ChannelID, &p.Platform, &modelsJSON, &p.BillingMode,
|
||||
&p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheReadPrice,
|
||||
&p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheWrite1hPrice, &p.CacheReadPrice,
|
||||
&p.FastMultiplier, &p.FlexMultiplier,
|
||||
&p.ImageInputPrice, &p.ImageOutputPrice, &p.PerRequestPrice, &timePricingJSON, &p.CreatedAt, &p.UpdatedAt,
|
||||
); err != nil {
|
||||
@@ -247,10 +247,10 @@ 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_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) 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, 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`,
|
||||
pricing.ChannelID, platform, modelsJSON, billingMode,
|
||||
pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheReadPrice,
|
||||
pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice,
|
||||
pricing.FastMultiplier, pricing.FlexMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice,
|
||||
pricing.PerRequestPrice, timePricingJSON,
|
||||
).Scan(&pricing.ID, &pricing.CreatedAt, &pricing.UpdatedAt)
|
||||
@@ -293,10 +293,10 @@ func unmarshalChannelTimePricing(data []byte) (*service.ChannelTimePricing, erro
|
||||
func createIntervalExec(ctx context.Context, exec dbExec, iv *service.PricingInterval) error {
|
||||
return exec.QueryRowContext(ctx,
|
||||
`INSERT INTO channel_pricing_intervals
|
||||
(pricing_id, min_tokens, max_tokens, tier_label, input_price, output_price, cache_write_price, cache_read_price, input_multiplier, output_multiplier, cache_write_multiplier, cache_read_multiplier, per_request_price, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at`,
|
||||
(pricing_id, min_tokens, max_tokens, tier_label, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, input_multiplier, output_multiplier, cache_write_multiplier, cache_read_multiplier, per_request_price, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, created_at, updated_at`,
|
||||
iv.PricingID, iv.MinTokens, iv.MaxTokens, iv.TierLabel,
|
||||
iv.InputPrice, iv.OutputPrice, iv.CacheWritePrice, iv.CacheReadPrice,
|
||||
iv.InputPrice, iv.OutputPrice, iv.CacheWritePrice, iv.CacheWrite1hPrice, iv.CacheReadPrice,
|
||||
iv.InputMultiplier, iv.OutputMultiplier, iv.CacheWriteMultiplier, iv.CacheReadMultiplier,
|
||||
iv.PerRequestPrice, iv.SortOrder,
|
||||
).Scan(&iv.ID, &iv.CreatedAt, &iv.UpdatedAt)
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
var channelModelPricingTimePricingColumns = []string{
|
||||
"id", "channel_id", "platform", "models", "billing_mode", "input_price", "output_price",
|
||||
"cache_write_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", "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, timePricing,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, timePricing,
|
||||
time.Date(2026, 8, 17, 0, 0, 0, 0, time.UTC), time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC),
|
||||
)
|
||||
}
|
||||
@@ -105,10 +105,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateRoundTrip(t *testing.T) {
|
||||
|
||||
t.Run("create writes JSON", func(t *testing.T) {
|
||||
repo, mock := newChannelModelPricingTimePricingRepo(t)
|
||||
mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_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, 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, channelModelPricingTimePricingJSON,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON,
|
||||
).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{}))
|
||||
|
||||
@@ -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 = \$11, time_pricing = \$12, platform = \$13.*WHERE id = \$14`).
|
||||
mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$12, time_pricing = \$13, platform = \$14.*WHERE id = \$15`).
|
||||
WithArgs(
|
||||
[]byte(`["gpt-5"]`), service.BillingModeToken,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11),
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11),
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
@@ -153,10 +153,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateWriteNullWhenDisabled(t *t
|
||||
|
||||
t.Run("create writes SQL NULL", func(t *testing.T) {
|
||||
repo, mock := newChannelModelPricingTimePricingRepo(t)
|
||||
mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_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, 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,
|
||||
).
|
||||
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 = \$11, time_pricing = \$12, platform = \$13.*WHERE id = \$14`).
|
||||
mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$12, time_pricing = \$13, platform = \$14.*WHERE id = \$15`).
|
||||
WithArgs(
|
||||
[]byte(`["gpt-5"]`), service.BillingModeToken,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11),
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11),
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/ent"
|
||||
@@ -18,6 +21,69 @@ import (
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const (
|
||||
maxDatabaseInitializationRetries = 8
|
||||
databaseInitializationRetryBase = time.Second
|
||||
databaseInitializationRetryMax = 30 * time.Second
|
||||
)
|
||||
|
||||
// initializeDatabaseWithRetry retries only errors that indicate PostgreSQL is
|
||||
// temporarily unavailable during startup. Permanent configuration, migration,
|
||||
// and data errors are returned immediately so they remain visible to operators.
|
||||
func initializeDatabaseWithRetry(ctx context.Context, initialize func(context.Context) error) error {
|
||||
return initializeDatabaseWithRetryWithWait(ctx, initialize, waitForDatabaseInitializationRetry)
|
||||
}
|
||||
|
||||
func initializeDatabaseWithRetryWithWait(
|
||||
ctx context.Context,
|
||||
initialize func(context.Context) error,
|
||||
wait func(context.Context, time.Duration) error,
|
||||
) error {
|
||||
for attempt := 1; ; attempt++ {
|
||||
if err := initialize(ctx); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
if !isTransientDatabaseInitializationError(err) || attempt > maxDatabaseInitializationRetries {
|
||||
return err
|
||||
}
|
||||
|
||||
delay := databaseInitializationRetryBase * time.Duration(1<<(attempt-1))
|
||||
if delay > databaseInitializationRetryMax {
|
||||
delay = databaseInitializationRetryMax
|
||||
}
|
||||
slog.Warn("database initialization temporarily unavailable; retrying",
|
||||
"retry", attempt,
|
||||
"max_retries", maxDatabaseInitializationRetries,
|
||||
"retry_in", delay,
|
||||
"error", err,
|
||||
)
|
||||
if err := wait(ctx, delay); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForDatabaseInitializationRetry(ctx context.Context, delay time.Duration) error {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func isTransientDatabaseInitializationError(err error) bool {
|
||||
var pqErr *pq.Error
|
||||
if !errors.As(err, &pqErr) {
|
||||
return false
|
||||
}
|
||||
code := string(pqErr.Code)
|
||||
return code == "57P03" || strings.HasPrefix(code, "08")
|
||||
}
|
||||
|
||||
// InitEnt 初始化 Ent ORM 客户端并返回客户端实例和底层的 *sql.DB。
|
||||
//
|
||||
// 该函数执行以下操作:
|
||||
@@ -69,7 +135,9 @@ func InitEnt(cfg *config.Config) (*ent.Client, *sql.DB, error) {
|
||||
// 这种方式比 Ent 的自动迁移更可控,支持复杂的迁移场景。
|
||||
migrationCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
if err := applyMigrationsFS(migrationCtx, drv.DB(), migrations.FS); err != nil {
|
||||
if err := initializeDatabaseWithRetry(migrationCtx, func(ctx context.Context) error {
|
||||
return applyMigrationsFS(ctx, drv.DB(), migrations.FS)
|
||||
}); err != nil {
|
||||
_ = drv.Close() // 迁移失败时关闭驱动,避免资源泄露
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func transientDatabaseError(code string) error {
|
||||
return &pq.Error{Code: pq.ErrorCode(code), Message: "database is temporarily unavailable"}
|
||||
}
|
||||
|
||||
func TestIsTransientDatabaseInitializationError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{name: "postgres is starting", err: transientDatabaseError("57P03"), want: true},
|
||||
{name: "connection failure", err: fmt.Errorf("wrapped: %w", transientDatabaseError("08006")), want: true},
|
||||
{name: "authentication failure", err: transientDatabaseError("28P01"), want: false},
|
||||
{name: "migration error", err: errors.New("migration checksum mismatch"), want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, isTransientDatabaseInitializationError(tt.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializeDatabaseWithRetryEventuallySucceeds(t *testing.T) {
|
||||
attempts := 0
|
||||
var delays []time.Duration
|
||||
err := initializeDatabaseWithRetryWithWait(context.Background(), func(context.Context) error {
|
||||
attempts++
|
||||
if attempts <= 3 {
|
||||
return transientDatabaseError("57P03")
|
||||
}
|
||||
return nil
|
||||
}, func(_ context.Context, delay time.Duration) error {
|
||||
delays = append(delays, delay)
|
||||
return nil
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 4, attempts)
|
||||
require.Equal(t, []time.Duration{time.Second, 2 * time.Second, 4 * time.Second}, delays)
|
||||
}
|
||||
|
||||
func TestInitializeDatabaseWithRetryFailsFastForPermanentError(t *testing.T) {
|
||||
attempts := 0
|
||||
waitCalled := false
|
||||
permanentErr := transientDatabaseError("28P01")
|
||||
err := initializeDatabaseWithRetryWithWait(context.Background(), func(context.Context) error {
|
||||
attempts++
|
||||
return permanentErr
|
||||
}, func(_ context.Context, _ time.Duration) error {
|
||||
waitCalled = true
|
||||
return nil
|
||||
})
|
||||
|
||||
require.ErrorIs(t, err, permanentErr)
|
||||
require.Equal(t, 1, attempts)
|
||||
require.False(t, waitCalled)
|
||||
}
|
||||
|
||||
func TestInitializeDatabaseWithRetryStopsWhenContextIsCanceled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
attempts := 0
|
||||
err := initializeDatabaseWithRetryWithWait(ctx, func(context.Context) error {
|
||||
attempts++
|
||||
return transientDatabaseError("57P03")
|
||||
}, func(ctx context.Context, _ time.Duration) error {
|
||||
cancel()
|
||||
return ctx.Err()
|
||||
})
|
||||
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
require.Equal(t, 1, attempts)
|
||||
}
|
||||
|
||||
func TestInitializeDatabaseWithRetryReturnsLastErrorAfterLimit(t *testing.T) {
|
||||
attempts := 0
|
||||
lastErr := transientDatabaseError("08006")
|
||||
var delays []time.Duration
|
||||
err := initializeDatabaseWithRetryWithWait(context.Background(), func(context.Context) error {
|
||||
attempts++
|
||||
return lastErr
|
||||
}, func(_ context.Context, delay time.Duration) error {
|
||||
delays = append(delays, delay)
|
||||
return nil
|
||||
})
|
||||
|
||||
require.ErrorIs(t, err, lastErr)
|
||||
require.Equal(t, maxDatabaseInitializationRetries+1, attempts)
|
||||
require.Len(t, delays, maxDatabaseInitializationRetries)
|
||||
require.Equal(t, databaseInitializationRetryMax, delays[len(delays)-1])
|
||||
}
|
||||
|
||||
func TestInitializeDatabaseWithRetryAllowsIdempotentMigrationRetry(t *testing.T) {
|
||||
applied := make(map[string]bool)
|
||||
attempts := 0
|
||||
err := initializeDatabaseWithRetryWithWait(context.Background(), func(context.Context) error {
|
||||
attempts++
|
||||
if !applied["001_init.sql"] {
|
||||
applied["001_init.sql"] = true
|
||||
return transientDatabaseError("57P03")
|
||||
}
|
||||
return nil
|
||||
}, func(_ context.Context, _ time.Duration) error { return nil })
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, attempts)
|
||||
require.True(t, applied["001_init.sql"])
|
||||
}
|
||||
@@ -90,6 +90,8 @@ func mustCreateGroup(t *testing.T, client *dbent.Client, g *service.Group) *serv
|
||||
SetSubscriptionType(g.SubscriptionType).
|
||||
SetRateMultiplier(g.RateMultiplier).
|
||||
SetIsExclusive(g.IsExclusive).
|
||||
SetForceOpenaiFast(g.ForceOpenAIFast).
|
||||
SetFreeOpenaiFast(g.FreeOpenAIFast).
|
||||
SetProfitControlEnabled(g.ProfitControlEnabled).
|
||||
SetProfitMinMargin(g.ProfitMinMargin).
|
||||
SetProfitSafetyBuffer(g.ProfitSafetyBuffer)
|
||||
|
||||
@@ -103,6 +103,8 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi
|
||||
SetMcpXMLInject(groupIn.MCPXMLInject).
|
||||
SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch).
|
||||
SetAllowLive(groupIn.AllowLive).
|
||||
SetForceOpenaiFast(groupIn.ForceOpenAIFast).
|
||||
SetFreeOpenaiFast(groupIn.FreeOpenAIFast).
|
||||
SetRequireOauthOnly(groupIn.RequireOAuthOnly).
|
||||
SetRequirePrivacySet(groupIn.RequirePrivacySet).
|
||||
SetDefaultMappedModel(groupIn.DefaultMappedModel).
|
||||
@@ -110,6 +112,7 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi
|
||||
SetModelsListConfig(groupIn.ModelsListConfig).
|
||||
SetRpmLimit(groupIn.RPMLimit).
|
||||
SetMaxReasoningEffort(groupIn.MaxReasoningEffort).
|
||||
SetMaxReasoningEffortOverLimit(groupIn.MaxReasoningEffortOverLimit).
|
||||
SetReasoningEffortMappings(groupIn.ReasoningEffortMappings).
|
||||
SetPeakRateEnabled(groupIn.PeakRateEnabled).
|
||||
SetPeakStart(groupIn.PeakStart).
|
||||
@@ -279,6 +282,8 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
|
||||
SetMcpXMLInject(groupIn.MCPXMLInject).
|
||||
SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch).
|
||||
SetAllowLive(groupIn.AllowLive).
|
||||
SetForceOpenaiFast(groupIn.ForceOpenAIFast).
|
||||
SetFreeOpenaiFast(groupIn.FreeOpenAIFast).
|
||||
SetRequireOauthOnly(groupIn.RequireOAuthOnly).
|
||||
SetRequirePrivacySet(groupIn.RequirePrivacySet).
|
||||
SetDefaultMappedModel(groupIn.DefaultMappedModel).
|
||||
@@ -286,6 +291,7 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
|
||||
SetModelsListConfig(groupIn.ModelsListConfig).
|
||||
SetRpmLimit(groupIn.RPMLimit).
|
||||
SetMaxReasoningEffort(groupIn.MaxReasoningEffort).
|
||||
SetMaxReasoningEffortOverLimit(groupIn.MaxReasoningEffortOverLimit).
|
||||
SetReasoningEffortMappings(groupIn.ReasoningEffortMappings).
|
||||
SetPeakRateEnabled(groupIn.PeakRateEnabled).
|
||||
SetPeakStart(groupIn.PeakStart).
|
||||
|
||||
@@ -55,8 +55,10 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
|
||||
requireColumn(t, tx, "accounts", "session_window_status", "character varying", 20, true)
|
||||
requireIndex(t, tx, "accounts", "idx_accounts_autopause_expiry_due")
|
||||
|
||||
// groups: OpenAI Live 默认关闭,管理员显式开启后才可访问。
|
||||
// groups: OpenAI Live 与 Fast 强制策略都默认关闭,管理员显式开启后才生效。
|
||||
requireColumn(t, tx, "groups", "allow_live", "boolean", 0, false)
|
||||
requireColumn(t, tx, "groups", "force_openai_fast", "boolean", 0, false)
|
||||
requireColumn(t, tx, "groups", "free_openai_fast", "boolean", 0, false)
|
||||
|
||||
// api_keys: key length should be 128
|
||||
requireColumn(t, tx, "api_keys", "key", "character varying", 128, false)
|
||||
|
||||
@@ -1000,6 +1000,13 @@ func filterSchedulerExtra(extra map[string]any) map[string]any {
|
||||
"openai_ws_force_http",
|
||||
"openai_responses_mode",
|
||||
"openai_responses_supported",
|
||||
// 透传开关必须进投影:候选过滤(ListSchedulableAccounts)读的是本投影,
|
||||
// 而 Account.IsModelSupported 靠 extra 上的这两个键短路 model_mapping 白名单。
|
||||
// 裁掉它们,透传账号在选号阶段会退回按(常为过期的)白名单判定并被误判为
|
||||
// model_not_supported —— 转发阶段却仍按透传工作,表现为"单独测账号能通、
|
||||
// 走网关报 no available accounts"。
|
||||
"openai_passthrough",
|
||||
"openai_oauth_passthrough",
|
||||
"codex_fingerprint_mode",
|
||||
"codex_fingerprint_seed",
|
||||
"codex_5h_used_percent",
|
||||
|
||||
@@ -1079,3 +1079,45 @@ func schedulerCacheBenchmarkAccounts(size int) []service.Account {
|
||||
}
|
||||
return accounts
|
||||
}
|
||||
|
||||
// 调度投影必须保留 OpenAI 透传开关。
|
||||
//
|
||||
// 候选过滤走 ListSchedulableAccounts,读的是 buildSchedulerMetadataAccount 产出的精简投影;
|
||||
// Account.IsModelSupported 又靠 extra 上的透传开关短路 model_mapping 白名单(#4936)。
|
||||
// 一旦投影把开关裁掉、却保留了白名单,透传账号在选号阶段就会退回白名单判定并被误判成
|
||||
// model_not_supported,而转发阶段(读完整账号)仍按透传工作 —— 表现为"单独测这个账号能通、
|
||||
// 走网关却报 no available accounts"。#4936 修的是判定逻辑,这里守的是喂给判定的输入。
|
||||
func TestBuildSchedulerMetadataAccount_KeepsOpenAIPassthroughForModelGate(t *testing.T) {
|
||||
for _, key := range []string{"openai_passthrough", "openai_oauth_passthrough"} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
account := service.Account{
|
||||
ID: 383,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
// 账号从白名单模式切到透传后常见的残留映射,未列出请求的模型。
|
||||
"model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"},
|
||||
"access_token": "drop-me",
|
||||
},
|
||||
Extra: map[string]any{key: true},
|
||||
}
|
||||
require.True(t, account.IsModelSupported("gpt-5.6-sol"),
|
||||
"前置条件:透传账号本应放行白名单外的模型")
|
||||
|
||||
meta := buildSchedulerMetadataAccount(account)
|
||||
|
||||
// 走一遍真实的序列化/反序列化路径(写入 sched:meta 再由 decodeCachedAccount 读回)。
|
||||
payload, err := json.Marshal(meta)
|
||||
require.NoError(t, err)
|
||||
var restored service.Account
|
||||
require.NoError(t, json.Unmarshal(payload, &restored))
|
||||
|
||||
require.Equal(t, true, restored.Extra[key])
|
||||
require.True(t, restored.IsOpenAIPassthroughEnabled())
|
||||
require.True(t, restored.IsModelSupported("gpt-5.6-sol"),
|
||||
"投影裁掉透传开关会让透传账号在候选过滤阶段被误判为 model_not_supported")
|
||||
// 白名单本身仍需保留:非透传账号依赖它做模型门。
|
||||
require.Equal(t, map[string]any{"gpt-5.5": "gpt-5.5"}, restored.Credentials["model_mapping"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,6 +392,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"require_oauth_only": false,
|
||||
"require_privacy_set": false,
|
||||
"max_reasoning_effort": "",
|
||||
"max_reasoning_effort_over_limit": "",
|
||||
"reasoning_effort_mappings": null,
|
||||
"rpm_limit": 0,
|
||||
"created_at": "2025-01-02T03:04:05Z",
|
||||
|
||||
@@ -1387,8 +1387,8 @@ func (a *Account) IsCodingPlan() bool {
|
||||
|
||||
// GetAPIProtocol 返回国产供应商账号的上游 API 协议。存储于
|
||||
// credentials["api_protocol"];缺失或与平台不匹配时回退 chat_completions
|
||||
// (与既有行为完全一致)。responses 协议仅 deepseek 支持(官方原生 /responses
|
||||
// 端点,适配 Codex);kimi/zhipu 无此端点。
|
||||
// (与既有行为完全一致)。responses 协议仅 deepseek / kimi 支持(官方原生
|
||||
// Responses 端点,适配 Codex);zhipu 无此端点。
|
||||
func (a *Account) GetAPIProtocol() string {
|
||||
if a == nil || !a.IsCNProvider() {
|
||||
return APIProtocolChatCompletions
|
||||
@@ -1399,7 +1399,7 @@ func (a *Account) GetAPIProtocol() string {
|
||||
case APIProtocolAnthropic:
|
||||
return APIProtocolAnthropic
|
||||
case APIProtocolResponses:
|
||||
if a.Platform == PlatformDeepseek {
|
||||
if a.SupportsNativeCNResponses() {
|
||||
return APIProtocolResponses
|
||||
}
|
||||
case APIProtocolChatCompletions:
|
||||
@@ -1408,6 +1408,35 @@ func (a *Account) GetAPIProtocol() string {
|
||||
return APIProtocolChatCompletions
|
||||
}
|
||||
|
||||
// SupportsNativeCNResponses 报告该国产供应商是否提供原生 Responses 端点。
|
||||
// DeepSeek 官方为 /responses(无 /v1);Kimi 按量付费与 Coding Plan 均为
|
||||
// /v1/responses(moonshot.cn / kimi.com/coding)。
|
||||
func (a *Account) SupportsNativeCNResponses() bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
switch a.Platform {
|
||||
case PlatformDeepseek, PlatformKimi:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// UsesNativeCNResponses 报告当前账号是否应按原生 Responses 协议转发
|
||||
// (显式 responses,或 adaptive 且平台具备原生端点)。
|
||||
func (a *Account) UsesNativeCNResponses() bool {
|
||||
if a == nil || !a.SupportsNativeCNResponses() {
|
||||
return false
|
||||
}
|
||||
switch a.GetAPIProtocol() {
|
||||
case APIProtocolResponses, APIProtocolAdaptive:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsAdaptiveAPIProtocol 报告账号是否按入站协议动态选择供应商原生端点。
|
||||
func (a *Account) IsAdaptiveAPIProtocol() bool {
|
||||
return a.GetAPIProtocol() == APIProtocolAdaptive
|
||||
|
||||
@@ -62,29 +62,17 @@ func resolveAccountStatsCost(
|
||||
}
|
||||
|
||||
// tryModelFilePricing 使用模型定价文件(LiteLLM/fallback)中的价格计算费用。
|
||||
// 与用户计费共用同一条定价管线,避免这里维护第二份"单价 × token 数"实现后,
|
||||
// 每加一个定价特性都要手工镜像一次。channelPricing 为 nil,保持优先级 3 的
|
||||
// 语义:只取模型定价文件,不引入渠道自定义定价。
|
||||
func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string) *float64 {
|
||||
pricing, err := billingService.GetModelPricing(model)
|
||||
if err != nil || pricing == nil {
|
||||
breakdown, err := billingService.CalculateCostWithServiceTier(
|
||||
model, tokens, 1, normalizeBillingServiceTier(serviceTier),
|
||||
)
|
||||
if err != nil || breakdown == nil || breakdown.TotalCost <= 0 {
|
||||
return nil
|
||||
}
|
||||
normalizedTier := normalizeBillingServiceTier(serviceTier)
|
||||
if normalizedTier == "priority" || normalizedTier == "fast" || normalizedTier == "flex" ||
|
||||
billingService.shouldApplySessionLongContextPricing(tokens, pricing) {
|
||||
breakdown, err := billingService.CalculateCostWithServiceTier(model, tokens, 1, normalizedTier)
|
||||
if err != nil || breakdown == nil || breakdown.TotalCost <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &breakdown.TotalCost
|
||||
}
|
||||
cost := float64(tokens.InputTokens)*pricing.InputPricePerToken +
|
||||
float64(tokens.OutputTokens)*pricing.OutputPricePerToken +
|
||||
float64(tokens.CacheCreationTokens)*pricing.CacheCreationPricePerToken +
|
||||
float64(tokens.CacheReadTokens)*pricing.CacheReadPricePerToken +
|
||||
float64(tokens.ImageOutputTokens)*pricing.ImageOutputPricePerToken
|
||||
if cost <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &cost
|
||||
return &breakdown.TotalCost
|
||||
}
|
||||
|
||||
// tryCustomRules 遍历自定义规则,按数组顺序先命中为准。
|
||||
@@ -200,11 +188,12 @@ func calculateTokenStatsCost(pricing *ChannelModelPricing, tokens UsageTokens) *
|
||||
totalTokens := tokens.InputTokens + tokens.OutputTokens + tokens.CacheCreationTokens + tokens.CacheReadTokens
|
||||
if iv := FindMatchingInterval(pricing.Intervals, totalTokens); iv != nil {
|
||||
p = &ChannelModelPricing{
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheWrite1hPrice: iv.CacheWrite1hPrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,9 +203,17 @@ func calculateTokenStatsCost(pricing *ChannelModelPricing, tokens UsageTokens) *
|
||||
}
|
||||
return *ptr
|
||||
}
|
||||
cacheCreationCost := float64(tokens.CacheCreationTokens) * deref(p.CacheWritePrice)
|
||||
if p.CacheWrite1hPrice != nil {
|
||||
cache5m, cache1h := normalizeCacheCreationBreakdown(tokens)
|
||||
if cache5m > 0 || cache1h > 0 {
|
||||
cacheCreationCost = float64(cache5m)*deref(p.CacheWritePrice) +
|
||||
float64(cache1h)*deref(p.CacheWrite1hPrice)
|
||||
}
|
||||
}
|
||||
cost := float64(tokens.InputTokens)*deref(p.InputPrice) +
|
||||
float64(tokens.OutputTokens)*deref(p.OutputPrice) +
|
||||
float64(tokens.CacheCreationTokens)*deref(p.CacheWritePrice) +
|
||||
cacheCreationCost +
|
||||
float64(tokens.CacheReadTokens)*deref(p.CacheReadPrice) +
|
||||
float64(tokens.ImageOutputTokens)*deref(p.ImageOutputPrice)
|
||||
if cost <= 0 {
|
||||
|
||||
@@ -228,6 +228,24 @@ func TestCalculateStatsCost_TokenBilling_WithCache(t *testing.T) {
|
||||
require.InDelta(t, 0.95, *result, 1e-12)
|
||||
}
|
||||
|
||||
func TestCalculateStatsCost_TokenBilling_WithCacheTTLPrices(t *testing.T) {
|
||||
pricing := &ChannelModelPricing{
|
||||
BillingMode: BillingModeToken,
|
||||
CacheWritePrice: testPtrFloat64(0.003),
|
||||
CacheWrite1hPrice: testPtrFloat64(0.005),
|
||||
}
|
||||
tokens := UsageTokens{
|
||||
CacheCreationTokens: 200,
|
||||
CacheCreation5mTokens: 80,
|
||||
CacheCreation1hTokens: 120,
|
||||
}
|
||||
|
||||
result := calculateStatsCost(pricing, tokens, 1)
|
||||
require.NotNil(t, result)
|
||||
// 80*0.003 + 120*0.005 = 0.84
|
||||
require.InDelta(t, 0.84, *result, 1e-12)
|
||||
}
|
||||
|
||||
func TestCalculateStatsCost_TokenBilling_WithImageOutput(t *testing.T) {
|
||||
pricing := &ChannelModelPricing{
|
||||
BillingMode: BillingModeToken,
|
||||
@@ -594,8 +612,9 @@ func TestTryModelFilePricing_WithImageOutput(t *testing.T) {
|
||||
}
|
||||
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "")
|
||||
require.NotNil(t, result)
|
||||
// 100*0.001 + 50*0.002 + 10*0.01 = 0.1 + 0.1 + 0.1 = 0.3
|
||||
require.InDelta(t, 0.3, *result, 1e-12)
|
||||
// ImageOutputTokens 是 OutputTokens 的子集,先扣除再按图片单价计。
|
||||
// 100*0.001 + (50-10)*0.002 + 10*0.01 = 0.1 + 0.08 + 0.1 = 0.28
|
||||
require.InDelta(t, 0.28, *result, 1e-12)
|
||||
}
|
||||
|
||||
func TestTryModelFilePricing_WithCacheTokens(t *testing.T) {
|
||||
|
||||
@@ -18,8 +18,8 @@ import (
|
||||
const accountTestSuppressCompletionContextKey = "account_test_suppress_completion"
|
||||
|
||||
// testCNProviderAdaptiveConnection verifies every native endpoint used by an
|
||||
// adaptive CN-provider account. Kimi and Zhipu use Chat Completions plus
|
||||
// Anthropic; DeepSeek additionally uses its native Responses endpoint.
|
||||
// adaptive CN-provider account. Zhipu uses Chat Completions plus Anthropic;
|
||||
// DeepSeek and Kimi additionally use their native Responses endpoints.
|
||||
func (s *AccountTestService) testCNProviderAdaptiveConnection(c *gin.Context, account *Account, modelID string, prompt string) error {
|
||||
testModelID := strings.TrimSpace(modelID)
|
||||
if testModelID == "" {
|
||||
@@ -44,7 +44,7 @@ func (s *AccountTestService) testCNProviderAdaptiveConnection(c *gin.Context, ac
|
||||
return err
|
||||
}
|
||||
|
||||
if account.Platform == PlatformDeepseek {
|
||||
if account.SupportsNativeCNResponses() {
|
||||
if err := s.testCNProviderAdaptiveResponsesConnection(c, account, testModelID, authToken); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -159,8 +159,8 @@ func (s *AccountTestService) testCNProviderAdaptiveResponsesConnection(c *gin.Co
|
||||
apiURL := buildOpenAIResponsesURLForPlatform(account.Platform, baseURL)
|
||||
|
||||
payload := createOpenAITestPayload(testModelID, false)
|
||||
// DeepSeek's native Responses endpoint is stateless and does not need the
|
||||
// OpenAI probe's synthetic instructions.
|
||||
// DeepSeek / Kimi native Responses endpoints are stateless and do not need
|
||||
// the OpenAI probe's synthetic instructions.
|
||||
delete(payload, "instructions")
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
payloadBytes = normalizeDeepSeekResponsesRequestBody(account, payloadBytes)
|
||||
|
||||
@@ -83,36 +83,25 @@ data: {"type":"response.completed"}
|
||||
}
|
||||
|
||||
func TestAccountTestService_AdaptiveChatOnlyProvidersTestChatAndAnthropicEndpoints(t *testing.T) {
|
||||
for index, testCase := range []struct {
|
||||
name string
|
||||
platform string
|
||||
model string
|
||||
}{
|
||||
{name: "Kimi", platform: PlatformKimi, model: "kimi-k2.5"},
|
||||
{name: "Zhipu", platform: PlatformZhipu, model: "glm-4.7"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
account := adaptiveCNAccountTestAccount(int64(301+index), testCase.platform)
|
||||
svc, upstream := adaptiveCNAccountTestService(
|
||||
account,
|
||||
adaptiveCNChatTestResponse(),
|
||||
adaptiveCNAnthropicTestResponse(),
|
||||
)
|
||||
c, recorder := newTestContext()
|
||||
account := adaptiveCNAccountTestAccount(301, PlatformZhipu)
|
||||
svc, upstream := adaptiveCNAccountTestService(
|
||||
account,
|
||||
adaptiveCNChatTestResponse(),
|
||||
adaptiveCNAnthropicTestResponse(),
|
||||
)
|
||||
c, recorder := newTestContext()
|
||||
|
||||
err := svc.TestAccountConnection(c, account.ID, testCase.model, "hello", AccountTestModeDefault)
|
||||
err := svc.TestAccountConnection(c, account.ID, "glm-4.7", "hello", AccountTestModeDefault)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, upstream.requests, 2)
|
||||
require.Equal(t, "http://chat.example/v1/chat/completions", upstream.requests[0].URL.String())
|
||||
require.Equal(t, "http://anthropic.example/v1/messages", upstream.requests[1].URL.String())
|
||||
require.Equal(t, "Bearer sk-adaptive-test", upstream.requests[0].Header.Get("Authorization"))
|
||||
require.Equal(t, "sk-adaptive-test", upstream.requests[1].Header.Get("x-api-key"))
|
||||
require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_start"`))
|
||||
require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`))
|
||||
require.Contains(t, recorder.Body.String(), "已通过原生 /v1/messages 验证")
|
||||
})
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Len(t, upstream.requests, 2)
|
||||
require.Equal(t, "http://chat.example/v1/chat/completions", upstream.requests[0].URL.String())
|
||||
require.Equal(t, "http://anthropic.example/v1/messages", upstream.requests[1].URL.String())
|
||||
require.Equal(t, "Bearer sk-adaptive-test", upstream.requests[0].Header.Get("Authorization"))
|
||||
require.Equal(t, "sk-adaptive-test", upstream.requests[1].Header.Get("x-api-key"))
|
||||
require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_start"`))
|
||||
require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`))
|
||||
require.Contains(t, recorder.Body.String(), "已通过原生 /v1/messages 验证")
|
||||
}
|
||||
|
||||
func TestAccountTestService_AdaptiveDeepSeekAlsoTestsResponsesEndpoint(t *testing.T) {
|
||||
@@ -139,6 +128,30 @@ func TestAccountTestService_AdaptiveDeepSeekAlsoTestsResponsesEndpoint(t *testin
|
||||
require.Contains(t, recorder.Body.String(), "已通过原生 /responses 验证")
|
||||
}
|
||||
|
||||
func TestAccountTestService_AdaptiveKimiAlsoTestsResponsesEndpoint(t *testing.T) {
|
||||
account := adaptiveCNAccountTestAccount(306, PlatformKimi)
|
||||
svc, upstream := adaptiveCNAccountTestService(
|
||||
account,
|
||||
adaptiveCNChatTestResponse(),
|
||||
adaptiveCNAnthropicTestResponse(),
|
||||
adaptiveCNResponsesTestResponse(),
|
||||
)
|
||||
c, recorder := newTestContext()
|
||||
|
||||
err := svc.TestAccountConnection(c, account.ID, "k3-256k", "", AccountTestModeDefault)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, upstream.requests, 3)
|
||||
require.Equal(t, "http://responses.example/v1/responses", upstream.requests[2].URL.String())
|
||||
require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.requests[2].Context()))
|
||||
require.Equal(t, "Bearer sk-adaptive-test", upstream.requests[2].Header.Get("Authorization"))
|
||||
require.True(t, gjson.GetBytes(upstream.bodies[2], "stream").Bool())
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[2], "store").Bool())
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[2], "instructions").Exists())
|
||||
require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`))
|
||||
require.Contains(t, recorder.Body.String(), "已通过原生 /responses 验证")
|
||||
}
|
||||
|
||||
func TestAccountTestService_AdaptiveStopsAndNamesFailingEndpoint(t *testing.T) {
|
||||
account := adaptiveCNAccountTestAccount(303, PlatformDeepseek)
|
||||
svc, upstream := adaptiveCNAccountTestService(
|
||||
|
||||
@@ -116,10 +116,10 @@ func TestAdaptiveProtocolRoutesResponsesShapedChatToNativeResponses(t *testing.T
|
||||
|
||||
func TestAdaptiveProtocolConvertsResponsesShapedChatForChatOnlyProvider(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"kimi-k2.5","input":"hello","max_output_tokens":32,"stream":false}`)
|
||||
body := []byte(`{"model":"glm-4.7","input":"hello","max_output_tokens":32,"stream":false}`)
|
||||
upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
|
||||
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
|
||||
account := adaptiveProtocolTestAccount(PlatformKimi, map[string]any{
|
||||
account := adaptiveProtocolTestAccount(PlatformZhipu, map[string]any{
|
||||
APIProtocolChatCompletions: "http://chat.example",
|
||||
APIProtocolAnthropic: "http://anthropic.example",
|
||||
})
|
||||
@@ -131,6 +131,24 @@ func TestAdaptiveProtocolConvertsResponsesShapedChatForChatOnlyProvider(t *testi
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
|
||||
}
|
||||
|
||||
func TestAdaptiveProtocolRoutesKimiResponsesShapedChatToNativeResponses(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"k3-256k","input":"hello","max_output_tokens":32,"stream":false}`)
|
||||
upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
|
||||
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
|
||||
account := adaptiveProtocolTestAccount(PlatformKimi, map[string]any{
|
||||
APIProtocolChatCompletions: "http://chat.example",
|
||||
APIProtocolAnthropic: "http://anthropic.example",
|
||||
APIProtocolResponses: "http://responses.example/v1",
|
||||
})
|
||||
|
||||
_, err := svc.ForwardAsChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", body), account, body, "", "")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "http://responses.example/v1/responses", upstream.lastReq.URL.String())
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists())
|
||||
}
|
||||
|
||||
func TestAdaptiveProtocolRoutesMessagesToNativeAnthropic(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"glm-4.7","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
|
||||
@@ -147,21 +165,42 @@ func TestAdaptiveProtocolRoutesMessagesToNativeAnthropic(t *testing.T) {
|
||||
require.Equal(t, "glm-4.7", gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
}
|
||||
|
||||
func TestAdaptiveProtocolConvertsKimiResponsesToChatCompletions(t *testing.T) {
|
||||
func TestAdaptiveProtocolRoutesKimiResponsesToNativeResponses(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"kimi-k2.5","input":"hello","stream":false}`)
|
||||
body := []byte(`{"model":"k3-256k","input":"hello","store":true,"previous_response_id":"resp_old","stream":false}`)
|
||||
upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
|
||||
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
|
||||
account := adaptiveProtocolTestAccount(PlatformKimi, map[string]any{
|
||||
APIProtocolChatCompletions: "http://chat.example",
|
||||
APIProtocolAnthropic: "http://anthropic.example",
|
||||
APIProtocolResponses: "http://responses.example/v1",
|
||||
})
|
||||
|
||||
_, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "http://chat.example/v1/chat/completions", upstream.lastReq.URL.String())
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, "messages").IsArray())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
|
||||
require.Equal(t, "http://responses.example/v1/responses", upstream.lastReq.URL.String())
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "store").Bool())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists())
|
||||
}
|
||||
|
||||
func TestAdaptiveProtocolRoutesKimiCodingResponsesToNativeResponses(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"k3-256k","input":"hello","stream":false}`)
|
||||
upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
|
||||
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
|
||||
account := adaptiveProtocolTestAccount(PlatformKimi, map[string]any{
|
||||
APIProtocolChatCompletions: "https://api.kimi.com/coding/v1",
|
||||
APIProtocolAnthropic: "https://api.kimi.com/coding",
|
||||
APIProtocolResponses: "https://api.kimi.com/coding/v1",
|
||||
})
|
||||
account.Credentials["account_mode"] = AccountModeCoding
|
||||
|
||||
_, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "https://api.kimi.com/coding/v1/responses", upstream.lastReq.URL.String())
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
|
||||
}
|
||||
|
||||
func TestAdaptiveProtocolRoutesDeepSeekResponsesToNativeResponses(t *testing.T) {
|
||||
|
||||
@@ -295,6 +295,19 @@ func groupSupportsOAuthOnlyFilter(platform string) bool {
|
||||
platform == PlatformComposite
|
||||
}
|
||||
|
||||
func groupSupportsOpenAIFast(platform string) bool {
|
||||
return platform == PlatformOpenAI || platform == PlatformComposite
|
||||
}
|
||||
|
||||
func sanitizeGroupOpenAIFast(group *Group) {
|
||||
if group == nil || !groupSupportsOpenAIFast(group.Platform) {
|
||||
if group != nil {
|
||||
group.ForceOpenAIFast = false
|
||||
group.FreeOpenAIFast = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) {
|
||||
if input.RateMultiplier <= 0 {
|
||||
return nil, errors.New("rate_multiplier must be > 0")
|
||||
@@ -309,6 +322,10 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
|
||||
if err != nil {
|
||||
return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_MAX_REASONING_EFFORT", "%v", err)
|
||||
}
|
||||
maxReasoningEffortOverLimit, err := normalizeMaxReasoningEffortOverLimitForPlatform(platform, input.MaxReasoningEffortOverLimit)
|
||||
if err != nil {
|
||||
return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_MAX_REASONING_EFFORT_OVER_LIMIT", "%v", err)
|
||||
}
|
||||
reasoningEffortMappings, err := NormalizeReasoningEffortMappings(platform, input.ReasoningEffortMappings)
|
||||
if err != nil {
|
||||
return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_REASONING_EFFORT_MAPPING", "%v", err)
|
||||
@@ -500,6 +517,8 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
|
||||
SupportedModelScopes: input.SupportedModelScopes,
|
||||
AllowMessagesDispatch: input.AllowMessagesDispatch,
|
||||
AllowLive: input.AllowLive,
|
||||
ForceOpenAIFast: input.ForceOpenAIFast,
|
||||
FreeOpenAIFast: input.FreeOpenAIFast,
|
||||
RequireOAuthOnly: input.RequireOAuthOnly,
|
||||
RequirePrivacySet: input.RequirePrivacySet,
|
||||
DefaultMappedModel: input.DefaultMappedModel,
|
||||
@@ -507,9 +526,11 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
|
||||
ModelsListConfig: normalizeGroupModelsListConfig(input.ModelsListConfig),
|
||||
RPMLimit: input.RPMLimit,
|
||||
MaxReasoningEffort: maxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: maxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: reasoningEffortMappings,
|
||||
}
|
||||
sanitizeGroupMessagesDispatchFields(group)
|
||||
sanitizeGroupOpenAIFast(group)
|
||||
if group.Platform != PlatformOpenAI && group.Platform != PlatformComposite {
|
||||
group.AllowLive = false
|
||||
}
|
||||
@@ -862,6 +883,12 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
|
||||
if input.AllowLive != nil {
|
||||
group.AllowLive = *input.AllowLive
|
||||
}
|
||||
if input.ForceOpenAIFast != nil {
|
||||
group.ForceOpenAIFast = *input.ForceOpenAIFast
|
||||
}
|
||||
if input.FreeOpenAIFast != nil {
|
||||
group.FreeOpenAIFast = *input.FreeOpenAIFast
|
||||
}
|
||||
if input.RequireOAuthOnly != nil {
|
||||
group.RequireOAuthOnly = *input.RequireOAuthOnly
|
||||
}
|
||||
@@ -887,6 +914,13 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
|
||||
}
|
||||
group.MaxReasoningEffort = maxReasoningEffort
|
||||
}
|
||||
if input.MaxReasoningEffortOverLimit != nil {
|
||||
maxReasoningEffortOverLimit, err := normalizeMaxReasoningEffortOverLimitForPlatform(group.Platform, *input.MaxReasoningEffortOverLimit)
|
||||
if err != nil {
|
||||
return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_MAX_REASONING_EFFORT_OVER_LIMIT", "%v", err)
|
||||
}
|
||||
group.MaxReasoningEffortOverLimit = maxReasoningEffortOverLimit
|
||||
}
|
||||
if input.ReasoningEffortMappings != nil {
|
||||
reasoningEffortMappings, err := NormalizeReasoningEffortMappings(group.Platform, *input.ReasoningEffortMappings)
|
||||
if err != nil {
|
||||
@@ -895,6 +929,7 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
|
||||
group.ReasoningEffortMappings = reasoningEffortMappings
|
||||
}
|
||||
sanitizeGroupMessagesDispatchFields(group)
|
||||
sanitizeGroupOpenAIFast(group)
|
||||
if group.Platform != PlatformOpenAI && group.Platform != PlatformComposite {
|
||||
group.AllowLive = false
|
||||
}
|
||||
|
||||
@@ -144,6 +144,8 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group {
|
||||
SortOrder: source.SortOrder,
|
||||
AllowMessagesDispatch: source.AllowMessagesDispatch,
|
||||
AllowLive: source.AllowLive,
|
||||
ForceOpenAIFast: source.ForceOpenAIFast,
|
||||
FreeOpenAIFast: source.FreeOpenAIFast,
|
||||
RequireOAuthOnly: source.RequireOAuthOnly,
|
||||
RequirePrivacySet: source.RequirePrivacySet,
|
||||
DefaultMappedModel: source.DefaultMappedModel,
|
||||
@@ -152,9 +154,10 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group {
|
||||
Enabled: source.ModelsListConfig.Enabled,
|
||||
Models: append([]string(nil), source.ModelsListConfig.Models...),
|
||||
},
|
||||
RPMLimit: source.RPMLimit,
|
||||
MaxReasoningEffort: source.MaxReasoningEffort,
|
||||
ReasoningEffortMappings: append([]ReasoningEffortMapping(nil), source.ReasoningEffortMappings...),
|
||||
RPMLimit: source.RPMLimit,
|
||||
MaxReasoningEffort: source.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: source.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: append([]ReasoningEffortMapping(nil), source.ReasoningEffortMappings...),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,8 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing
|
||||
SortOrder: 9,
|
||||
AllowMessagesDispatch: true,
|
||||
AllowLive: true,
|
||||
ForceOpenAIFast: true,
|
||||
FreeOpenAIFast: true,
|
||||
RequireOAuthOnly: true,
|
||||
RequirePrivacySet: true,
|
||||
DefaultMappedModel: "gpt-5.4",
|
||||
@@ -175,17 +177,18 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing
|
||||
HaikuMappedModel: "gpt-5-mini",
|
||||
ExactModelMappings: map[string]string{"claude-special": "gpt-special"},
|
||||
},
|
||||
ModelsListConfig: GroupModelsListConfig{Enabled: true, Models: []string{"gpt-5.4", "gpt-5-mini"}},
|
||||
RPMLimit: 99,
|
||||
MaxReasoningEffort: "medium",
|
||||
ReasoningEffortMappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}},
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
AccountCount: 12,
|
||||
ActiveAccountCount: 8,
|
||||
RateLimitedAccountCount: 2,
|
||||
DuplicateOperationID: "old-operation-must-not-copy",
|
||||
AccountGroups: []AccountGroup{{AccountID: 13, GroupID: 41, Priority: 37}},
|
||||
ModelsListConfig: GroupModelsListConfig{Enabled: true, Models: []string{"gpt-5.4", "gpt-5-mini"}},
|
||||
RPMLimit: 99,
|
||||
MaxReasoningEffort: "medium",
|
||||
MaxReasoningEffortOverLimit: ReasoningEffortOverLimitDeny,
|
||||
ReasoningEffortMappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}},
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
AccountCount: 12,
|
||||
ActiveAccountCount: 8,
|
||||
RateLimitedAccountCount: 2,
|
||||
DuplicateOperationID: "old-operation-must-not-copy",
|
||||
AccountGroups: []AccountGroup{{AccountID: 13, GroupID: 41, Priority: 37}},
|
||||
}
|
||||
repo := newDuplicateGroupRepoStub(source)
|
||||
repo.sourceBindings[source.ID] = []AccountGroup{
|
||||
@@ -212,9 +215,12 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing
|
||||
require.Equal(t, source.FallbackGroupID, duplicate.FallbackGroupID)
|
||||
require.Equal(t, source.ModelRouting, duplicate.ModelRouting)
|
||||
require.Equal(t, source.MessagesDispatchModelConfig, duplicate.MessagesDispatchModelConfig)
|
||||
require.Equal(t, source.ForceOpenAIFast, duplicate.ForceOpenAIFast)
|
||||
require.Equal(t, source.FreeOpenAIFast, duplicate.FreeOpenAIFast)
|
||||
require.Equal(t, source.ModelsListConfig, duplicate.ModelsListConfig)
|
||||
require.Equal(t, source.RPMLimit, duplicate.RPMLimit)
|
||||
require.Equal(t, source.MaxReasoningEffort, duplicate.MaxReasoningEffort)
|
||||
require.Equal(t, source.MaxReasoningEffortOverLimit, duplicate.MaxReasoningEffortOverLimit)
|
||||
require.Equal(t, source.ReasoningEffortMappings, duplicate.ReasoningEffortMappings)
|
||||
require.EqualValues(t, 2, duplicate.AccountCount)
|
||||
require.EqualValues(t, 2, duplicate.ActiveAccountCount)
|
||||
|
||||
@@ -266,6 +266,8 @@ type CreateGroupInput struct {
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch bool
|
||||
AllowLive bool
|
||||
ForceOpenAIFast bool
|
||||
FreeOpenAIFast bool
|
||||
DefaultMappedModel string
|
||||
RequireOAuthOnly bool
|
||||
RequirePrivacySet bool
|
||||
@@ -275,6 +277,8 @@ type CreateGroupInput struct {
|
||||
RPMLimit int
|
||||
// MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string
|
||||
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string
|
||||
// ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。
|
||||
ReasoningEffortMappings []ReasoningEffortMapping
|
||||
// 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数,nil 按 0 处理)
|
||||
@@ -341,6 +345,8 @@ type UpdateGroupInput struct {
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch *bool
|
||||
AllowLive *bool
|
||||
ForceOpenAIFast *bool
|
||||
FreeOpenAIFast *bool
|
||||
DefaultMappedModel *string
|
||||
RequireOAuthOnly *bool
|
||||
RequirePrivacySet *bool
|
||||
@@ -350,6 +356,8 @@ type UpdateGroupInput struct {
|
||||
RPMLimit *int
|
||||
// MaxReasoningEffort 空字符串表示清除上限;nil 表示未提供不改动。
|
||||
MaxReasoningEffort *string
|
||||
// MaxReasoningEffortOverLimit 空字符串视为 downgrade;nil 表示未提供不改动。
|
||||
MaxReasoningEffortOverLimit *string
|
||||
// ReasoningEffortMappings nil 表示不修改,空数组表示清空,非空数组表示替换。
|
||||
ReasoningEffortMappings *[]ReasoningEffortMapping
|
||||
// 分组利润控制(nil 表示不修改;margin/buffer 为小数)
|
||||
|
||||
@@ -42,10 +42,11 @@ func TestAdminService_CreateCompositeGroupCopiesAccountsFromConcreteGroups(t *te
|
||||
svc := &adminServiceImpl{groupRepo: groupRepo}
|
||||
|
||||
group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{
|
||||
Name: "Composite",
|
||||
Platform: PlatformComposite,
|
||||
RateMultiplier: 1,
|
||||
MaxReasoningEffort: "medium",
|
||||
Name: "Composite",
|
||||
Platform: PlatformComposite,
|
||||
RateMultiplier: 1,
|
||||
MaxReasoningEffort: "medium",
|
||||
MaxReasoningEffortOverLimit: ReasoningEffortOverLimitDeny,
|
||||
ReasoningEffortMappings: []ReasoningEffortMapping{
|
||||
{From: "max", To: "xhigh"},
|
||||
},
|
||||
@@ -55,6 +56,7 @@ func TestAdminService_CreateCompositeGroupCopiesAccountsFromConcreteGroups(t *te
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, PlatformComposite, groupRepo.created.Platform)
|
||||
require.Equal(t, "medium", groupRepo.created.MaxReasoningEffort)
|
||||
require.Equal(t, ReasoningEffortOverLimitDeny, groupRepo.created.MaxReasoningEffortOverLimit)
|
||||
require.Equal(t, []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, groupRepo.created.ReasoningEffortMappings)
|
||||
require.Equal(t, int64(99), group.ID)
|
||||
require.Equal(t, int64(2), group.AccountCount)
|
||||
@@ -90,17 +92,20 @@ func TestAdminService_UpdateCompositeGroupCopiesAccountsFromConcreteGroups(t *te
|
||||
}
|
||||
svc := &adminServiceImpl{groupRepo: groupRepo}
|
||||
maxReasoningEffort := "low"
|
||||
maxReasoningEffortOverLimit := ReasoningEffortOverLimitDeny
|
||||
reasoningEffortMappings := []ReasoningEffortMapping{{From: "max", To: "high"}}
|
||||
|
||||
group, err := svc.UpdateGroup(context.Background(), 99, &UpdateGroupInput{
|
||||
MaxReasoningEffort: &maxReasoningEffort,
|
||||
ReasoningEffortMappings: &reasoningEffortMappings,
|
||||
CopyAccountsFromGroupIDs: []int64{10, 20},
|
||||
MaxReasoningEffort: &maxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: &maxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: &reasoningEffortMappings,
|
||||
CopyAccountsFromGroupIDs: []int64{10, 20},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, PlatformComposite, group.Platform)
|
||||
require.Equal(t, "low", group.MaxReasoningEffort)
|
||||
require.Equal(t, ReasoningEffortOverLimitDeny, group.MaxReasoningEffortOverLimit)
|
||||
require.Equal(t, reasoningEffortMappings, group.ReasoningEffortMappings)
|
||||
require.Equal(t, int64(99), clearedGroupID)
|
||||
require.ElementsMatch(t, []int64{10, 20}, copiedFrom)
|
||||
|
||||
@@ -949,12 +949,13 @@ func TestAdminService_UpdateGroup_RejectsInvalidReasoningEffortMappings(t *testi
|
||||
|
||||
func TestAdminService_UpdateGroup_ClearsReasoningPolicyForUnsupportedPlatform(t *testing.T) {
|
||||
existing := &Group{
|
||||
ID: 1,
|
||||
Name: "openai-group",
|
||||
Platform: PlatformOpenAI,
|
||||
Status: StatusActive,
|
||||
MaxReasoningEffort: "medium",
|
||||
ReasoningEffortMappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}},
|
||||
ID: 1,
|
||||
Name: "openai-group",
|
||||
Platform: PlatformOpenAI,
|
||||
Status: StatusActive,
|
||||
MaxReasoningEffort: "medium",
|
||||
MaxReasoningEffortOverLimit: ReasoningEffortOverLimitDeny,
|
||||
ReasoningEffortMappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}},
|
||||
}
|
||||
repo := &groupRepoStubForAdmin{getByID: existing}
|
||||
svc := &adminServiceImpl{groupRepo: repo}
|
||||
@@ -963,6 +964,7 @@ func TestAdminService_UpdateGroup_ClearsReasoningPolicyForUnsupportedPlatform(t
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, repo.updated.MaxReasoningEffort)
|
||||
require.Equal(t, ReasoningEffortOverLimitDowngrade, repo.updated.MaxReasoningEffortOverLimit)
|
||||
require.Empty(t, repo.updated.ReasoningEffortMappings)
|
||||
}
|
||||
|
||||
@@ -1096,6 +1098,70 @@ func TestAdminService_CreateCompositeGroupPreservesLive(t *testing.T) {
|
||||
require.True(t, repo.created.AllowLive)
|
||||
}
|
||||
|
||||
func TestAdminService_CreateGroup_NormalizesForceOpenAIFastByPlatform(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
platform string
|
||||
want bool
|
||||
}{
|
||||
{name: "openai", platform: PlatformOpenAI, want: true},
|
||||
{name: "composite", platform: PlatformComposite, want: true},
|
||||
{name: "anthropic", platform: PlatformAnthropic, want: false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repo := &groupRepoStubForAdmin{}
|
||||
svc := &adminServiceImpl{groupRepo: repo}
|
||||
|
||||
group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{
|
||||
Name: "fast-" + tt.name, Platform: tt.platform, RateMultiplier: 1, ForceOpenAIFast: true, FreeOpenAIFast: true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, group)
|
||||
require.Equal(t, tt.want, repo.created.ForceOpenAIFast)
|
||||
require.Equal(t, tt.want, repo.created.FreeOpenAIFast)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminService_UpdateGroup_ClearsForceOpenAIFastWhenPlatformChanges(t *testing.T) {
|
||||
existingGroup := &Group{
|
||||
ID: 1, Name: "existing-fast", Platform: PlatformOpenAI, Status: StatusActive, ForceOpenAIFast: true, FreeOpenAIFast: true,
|
||||
}
|
||||
repo := &groupRepoStubForAdmin{getByID: existingGroup}
|
||||
svc := &adminServiceImpl{groupRepo: repo}
|
||||
|
||||
group, err := svc.UpdateGroup(context.Background(), existingGroup.ID, &UpdateGroupInput{
|
||||
Platform: PlatformAnthropic,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, group)
|
||||
require.False(t, repo.updated.ForceOpenAIFast)
|
||||
require.False(t, repo.updated.FreeOpenAIFast)
|
||||
}
|
||||
|
||||
func TestAdminService_UpdateGroup_ForceOpenAIFastInvalidatesAuthCache(t *testing.T) {
|
||||
existingGroup := &Group{
|
||||
ID: 1, Name: "existing-fast", Platform: PlatformOpenAI, Status: StatusActive,
|
||||
}
|
||||
repo := &groupRepoStubForAdmin{getByID: existingGroup}
|
||||
invalidator := &authCacheInvalidatorStub{}
|
||||
svc := &adminServiceImpl{groupRepo: repo, authCacheInvalidator: invalidator}
|
||||
enabled := true
|
||||
|
||||
group, err := svc.UpdateGroup(context.Background(), existingGroup.ID, &UpdateGroupInput{
|
||||
ForceOpenAIFast: &enabled,
|
||||
FreeOpenAIFast: &enabled,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, group)
|
||||
require.True(t, repo.updated.ForceOpenAIFast)
|
||||
require.True(t, repo.updated.FreeOpenAIFast)
|
||||
require.Equal(t, []int64{existingGroup.ID}, invalidator.groupIDs)
|
||||
}
|
||||
|
||||
func TestAdminService_UpdateCompositeGroupPreservesLive(t *testing.T) {
|
||||
existingGroup := &Group{
|
||||
ID: 1,
|
||||
|
||||
@@ -76,6 +76,12 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) {
|
||||
},
|
||||
|
||||
// 3. 默认映射中的透传(映射到自己)
|
||||
{
|
||||
name: "默认映射透传 - claude-fable-5-1",
|
||||
requestedModel: "claude-fable-5-1",
|
||||
accountMapping: nil,
|
||||
expected: "claude-fable-5-1",
|
||||
},
|
||||
{
|
||||
name: "默认映射透传 - claude-fable-5",
|
||||
requestedModel: "claude-fable-5",
|
||||
@@ -233,6 +239,7 @@ func TestAntigravityGatewayService_IsModelSupported(t *testing.T) {
|
||||
expected bool
|
||||
}{
|
||||
// 直接支持
|
||||
{"直接支持 - claude-fable-5-1", "claude-fable-5-1", true},
|
||||
{"直接支持 - claude-fable-5", "claude-fable-5", true},
|
||||
{"直接支持 - claude-sonnet-4-5", "claude-sonnet-4-5", true},
|
||||
{"直接支持 - gemini-3-flash", "gemini-3-flash", true},
|
||||
|
||||
@@ -103,6 +103,8 @@ type APIKeyAuthGroupSnapshot struct {
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch bool `json:"allow_messages_dispatch"`
|
||||
AllowLive bool `json:"allow_live"`
|
||||
ForceOpenAIFast bool `json:"force_openai_fast"`
|
||||
FreeOpenAIFast bool `json:"free_openai_fast"`
|
||||
DefaultMappedModel string `json:"default_mapped_model,omitempty"`
|
||||
MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"`
|
||||
ModelsListConfig GroupModelsListConfig `json:"models_list_config,omitempty"`
|
||||
@@ -112,6 +114,8 @@ type APIKeyAuthGroupSnapshot struct {
|
||||
|
||||
// MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"`
|
||||
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit,omitempty"`
|
||||
// ReasoningEffortMappings rewrites explicit effort values before the ceiling.
|
||||
ReasoningEffortMappings []ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/dgraph-io/ristretto"
|
||||
)
|
||||
|
||||
const apiKeyAuthSnapshotVersion = 20 // v20: group long-context and model pricing fields (force refresh of pre-fix snapshots)
|
||||
const apiKeyAuthSnapshotVersion = 22 // v22: group free_openai_fast field
|
||||
|
||||
type apiKeyAuthCacheConfig struct {
|
||||
l1Size int
|
||||
@@ -418,11 +418,14 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey)
|
||||
SupportedModelScopes: apiKey.Group.SupportedModelScopes,
|
||||
AllowMessagesDispatch: apiKey.Group.AllowMessagesDispatch,
|
||||
AllowLive: apiKey.Group.AllowLive,
|
||||
ForceOpenAIFast: apiKey.Group.ForceOpenAIFast,
|
||||
FreeOpenAIFast: apiKey.Group.FreeOpenAIFast,
|
||||
DefaultMappedModel: apiKey.Group.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: apiKey.Group.MessagesDispatchModelConfig,
|
||||
ModelsListConfig: apiKey.Group.ModelsListConfig,
|
||||
RPMLimit: apiKey.Group.RPMLimit,
|
||||
MaxReasoningEffort: apiKey.Group.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: apiKey.Group.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: apiKey.Group.ReasoningEffortMappings,
|
||||
PeakRateEnabled: apiKey.Group.PeakRateEnabled,
|
||||
PeakStart: apiKey.Group.PeakStart,
|
||||
@@ -516,11 +519,14 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
|
||||
SupportedModelScopes: snapshot.Group.SupportedModelScopes,
|
||||
AllowMessagesDispatch: snapshot.Group.AllowMessagesDispatch,
|
||||
AllowLive: snapshot.Group.AllowLive,
|
||||
ForceOpenAIFast: snapshot.Group.ForceOpenAIFast,
|
||||
FreeOpenAIFast: snapshot.Group.FreeOpenAIFast,
|
||||
DefaultMappedModel: snapshot.Group.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: snapshot.Group.MessagesDispatchModelConfig,
|
||||
ModelsListConfig: snapshot.Group.ModelsListConfig,
|
||||
RPMLimit: snapshot.Group.RPMLimit,
|
||||
MaxReasoningEffort: snapshot.Group.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: snapshot.Group.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: snapshot.Group.ReasoningEffortMappings,
|
||||
PeakRateEnabled: snapshot.Group.PeakRateEnabled,
|
||||
PeakStart: snapshot.Group.PeakStart,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAPIKeyAuthSnapshotGroupForceOpenAIFastRoundtrip(t *testing.T) {
|
||||
groupID := int64(50)
|
||||
apiKey := &APIKey{
|
||||
ID: 82, UserID: 40, GroupID: &groupID, Key: "sk-fast-roundtrip", Status: StatusActive,
|
||||
User: &User{ID: 40, Status: StatusActive},
|
||||
Group: &Group{
|
||||
ID: groupID, Name: "fast-roundtrip", Platform: PlatformOpenAI, Status: StatusActive,
|
||||
Hydrated: true, ForceOpenAIFast: true, FreeOpenAIFast: true,
|
||||
},
|
||||
}
|
||||
svc := &APIKeyService{}
|
||||
|
||||
payload, err := json.Marshal(&APIKeyAuthCacheEntry{Snapshot: svc.snapshotFromAPIKey(context.Background(), apiKey)})
|
||||
require.NoError(t, err)
|
||||
var cached APIKeyAuthCacheEntry
|
||||
require.NoError(t, json.Unmarshal(payload, &cached))
|
||||
|
||||
materialized, used, err := svc.applyAuthCacheEntry(apiKey.Key, &cached)
|
||||
require.NoError(t, err)
|
||||
require.True(t, used)
|
||||
require.NotNil(t, materialized.Group)
|
||||
require.True(t, materialized.Group.Hydrated)
|
||||
require.True(t, materialized.Group.ForceOpenAIFast)
|
||||
require.True(t, materialized.Group.FreeOpenAIFast)
|
||||
require.Equal(t, 22, cached.Snapshot.Version)
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func TestAPIKeyAuthSnapshotProfitControlRoundtrip(t *testing.T) {
|
||||
snapshot := svc.snapshotFromAPIKey(context.Background(), apiKey)
|
||||
require.NotNil(t, snapshot)
|
||||
require.Equal(t, apiKeyAuthSnapshotVersion, snapshot.Version)
|
||||
require.Equal(t, 20, snapshot.Version, "v20 起认证快照携带分组长上下文与模型定价字段")
|
||||
require.Equal(t, 22, snapshot.Version, "v22 起认证快照携带分组免费 Fast 开关")
|
||||
|
||||
// 模拟 L2 缓存的完整 JSON 往返(与 apiKeyCache.SetAuthCache/GetAuthCache 同构)。
|
||||
payload, err := json.Marshal(&APIKeyAuthCacheEntry{Snapshot: snapshot})
|
||||
|
||||
@@ -295,13 +295,14 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesReasoningEffortPolicy(t *testi
|
||||
Concurrency: 3,
|
||||
},
|
||||
Group: &Group{
|
||||
ID: groupID,
|
||||
Name: "composite",
|
||||
Platform: PlatformComposite,
|
||||
Status: StatusActive,
|
||||
SubscriptionType: SubscriptionTypeStandard,
|
||||
RateMultiplier: 1,
|
||||
MaxReasoningEffort: "medium",
|
||||
ID: groupID,
|
||||
Name: "composite",
|
||||
Platform: PlatformComposite,
|
||||
Status: StatusActive,
|
||||
SubscriptionType: SubscriptionTypeStandard,
|
||||
RateMultiplier: 1,
|
||||
MaxReasoningEffort: "medium",
|
||||
MaxReasoningEffortOverLimit: ReasoningEffortOverLimitDeny,
|
||||
ReasoningEffortMappings: []ReasoningEffortMapping{
|
||||
{From: "max", To: "xhigh"},
|
||||
},
|
||||
@@ -315,6 +316,7 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesReasoningEffortPolicy(t *testi
|
||||
require.NotNil(t, roundTrip.Group)
|
||||
require.Equal(t, PlatformComposite, roundTrip.Group.Platform)
|
||||
require.Equal(t, "medium", roundTrip.Group.MaxReasoningEffort)
|
||||
require.Equal(t, ReasoningEffortOverLimitDeny, roundTrip.Group.MaxReasoningEffortOverLimit)
|
||||
require.Equal(t, apiKey.Group.ReasoningEffortMappings, roundTrip.Group.ReasoningEffortMappings)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
@@ -638,10 +639,26 @@ func filterBedrockBetaTokens(tokens []string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// sanitizeBedrockFieldsForBetaTokens 与直连路径的 sanitizeAnthropicBodyForBetaTokens
|
||||
// 对称:按最终 Bedrock beta tokens 决定是否保留 body 中的 beta 字段。
|
||||
// - context_management 缺 context-management beta → strip
|
||||
// - fallbacks 缺 server-side-fallback beta → strip
|
||||
// - fallback_credit_token 缺 server-side-fallback / 任一 fallback-credit beta → strip
|
||||
//
|
||||
// 注意:fallback beta token 均不在 bedrockSupportedBetaTokens 白名单内
|
||||
// (会被 filterBedrockBetaTokens 过滤掉),因此条件 strip 实际总会剥除——这是预期:
|
||||
// Bedrock Invoke 不支持这些 beta 字段。
|
||||
func sanitizeBedrockFieldsForBetaTokens(body []byte, betaTokens []string) []byte {
|
||||
if !containsBedrockBetaToken(betaTokens, bedrockContextManagementBetaToken) && gjson.GetBytes(body, "context_management").Exists() {
|
||||
body, _ = sjson.DeleteBytes(body, "context_management")
|
||||
}
|
||||
if !containsBedrockBetaToken(betaTokens, claude.BetaServerSideFallback) && gjson.GetBytes(body, "fallbacks").Exists() {
|
||||
body, _ = sjson.DeleteBytes(body, "fallbacks")
|
||||
}
|
||||
if !containsAnyBedrockBetaToken(betaTokens, claude.BetaServerSideFallback, claude.BetaFallbackCredit, claude.BetaFallbackCreditLegacy) &&
|
||||
gjson.GetBytes(body, "fallback_credit_token").Exists() {
|
||||
body, _ = sjson.DeleteBytes(body, "fallback_credit_token")
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -654,6 +671,16 @@ func containsBedrockBetaToken(tokens []string, target string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// containsAnyBedrockBetaToken 判断 tokens 是否包含 targets 中的任意一个 token。
|
||||
func containsAnyBedrockBetaToken(tokens []string, targets ...string) bool {
|
||||
for _, target := range targets {
|
||||
if containsBedrockBetaToken(tokens, target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// bedrockToolUseIDRe 匹配 Bedrock 允许的 tool_use ID 字符(字母、数字、下划线、连字符)
|
||||
var bedrockToolUseIDRe = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
|
||||
|
||||
@@ -760,6 +787,8 @@ const defaultCCMaxTokens = 81920
|
||||
// - 移除 service_tier(Anthropic API 专有,Bedrock 不支持)
|
||||
// - 移除 interface_geo(Anthropic API 专有,Bedrock 不支持)
|
||||
// - 移除 context_management(Anthropic API 专有,Bedrock 不支持,CC v2.1.87+ 默认携带)
|
||||
// - 无条件移除 fallbacks / fallback_credit_token(server-side refusal fallback,
|
||||
// Anthropic 直连 beta API 专有;Bedrock Invoke 无对应 beta,永不支持)
|
||||
// - 注入 max_tokens 默认值 81920(CC 可能省略,Bedrock 要求必须提供)
|
||||
// - 注入 anthropic_version(CC 通过 HTTP 头发送,Bedrock 需要放在请求体中)
|
||||
func sanitizeBedrockCCFields(body []byte) []byte {
|
||||
@@ -772,6 +801,12 @@ func sanitizeBedrockCCFields(body []byte) []byte {
|
||||
if gjson.GetBytes(body, "context_management").Exists() {
|
||||
body, _ = sjson.DeleteBytes(body, "context_management")
|
||||
}
|
||||
if gjson.GetBytes(body, "fallbacks").Exists() {
|
||||
body, _ = sjson.DeleteBytes(body, "fallbacks")
|
||||
}
|
||||
if gjson.GetBytes(body, "fallback_credit_token").Exists() {
|
||||
body, _ = sjson.DeleteBytes(body, "fallback_credit_token")
|
||||
}
|
||||
if !gjson.GetBytes(body, "max_tokens").Exists() {
|
||||
body, _ = sjson.SetBytes(body, "max_tokens", defaultCCMaxTokens)
|
||||
}
|
||||
|
||||
@@ -541,6 +541,20 @@ func TestResolveBedrockModelID(t *testing.T) {
|
||||
assert.Equal(t, "anthropic.claude-fable-5", modelID)
|
||||
})
|
||||
|
||||
t.Run("默认 Fable 5.1 映射使用官方 Bedrock 模型 ID", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeBedrock,
|
||||
Credentials: map[string]any{
|
||||
"aws_region": "eu-west-1",
|
||||
},
|
||||
}
|
||||
|
||||
modelID, ok := ResolveBedrockModelID(account, "claude-fable-5-1")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "anthropic.claude-fable-5-1", modelID)
|
||||
})
|
||||
|
||||
t.Run("force global rewrites anthropic regional model id", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
|
||||
@@ -9,15 +9,12 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ContextPricingBasis 阶梯的计价基准。
|
||||
// ContextPricingBasis 阶梯的计价基准。当前只有整单口径;历史上的
|
||||
// Gemini 边际口径("marginal")已随平台旧规则一并移除。
|
||||
type ContextPricingBasis string
|
||||
|
||||
const (
|
||||
// ContextPricingBasisWholeRequest 整单按所在档单价计价(目录阶梯、渠道区间)。
|
||||
ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request"
|
||||
// ContextPricingBasisMarginal 仅超出阈值的部分按该档单价计价(平台旧规则)。
|
||||
ContextPricingBasisMarginal ContextPricingBasis = "marginal"
|
||||
)
|
||||
// ContextPricingBasisWholeRequest 整单按所在档单价计价(目录阶梯、渠道区间)。
|
||||
const ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request"
|
||||
|
||||
// ContextPricingTier (MinTokens, MaxTokens] 区间内的有效 per-token 单价(USD)。
|
||||
// nil 表示该项无价/不计费;MaxTokens 为 nil 表示无上限。
|
||||
@@ -73,8 +70,8 @@ const contextProbeDelta = 1000
|
||||
// ResolveContextPricingSchedule 解析分组+模型的上下文阶梯单价表。
|
||||
//
|
||||
// 解析链与扣费完全一致:Resolver.Resolve(分组卡 → 渠道 → 目录 → 策略)给出定价,
|
||||
// CalculateTokenCostForRequest 给出路径(分组/渠道定价 → 平台旧规则 → 内置目录)。
|
||||
// 断点只取自计费自身的规则输入(渠道区间边界、目录阶梯阈值、旧规则阈值),
|
||||
// CalculateTokenCostForRequest 给出路径(分组/渠道定价 → 内置目录)。
|
||||
// 断点只取自计费自身的规则输入(渠道区间边界、目录阶梯阈值),
|
||||
// 每一段的单价由真实计费函数在该段内两点探针的差商得到,因此倍率、策略等
|
||||
// 规则变更无需同步到这里;相邻同价段会合并。
|
||||
//
|
||||
@@ -103,22 +100,13 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var legacy *LegacyLongContextRule
|
||||
if in.Group != nil {
|
||||
legacy = s.LegacyLongContextRule(in.Platform)
|
||||
}
|
||||
if !legacyLongContextApplies(resolved, in.Group, legacy) {
|
||||
legacy = nil
|
||||
}
|
||||
|
||||
req := TokenCostRequest{
|
||||
Ctx: ctx,
|
||||
Model: in.Model,
|
||||
Group: in.Group,
|
||||
RateMultiplier: 1,
|
||||
Resolver: resolver,
|
||||
Resolved: resolved,
|
||||
LegacyLongContext: legacy,
|
||||
Ctx: ctx,
|
||||
Model: in.Model,
|
||||
Group: in.Group,
|
||||
RateMultiplier: 1,
|
||||
Resolver: resolver,
|
||||
Resolved: resolved,
|
||||
}
|
||||
probe := func(tokens UsageTokens) (*CostBreakdown, error) {
|
||||
r := req
|
||||
@@ -126,7 +114,7 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso
|
||||
return s.CalculateTokenCostForRequest(r)
|
||||
}
|
||||
|
||||
plan := s.contextPricingBreakpoints(resolver, resolved, in.Model, legacy)
|
||||
plan := s.contextPricingBreakpoints(resolver, resolved, in.Model)
|
||||
segments := buildContextSegments(plan.bounds)
|
||||
|
||||
tiers := make([]ContextPricingTier, 0, len(segments))
|
||||
@@ -140,11 +128,7 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso
|
||||
tiers = mergeEqualContextTiers(tiers)
|
||||
applyContextTierLabels(tiers, plan)
|
||||
|
||||
basis := ContextPricingBasisWholeRequest
|
||||
if legacy != nil {
|
||||
basis = ContextPricingBasisMarginal
|
||||
}
|
||||
return &ContextPricingSchedule{Basis: basis, Tiers: tiers, TimePricing: resolvedTimePricingSchedule(resolved)}, nil
|
||||
return &ContextPricingSchedule{Basis: ContextPricingBasisWholeRequest, Tiers: tiers, TimePricing: resolvedTimePricingSchedule(resolved)}, nil
|
||||
}
|
||||
|
||||
// resolvedTimePricingSchedule 列出计费会生效的分时倍率时段。
|
||||
@@ -210,14 +194,8 @@ type contextBreakpointPlan struct {
|
||||
}
|
||||
|
||||
// contextPricingBreakpoints 从计费自身的规则输入收集价格断点(不读取任何倍率)。
|
||||
func (s *BillingService) contextPricingBreakpoints(resolver *ModelPricingResolver, resolved *ResolvedPricing, model string, legacy *LegacyLongContextRule) contextBreakpointPlan {
|
||||
func (s *BillingService) contextPricingBreakpoints(resolver *ModelPricingResolver, resolved *ResolvedPricing, model string) contextBreakpointPlan {
|
||||
plan := contextBreakpointPlan{}
|
||||
if legacy != nil {
|
||||
plan.bounds = []int{legacy.Threshold}
|
||||
plan.thresholdBound = legacy.Threshold
|
||||
plan.threshold = legacy.Threshold
|
||||
return plan
|
||||
}
|
||||
if !resolved.longContextPricingEnabled {
|
||||
return plan
|
||||
}
|
||||
|
||||
@@ -69,12 +69,25 @@ func requirePrice(t *testing.T, want, got *float64, field string) {
|
||||
require.InDelta(t, *want, *got, 1e-15, field)
|
||||
}
|
||||
|
||||
// mustCatalogFromJSON 走生产解析路径(含 above_XXXk 折算)构造目录 stub;场景表无 *testing.T,解析失败直接 panic。
|
||||
func mustCatalogFromJSON(body string) *PricingService {
|
||||
s := &PricingService{}
|
||||
data, err := s.parsePricingData([]byte(body))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
s.pricingData = data
|
||||
return s
|
||||
}
|
||||
|
||||
func openAILadderCatalog() *PricingService { return mustCatalogFromJSON(openAILadderCatalogJSON) }
|
||||
|
||||
func scheduleScenarios() []scheduleScenario {
|
||||
p := testPtrFloat64
|
||||
return []scheduleScenario{
|
||||
{
|
||||
name: "官方阶梯 gpt-5.4 整单两档", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
|
||||
group: enabledGroup(PlatformOpenAI), wantBasis: ContextPricingBasisWholeRequest,
|
||||
group: enabledGroup(PlatformOpenAI), catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 2)
|
||||
requireTier(t, s.Tiers[0], 0, intPtr(272000), "≤272K", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6))
|
||||
@@ -92,7 +105,7 @@ func scheduleScenarios() []scheduleScenario {
|
||||
},
|
||||
{
|
||||
name: "分组关闭阶梯只剩基础档", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
|
||||
group: disabledGroup(PlatformOpenAI), wantBasis: ContextPricingBasisWholeRequest,
|
||||
group: disabledGroup(PlatformOpenAI), catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 1)
|
||||
requireTier(t, s.Tiers[0], 0, nil, "", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6))
|
||||
@@ -100,7 +113,7 @@ func scheduleScenarios() []scheduleScenario {
|
||||
},
|
||||
{
|
||||
name: "官方参考价(无分组)带目录阶梯", model: "gpt-5.4", platform: "", groupPlatform: PlatformOpenAI,
|
||||
group: nil, wantBasis: ContextPricingBasisWholeRequest,
|
||||
group: nil, catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 2)
|
||||
requireTier(t, s.Tiers[1], 272000, nil, ">272K", p(5e-6), p(22.5e-6), p(5e-6), p(0.5e-6))
|
||||
@@ -210,6 +223,7 @@ func scheduleScenarios() []scheduleScenario {
|
||||
group: &Group{ID: 100, Platform: PlatformOpenAI, LongContextPricingEnabled: true, ModelPricing: []ChannelModelPricing{{
|
||||
Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6),
|
||||
}}},
|
||||
catalog: openAILadderCatalog(),
|
||||
wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 2)
|
||||
@@ -219,46 +233,56 @@ func scheduleScenarios() []scheduleScenario {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Gemini 旧规则按超出部分计价", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||||
group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisMarginal,
|
||||
name: "Gemini 目录阶梯整单换档", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||||
group: enabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 2)
|
||||
requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(1.25e-6), p(10e-6), nil, p(0.3125e-6))
|
||||
requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(10e-6), nil, p(0.625e-6))
|
||||
// 缓存写入按标准输入价,与 input 一同整单换档
|
||||
requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(1.25e-6), p(10e-6), p(1.25e-6), p(0.125e-6))
|
||||
requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Gemini 分组关闭时不用旧规则", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||||
group: disabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest,
|
||||
name: "Gemini 分组关闭时无阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||||
group: disabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Gemini 有渠道定价时旧规则让位", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||||
group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(),
|
||||
name: "Gemini 渠道平价之上叠加目录阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||||
group: enabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON),
|
||||
channel: []ChannelModelPricing{{
|
||||
Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken, InputPrice: p(3e-6),
|
||||
}},
|
||||
wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 1)
|
||||
require.Len(t, s.Tiers, 2)
|
||||
requirePrice(t, p(3e-6), s.Tiers[0].Input, "input")
|
||||
requirePrice(t, p(6e-6), s.Tiers[1].Input, "input")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Gemini 官方参考不套用站内旧规则", model: "gemini-2.5-pro", platform: "", groupPlatform: PlatformGemini,
|
||||
group: nil, catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest,
|
||||
name: "Gemini 目录无阶梯字段时开关开启也无阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||||
group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Gemini 官方参考价与目录数据同源", model: "gemini-2.5-pro", platform: "", groupPlatform: PlatformGemini,
|
||||
group: nil, catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 2)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "composite 分组按模型平台取渠道定价并叠加官方阶梯", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformComposite,
|
||||
group: enabledGroup(PlatformComposite),
|
||||
channel: []ChannelModelPricing{{
|
||||
Platform: PlatformOpenAI, Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6),
|
||||
}},
|
||||
catalog: openAILadderCatalog(),
|
||||
wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 2)
|
||||
@@ -283,9 +307,11 @@ func scheduleScenarios() []scheduleScenario {
|
||||
{
|
||||
name: "gpt-5.6 缺 cache_write 时按策略补 1.25 倍并带阶梯", model: "gpt-5.6-sol", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
|
||||
group: enabledGroup(PlatformOpenAI),
|
||||
catalog: newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
|
||||
"gpt-5.6-sol": {Mode: "chat", InputCostPerToken: 5e-6, OutputCostPerToken: 30e-6, CacheReadInputTokenCost: 0.5e-6},
|
||||
}),
|
||||
catalog: mustCatalogFromJSON(`{"gpt-5.6-sol": {"litellm_provider": "openai", "mode": "chat",
|
||||
"input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, "cache_read_input_token_cost": 5e-07,
|
||||
"input_cost_per_token_above_272k_tokens": 1e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 4.5e-05,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 1e-06}}`),
|
||||
wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 2)
|
||||
@@ -398,22 +424,8 @@ func tierAt(tiers []ContextPricingTier, contextTokens int) ContextPricingTier {
|
||||
}
|
||||
|
||||
// expectedCostFromSchedule 按阶梯表推算 contextTokens 个某类 token 的费用:
|
||||
// 整单基准取所在档单价 × 全量;边际基准逐段累加。
|
||||
// 整单基准取所在档单价 × 全量。
|
||||
func expectedCostFromSchedule(s *ContextPricingSchedule, kind tokenKind, contextTokens int) float64 {
|
||||
if s.Basis == ContextPricingBasisMarginal {
|
||||
total := 0.0
|
||||
for _, tier := range s.Tiers {
|
||||
if contextTokens <= tier.MinTokens {
|
||||
break
|
||||
}
|
||||
upper := contextTokens
|
||||
if tier.MaxTokens != nil && *tier.MaxTokens < upper {
|
||||
upper = *tier.MaxTokens
|
||||
}
|
||||
total += float64(upper-tier.MinTokens) * tierPrice(tier, kind)
|
||||
}
|
||||
return total
|
||||
}
|
||||
return float64(contextTokens) * tierPrice(tierAt(s.Tiers, contextTokens), kind)
|
||||
}
|
||||
|
||||
@@ -443,17 +455,10 @@ func TestResolveContextPricingSchedule_ParityWithBilling(t *testing.T) {
|
||||
pricingInput.GroupID = &gid
|
||||
}
|
||||
resolved := resolver.Resolve(ctx, pricingInput)
|
||||
var legacy *LegacyLongContextRule
|
||||
if sc.group != nil {
|
||||
legacy = bs.LegacyLongContextRule(sc.platform)
|
||||
}
|
||||
if !legacyLongContextApplies(resolved, sc.group, legacy) {
|
||||
legacy = nil
|
||||
}
|
||||
cost := func(tokens UsageTokens) float64 {
|
||||
bd, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||||
Ctx: ctx, Model: sc.model, Group: sc.group, Tokens: tokens, RateMultiplier: 1,
|
||||
Resolver: resolver, Resolved: resolved, LegacyLongContext: legacy,
|
||||
Resolver: resolver, Resolved: resolved,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return bd.ActualCost
|
||||
|
||||
@@ -116,12 +116,6 @@ type ModelPricing struct {
|
||||
ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退)
|
||||
}
|
||||
|
||||
const (
|
||||
openAIGPT54LongContextInputThreshold = 272000
|
||||
openAIGPT54LongContextInputMultiplier = 2.0
|
||||
openAIGPT54LongContextOutputMultiplier = 1.5
|
||||
)
|
||||
|
||||
func normalizeBillingServiceTier(serviceTier string) string {
|
||||
return strings.ToLower(strings.TrimSpace(serviceTier))
|
||||
}
|
||||
@@ -365,6 +359,27 @@ func (s *BillingService) initFallbackPricing() {
|
||||
s.fallbackPrices["claude-opus-4.8"] = pricingWithPriorityMultiplier(s.fallbackPrices["claude-opus-4.7"], 2)
|
||||
s.fallbackPrices["claude-opus-5"] = pricingWithPriorityMultiplier(s.fallbackPrices["claude-opus-4.8"], 2)
|
||||
|
||||
// Claude Fable 5.x uses the same input/output and cache-write prices, while
|
||||
// Fable 5.1 reduces cache reads from $1 to $0.25 per MTok.
|
||||
s.fallbackPrices["claude-fable-5"] = &ModelPricing{
|
||||
InputPricePerToken: 10e-6,
|
||||
OutputPricePerToken: 50e-6,
|
||||
CacheCreationPricePerToken: 12.5e-6,
|
||||
CacheCreation5mPrice: 12.5e-6,
|
||||
CacheCreation1hPrice: 20e-6,
|
||||
CacheReadPricePerToken: 1e-6,
|
||||
SupportsCacheBreakdown: true,
|
||||
}
|
||||
s.fallbackPrices["claude-fable-5-1"] = &ModelPricing{
|
||||
InputPricePerToken: 10e-6,
|
||||
OutputPricePerToken: 50e-6,
|
||||
CacheCreationPricePerToken: 12.5e-6,
|
||||
CacheCreation5mPrice: 12.5e-6,
|
||||
CacheCreation1hPrice: 20e-6,
|
||||
CacheReadPricePerToken: 0.25e-6,
|
||||
SupportsCacheBreakdown: true,
|
||||
}
|
||||
|
||||
// Gemini 3.1 Pro
|
||||
s.fallbackPrices["gemini-3.1-pro"] = &ModelPricing{
|
||||
InputPricePerToken: 2e-6, // $2 per MTok
|
||||
@@ -395,9 +410,6 @@ func (s *BillingService) initFallbackPricing() {
|
||||
CacheReadPricePerToken: 0.25e-6, // $0.25 per MTok
|
||||
CacheReadPricePerTokenPriority: 0.5e-6, // $0.5 per MTok
|
||||
SupportsCacheBreakdown: false,
|
||||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
||||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
||||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
||||
}
|
||||
// OpenAI GPT-5.5 官方价格;Fast 为标准价 2.5 倍。
|
||||
// Source: https://platform.openai.com/docs/pricing
|
||||
@@ -405,24 +417,18 @@ func (s *BillingService) initFallbackPricing() {
|
||||
InputPricePerToken: 5e-6,
|
||||
OutputPricePerToken: 30e-6,
|
||||
// 官方未列独立 cache-write 价;内部出现 cache creation token 时按输入价兜底。
|
||||
CacheCreationPricePerToken: 5e-6,
|
||||
CacheReadPricePerToken: 0.5e-6,
|
||||
SupportsCacheBreakdown: false,
|
||||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
||||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
||||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
||||
CacheCreationPricePerToken: 5e-6,
|
||||
CacheReadPricePerToken: 0.5e-6,
|
||||
SupportsCacheBreakdown: false,
|
||||
}, 2.5)
|
||||
// GPT-5.5 Pro 当前不提供 Fast;保留标准、Flex 和长上下文 fallback 价格。
|
||||
s.fallbackPrices["gpt-5.5-pro"] = &ModelPricing{
|
||||
InputPricePerToken: 30e-6,
|
||||
OutputPricePerToken: 180e-6,
|
||||
// 官方未列独立 cached-input/cache-write 价;内部出现对应 token 时按输入价兜底。
|
||||
CacheCreationPricePerToken: 30e-6,
|
||||
CacheReadPricePerToken: 30e-6,
|
||||
SupportsCacheBreakdown: false,
|
||||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
||||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
||||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
||||
CacheCreationPricePerToken: 30e-6,
|
||||
CacheReadPricePerToken: 30e-6,
|
||||
SupportsCacheBreakdown: false,
|
||||
}
|
||||
|
||||
// OpenAI GPT-5.6 官方价格(USD/token)。缓存写入为输入价的 1.25 倍。
|
||||
@@ -435,9 +441,6 @@ func (s *BillingService) initFallbackPricing() {
|
||||
CacheCreationPricePerTokenPriority: 12.5e-6,
|
||||
CacheReadPricePerToken: 0.5e-6,
|
||||
CacheReadPricePerTokenPriority: 1e-6,
|
||||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
||||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
||||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
||||
}
|
||||
s.fallbackPrices["gpt-5.6-terra"] = &ModelPricing{
|
||||
InputPricePerToken: 2e-6,
|
||||
@@ -448,9 +451,6 @@ func (s *BillingService) initFallbackPricing() {
|
||||
CacheCreationPricePerTokenPriority: 5e-6,
|
||||
CacheReadPricePerToken: 0.2e-6,
|
||||
CacheReadPricePerTokenPriority: 0.4e-6,
|
||||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
||||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
||||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
||||
}
|
||||
s.fallbackPrices["gpt-5.6-luna"] = &ModelPricing{
|
||||
InputPricePerToken: 0.2e-6,
|
||||
@@ -461,9 +461,6 @@ func (s *BillingService) initFallbackPricing() {
|
||||
CacheCreationPricePerTokenPriority: 0.5e-6,
|
||||
CacheReadPricePerToken: 0.02e-6,
|
||||
CacheReadPricePerTokenPriority: 0.04e-6,
|
||||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
||||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
||||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
||||
}
|
||||
|
||||
s.fallbackPrices["gpt-5.4-mini"] = &ModelPricing{
|
||||
@@ -806,6 +803,13 @@ 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") {
|
||||
return s.fallbackPrices["claude-fable-5-1"]
|
||||
}
|
||||
if strings.Contains(modelLower, "fable-5") || strings.Contains(modelLower, "fable5") {
|
||||
return s.fallbackPrices["claude-fable-5"]
|
||||
}
|
||||
if strings.Contains(modelLower, "opus") {
|
||||
// "opus-5" 必须先判:不能用裸 "5" 匹配,否则 claude-opus-4-5 会被误判。
|
||||
if strings.Contains(modelLower, "opus-5") || strings.Contains(modelLower, "opus5") {
|
||||
@@ -1125,10 +1129,12 @@ func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) {
|
||||
CacheCreation1hPrice: price1h,
|
||||
SupportsCacheBreakdown: enableBreakdown,
|
||||
LongContextInputThreshold: litellmPricing.LongContextInputTokenThreshold,
|
||||
LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier,
|
||||
LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier,
|
||||
ImageInputPricePerToken: litellmPricing.InputCostPerImageToken,
|
||||
ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken,
|
||||
// xAI 的长上下文阈值语义为"达到即进高档"(LiteLLM 同口径),其余提供商为严格大于。
|
||||
LongContextThresholdInclusive: strings.EqualFold(litellmPricing.LiteLLMProvider, "xai"),
|
||||
LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier,
|
||||
LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier,
|
||||
ImageInputPricePerToken: litellmPricing.InputCostPerImageToken,
|
||||
ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken,
|
||||
}), nil
|
||||
}
|
||||
}
|
||||
@@ -1203,7 +1209,15 @@ func applyChannelTokenPriceOverrides(pricing *ModelPricing, channelPricing *Chan
|
||||
pricing.CacheCreationPricePerTokenPriority = priority
|
||||
pricing.CacheCreationPriceExplicit = true
|
||||
pricing.CacheCreation5mPrice = *channelPricing.CacheWritePrice
|
||||
pricing.CacheCreation1hPrice = *channelPricing.CacheWritePrice
|
||||
if channelPricing.CacheWrite1hPrice == nil {
|
||||
// Preserve the pre-split behavior for existing configurations: a lone
|
||||
// cache_write_price continues to override both TTL tiers.
|
||||
pricing.CacheCreation1hPrice = *channelPricing.CacheWritePrice
|
||||
}
|
||||
}
|
||||
if channelPricing.CacheWrite1hPrice != nil {
|
||||
pricing.CacheCreation1hPrice = *channelPricing.CacheWrite1hPrice
|
||||
pricing.SupportsCacheBreakdown = true
|
||||
}
|
||||
if channelPricing.CacheReadPrice != nil {
|
||||
priority := channelTierOverridePrice(pricing.CacheReadPricePerToken, pricing.CacheReadPricePerTokenPriority, *channelPricing.CacheReadPrice)
|
||||
@@ -1375,15 +1389,18 @@ func (s *BillingService) computeTokenBreakdown(
|
||||
var baselineCost *CostBreakdown
|
||||
if longContextPricingEligible {
|
||||
baselineCost = s.computeTokenBreakdown(pricing, tokens, rateMultiplier, serviceTier, false)
|
||||
inputPrice *= pricing.LongContextInputMultiplier
|
||||
outputPrice *= pricing.LongContextOutputMultiplier
|
||||
// 倍率 ≤0 表示该项未配置(目录/覆写条目可能只写了 input 或 output 一侧),
|
||||
// 按 1 计而不是乘 0:乘 0 会把超阈值请求的对应分项算成免费。
|
||||
longCtxInputMultiplier := longContextMultiplierOrOne(pricing.LongContextInputMultiplier)
|
||||
inputPrice *= longCtxInputMultiplier
|
||||
outputPrice *= longContextMultiplierOrOne(pricing.LongContextOutputMultiplier)
|
||||
// 缓存读取本质上是输入侧的复用,应与 input 一同应用长上下文倍率;
|
||||
// 否则 cache hit 越多,少计的费用越多(见 #2293)。
|
||||
cacheReadPrice *= pricing.LongContextInputMultiplier
|
||||
cacheReadPrice *= longCtxInputMultiplier
|
||||
// 缓存创建(cache_write)也是输入侧操作,三档价格(标准 / 5m / 1h)
|
||||
// 都通过 computeCacheCreationCost 直接读取 pricing.*,不会经过这里
|
||||
// 的倍率修改,因此显式向下传一个倍率,避免长上下文场景下被漏乘。
|
||||
cacheCreationMultiplier = pricing.LongContextInputMultiplier
|
||||
cacheCreationMultiplier = longCtxInputMultiplier
|
||||
}
|
||||
|
||||
bd := &CostBreakdown{}
|
||||
@@ -1568,10 +1585,13 @@ func (s *BillingService) calculateCostInternalWithPolicy(
|
||||
return s.computeTokenBreakdown(pricing, tokens, rateMultiplier, serviceTier, longContextBillingEnabled), nil
|
||||
}
|
||||
|
||||
// applyModelSpecificPricingPolicy 应用模型特定定价策略(GPT-5.6 长上下文/缓存写入、
|
||||
// DeepSeek 官方价强制覆盖等)。默认强制 DeepSeek 官方价——该路径仅被默认价卡
|
||||
// (GetModelPricing 内部)调用;分组/渠道自定义定价路径用带参数的
|
||||
// applyModelSpecificPricingPolicyEx 关闭强制,保留运营者配置。
|
||||
// applyModelSpecificPricingPolicy 对目录数据做模型特定修正:DeepSeek 官方价
|
||||
// 强制覆盖;GPT-5.6 缺 cache_write 价时按官方规则补 1.25 倍输入价;Fast/priority
|
||||
// 档按业务倍率改写(本地/远程目录的 priority 价可能沿用官方旧口径)。长上下文
|
||||
// 阶梯不在此处补齐:一律由目录数据(above_XXXk 折算或显式 long_context_* 字段)
|
||||
// 驱动。默认强制 DeepSeek 官方价——该路径仅被默认价卡(GetModelPricing 内部)
|
||||
// 调用;分组/渠道自定义定价路径用带参数的 applyModelSpecificPricingPolicyEx
|
||||
// 关闭强制,保留运营者配置。
|
||||
func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing *ModelPricing) *ModelPricing {
|
||||
return s.applyModelSpecificPricingPolicyEx(model, pricing, true)
|
||||
}
|
||||
@@ -1607,16 +1627,10 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing
|
||||
}
|
||||
normalized := normalizeKnownOpenAICodexModel(model)
|
||||
isGPT56 := isOpenAIGPT56Model(normalized)
|
||||
usesLegacyLongContextPricing := usesOpenAILegacyLongContextPricing(normalized)
|
||||
if !isGPT56 && !usesLegacyLongContextPricing {
|
||||
return pricing
|
||||
}
|
||||
needsLongContextPolicy := (isGPT56 || usesLegacyLongContextPricing) &&
|
||||
(pricing.LongContextInputThreshold <= 0 || pricing.LongContextInputMultiplier <= 0 || pricing.LongContextOutputMultiplier <= 0)
|
||||
needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 ||
|
||||
(pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0))
|
||||
fastRatio := openAIModelFastPricingRatio(normalized)
|
||||
if !needsLongContextPolicy && !needsCacheCreationPolicy && fastRatio <= 0 {
|
||||
if !needsCacheCreationPolicy && fastRatio <= 0 {
|
||||
return pricing
|
||||
}
|
||||
cloned := *pricing
|
||||
@@ -1628,17 +1642,6 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing
|
||||
cloned.CacheCreationPricePerTokenPriority = cloned.InputPricePerTokenPriority * 1.25
|
||||
}
|
||||
}
|
||||
if isGPT56 || usesLegacyLongContextPricing {
|
||||
if cloned.LongContextInputThreshold <= 0 {
|
||||
cloned.LongContextInputThreshold = openAIGPT54LongContextInputThreshold
|
||||
}
|
||||
if cloned.LongContextInputMultiplier <= 0 {
|
||||
cloned.LongContextInputMultiplier = openAIGPT54LongContextInputMultiplier
|
||||
}
|
||||
if cloned.LongContextOutputMultiplier <= 0 {
|
||||
cloned.LongContextOutputMultiplier = openAIGPT54LongContextOutputMultiplier
|
||||
}
|
||||
}
|
||||
if fastRatio > 0 {
|
||||
enforceOpenAIFastPricingRatio(&cloned, fastRatio)
|
||||
}
|
||||
@@ -1678,6 +1681,14 @@ func enforceOpenAIFastPricingRatio(pricing *ModelPricing, ratio float64) {
|
||||
}
|
||||
}
|
||||
|
||||
// longContextMultiplierOrOne 把未配置(≤0)的长上下文倍率归一为 1。
|
||||
func longContextMultiplierOrOne(m float64) float64 {
|
||||
if m <= 0 {
|
||||
return 1
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *BillingService) shouldApplySessionLongContextPricing(tokens UsageTokens, pricing *ModelPricing) bool {
|
||||
if pricing == nil || pricing.LongContextInputThreshold <= 0 {
|
||||
return false
|
||||
@@ -1692,10 +1703,6 @@ func (s *BillingService) shouldApplySessionLongContextPricing(tokens UsageTokens
|
||||
return totalInputTokens > pricing.LongContextInputThreshold
|
||||
}
|
||||
|
||||
func usesOpenAILegacyLongContextPricing(normalized string) bool {
|
||||
return normalized == "gpt-5.4" || normalized == "gpt-5.5" || normalized == "gpt-5.5-pro"
|
||||
}
|
||||
|
||||
// CalculateCostWithConfig 使用配置中的默认倍率计算费用
|
||||
func (s *BillingService) CalculateCostWithConfig(model string, tokens UsageTokens) (*CostBreakdown, error) {
|
||||
multiplier := s.cfg.Default.RateMultiplier
|
||||
@@ -1705,82 +1712,6 @@ func (s *BillingService) CalculateCostWithConfig(model string, tokens UsageToken
|
||||
return s.CalculateCost(model, tokens, multiplier)
|
||||
}
|
||||
|
||||
// CalculateCostWithLongContext 计算费用,支持长上下文双倍计费
|
||||
// threshold: 阈值(如 200000),超过此值的部分按 extraMultiplier 倍计费
|
||||
// extraMultiplier: 超出部分的倍率(如 2.0 表示双倍)
|
||||
//
|
||||
// 示例:缓存 210k + 输入 10k = 220k,阈值 200k,倍率 2.0
|
||||
// 拆分为:范围内 (200k, 0) + 范围外 (10k, 10k)
|
||||
// 范围内正常计费,范围外 × 2 计费
|
||||
func (s *BillingService) CalculateCostWithLongContext(model string, tokens UsageTokens, rateMultiplier float64, threshold int, extraMultiplier float64) (*CostBreakdown, error) {
|
||||
// 未启用长上下文计费,直接走正常计费
|
||||
if threshold <= 0 || extraMultiplier <= 1 {
|
||||
return s.CalculateCost(model, tokens, rateMultiplier)
|
||||
}
|
||||
|
||||
// 计算总输入 token(缓存读取 + 新输入)
|
||||
total := tokens.CacheReadTokens + tokens.InputTokens
|
||||
if total <= threshold {
|
||||
return s.CalculateCost(model, tokens, rateMultiplier)
|
||||
}
|
||||
|
||||
// 拆分成范围内和范围外
|
||||
var inRangeCacheTokens, inRangeInputTokens int
|
||||
var outRangeCacheTokens, outRangeInputTokens int
|
||||
|
||||
if tokens.CacheReadTokens >= threshold {
|
||||
// 缓存已超过阈值:范围内只有缓存,范围外是超出的缓存+全部输入
|
||||
inRangeCacheTokens = threshold
|
||||
inRangeInputTokens = 0
|
||||
outRangeCacheTokens = tokens.CacheReadTokens - threshold
|
||||
outRangeInputTokens = tokens.InputTokens
|
||||
} else {
|
||||
// 缓存未超过阈值:范围内是全部缓存+部分输入,范围外是剩余输入
|
||||
inRangeCacheTokens = tokens.CacheReadTokens
|
||||
inRangeInputTokens = threshold - tokens.CacheReadTokens
|
||||
outRangeCacheTokens = 0
|
||||
outRangeInputTokens = tokens.InputTokens - inRangeInputTokens
|
||||
}
|
||||
|
||||
// 范围内部分:正常计费
|
||||
inRangeTokens := UsageTokens{
|
||||
InputTokens: inRangeInputTokens,
|
||||
OutputTokens: tokens.OutputTokens, // 输出只算一次
|
||||
CacheCreationTokens: tokens.CacheCreationTokens,
|
||||
CacheReadTokens: inRangeCacheTokens,
|
||||
CacheCreation5mTokens: tokens.CacheCreation5mTokens,
|
||||
CacheCreation1hTokens: tokens.CacheCreation1hTokens,
|
||||
ImageOutputTokens: tokens.ImageOutputTokens,
|
||||
}
|
||||
inRangeCost, err := s.CalculateCost(model, inRangeTokens, rateMultiplier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 范围外部分:× extraMultiplier 计费
|
||||
outRangeTokens := UsageTokens{
|
||||
InputTokens: outRangeInputTokens,
|
||||
CacheReadTokens: outRangeCacheTokens,
|
||||
}
|
||||
outRangeCost, err := s.CalculateCost(model, outRangeTokens, rateMultiplier*extraMultiplier)
|
||||
if err != nil {
|
||||
return inRangeCost, fmt.Errorf("out-range cost: %w", err)
|
||||
}
|
||||
|
||||
// 合并成本
|
||||
return &CostBreakdown{
|
||||
InputCost: inRangeCost.InputCost + outRangeCost.InputCost,
|
||||
ImageInputCost: inRangeCost.ImageInputCost + outRangeCost.ImageInputCost,
|
||||
OutputCost: inRangeCost.OutputCost,
|
||||
ImageOutputCost: inRangeCost.ImageOutputCost,
|
||||
CacheCreationCost: inRangeCost.CacheCreationCost,
|
||||
CacheReadCost: inRangeCost.CacheReadCost + outRangeCost.CacheReadCost,
|
||||
TotalCost: inRangeCost.TotalCost + outRangeCost.TotalCost,
|
||||
ActualCost: inRangeCost.ActualCost + outRangeCost.ActualCost,
|
||||
LongContextBillingApplied: outRangeCost.ActualCost > 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListSupportedModels 列出所有支持的模型(现在总是返回true,因为有模糊匹配)
|
||||
func (s *BillingService) ListSupportedModels() []string {
|
||||
models := make([]string, 0)
|
||||
|
||||
@@ -33,6 +33,11 @@ func newTestBillingService() *BillingService {
|
||||
return NewBillingService(&config.Config{}, nil)
|
||||
}
|
||||
|
||||
func newTestBillingServiceWithOpenAILadderCatalog(t *testing.T) *BillingService {
|
||||
t.Helper()
|
||||
return NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, openAILadderCatalogJSON))
|
||||
}
|
||||
|
||||
func TestCalculateCost_BasicComputation(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
@@ -200,9 +205,21 @@ func TestGetModelPricing_OpenAIGPT54Fallback(t *testing.T) {
|
||||
require.InDelta(t, 2.5e-6, pricing.InputPricePerToken, 1e-12)
|
||||
require.InDelta(t, 15e-6, pricing.OutputPricePerToken, 1e-12)
|
||||
require.InDelta(t, 0.25e-6, pricing.CacheReadPricePerToken, 1e-12)
|
||||
// 静态兜底价不携带长上下文阶梯:阶梯一律由目录数据(above_272k 折算)驱动。
|
||||
require.Zero(t, pricing.LongContextInputThreshold)
|
||||
require.Zero(t, pricing.LongContextInputMultiplier)
|
||||
require.Zero(t, pricing.LongContextOutputMultiplier)
|
||||
}
|
||||
|
||||
func TestGetModelPricing_CatalogAboveTierFieldsDriveLongContext(t *testing.T) {
|
||||
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||
|
||||
pricing, err := svc.GetModelPricing("gpt-5.4")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 272000, pricing.LongContextInputThreshold)
|
||||
require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12)
|
||||
require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12)
|
||||
require.False(t, pricing.LongContextThresholdInclusive, "openai 阈值语义为严格大于")
|
||||
}
|
||||
|
||||
func TestGetModelPricing_OpenAICompactAliasesFallback(t *testing.T) {
|
||||
@@ -215,8 +232,8 @@ func TestGetModelPricing_OpenAICompactAliasesFallback(t *testing.T) {
|
||||
cacheRead float64
|
||||
longContext int
|
||||
}{
|
||||
{model: "gpt5.5", inputPrice: 5e-6, outputPrice: 30e-6, cacheRead: 0.5e-6, longContext: 272000},
|
||||
{model: "openai/gpt5.4", inputPrice: 2.5e-6, outputPrice: 15e-6, cacheRead: 0.25e-6, longContext: 272000},
|
||||
{model: "gpt5.5", inputPrice: 5e-6, outputPrice: 30e-6, cacheRead: 0.5e-6, longContext: 0},
|
||||
{model: "openai/gpt5.4", inputPrice: 2.5e-6, outputPrice: 15e-6, cacheRead: 0.25e-6, longContext: 0},
|
||||
{model: "gpt5.4-mini", inputPrice: 7.5e-7, outputPrice: 4.5e-6, cacheRead: 7.5e-8, longContext: 0},
|
||||
{model: "gpt5.3codexspark", inputPrice: 1.5e-6, outputPrice: 12e-6, cacheRead: 0.15e-6, longContext: 0},
|
||||
}
|
||||
@@ -247,7 +264,7 @@ func TestGetModelPricing_OpenAIGPT54MiniFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCalculateCost_OpenAIGPT54LongContextAppliesWholeSessionMultipliers(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||
|
||||
tokens := UsageTokens{
|
||||
InputTokens: 300000,
|
||||
@@ -267,7 +284,7 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesWholeSessionMultipliers(t *t
|
||||
}
|
||||
|
||||
func TestCalculateCost_OpenAIGPT54LongContextMarkerRequiresActualCostIncrease(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||
|
||||
cost, err := svc.calculateCostWithServiceTierPolicy(
|
||||
"gpt-5.4-2026-03-05",
|
||||
@@ -283,7 +300,7 @@ func TestCalculateCost_OpenAIGPT54LongContextMarkerRequiresActualCostIncrease(t
|
||||
}
|
||||
|
||||
func TestCalculateCost_OpenAIGPT55ProUsesGPT55PricingPolicy(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||
|
||||
tokens := UsageTokens{
|
||||
InputTokens: 300000,
|
||||
@@ -331,7 +348,7 @@ func TestFallbackPricing_OpenAIGPT55ProUsesOfficialPrices(t *testing.T) {
|
||||
// 修复前:CacheReadCost = tokens * 0.25e-6 (漏乘倍率,少计费用)。
|
||||
// 修复后:CacheReadCost = tokens * 0.25e-6 * LongContextInputMultiplier(=2.0)。
|
||||
func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheRead(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||
|
||||
// InputTokens + CacheReadTokens = 1000 + 300000 = 301000 > 272000 阈值
|
||||
tokens := UsageTokens{
|
||||
@@ -359,7 +376,7 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheRead(t *tes
|
||||
|
||||
// 阴性测试:未触发长上下文时,cache_read_price 不应被错误地乘以倍率。
|
||||
func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheReadAtBasePrice(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||
|
||||
// InputTokens + CacheReadTokens = 1000 + 100000 = 101000 < 272000 阈值,不触发长上下文
|
||||
tokens := UsageTokens{
|
||||
@@ -381,7 +398,7 @@ func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheReadAtBasePrice(t *test
|
||||
// 不经过 computeTokenBreakdown 内的 inputPrice / cacheReadPrice 倍率修改,因此
|
||||
// 修复前 cache_creation 部分会按基础价计算,少计费用约 50%(默认倍率 2.0)。
|
||||
func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheCreation(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||
|
||||
// InputTokens + CacheReadTokens = 1000 + 300000 = 301000 > 272000 阈值
|
||||
tokens := UsageTokens{
|
||||
@@ -402,7 +419,7 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheCreation(t
|
||||
|
||||
// 阴性测试:未触发长上下文时,cache_creation_price 不应被错误地乘以倍率。
|
||||
func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheCreationAtBasePrice(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||
|
||||
// InputTokens + CacheReadTokens = 1000 + 100000 = 101000 < 272000 阈值,不触发长上下文
|
||||
tokens := UsageTokens{
|
||||
@@ -921,114 +938,6 @@ func TestComputeTokenBreakdown_GptImage2ImageEditIssue4386(t *testing.T) {
|
||||
require.InDelta(t, wantImageOutput, cost.ImageOutputCost, 1e-15)
|
||||
require.InDelta(t, 0.016081, cost.TotalCost, 1e-9, "总额应为 $0.016081(修复前为 $0.015025)")
|
||||
}
|
||||
func TestCalculateCostWithLongContext_BelowThreshold(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
tokens := UsageTokens{
|
||||
InputTokens: 50000,
|
||||
OutputTokens: 1000,
|
||||
CacheReadTokens: 100000,
|
||||
}
|
||||
// 总输入 150k < 200k 阈值,应走正常计费
|
||||
cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 2.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
normalCost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.InDelta(t, normalCost.ActualCost, cost.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateCostWithLongContext_AboveThreshold_CacheExceedsThreshold(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
// 缓存 210k + 输入 10k = 220k > 200k 阈值
|
||||
// 缓存已超阈值:范围内 200k 缓存,范围外 10k 缓存 + 10k 输入
|
||||
tokens := UsageTokens{
|
||||
InputTokens: 10000,
|
||||
OutputTokens: 1000,
|
||||
CacheReadTokens: 210000,
|
||||
}
|
||||
cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 2.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 范围内:200k cache + 0 input + 1k output
|
||||
inRange, _ := svc.CalculateCost("claude-sonnet-4", UsageTokens{
|
||||
InputTokens: 0,
|
||||
OutputTokens: 1000,
|
||||
CacheReadTokens: 200000,
|
||||
}, 1.0)
|
||||
|
||||
// 范围外:10k cache + 10k input,倍率 2.0
|
||||
outRange, _ := svc.CalculateCost("claude-sonnet-4", UsageTokens{
|
||||
InputTokens: 10000,
|
||||
CacheReadTokens: 10000,
|
||||
}, 2.0)
|
||||
|
||||
require.InDelta(t, inRange.ActualCost+outRange.ActualCost, cost.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateCostWithLongContext_AboveThreshold_CacheBelowThreshold(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
// 缓存 100k + 输入 150k = 250k > 200k 阈值
|
||||
// 缓存未超阈值:范围内 100k 缓存 + 100k 输入,范围外 50k 输入
|
||||
tokens := UsageTokens{
|
||||
InputTokens: 150000,
|
||||
OutputTokens: 1000,
|
||||
CacheReadTokens: 100000,
|
||||
}
|
||||
cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 2.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, cost.ActualCost > 0, "费用应大于 0")
|
||||
|
||||
// 正常费用不含长上下文
|
||||
normalCost, _ := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||
require.True(t, cost.ActualCost > normalCost.ActualCost, "长上下文费用应高于正常费用")
|
||||
}
|
||||
|
||||
func TestCalculateCostWithLongContext_MarkerRequiresActualCostIncrease(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
tokens := UsageTokens{InputTokens: 300000}
|
||||
|
||||
cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 0, 200000, 2.0)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, cost.ActualCost)
|
||||
require.False(t, cost.LongContextBillingApplied)
|
||||
}
|
||||
|
||||
func TestCalculateCostWithLongContext_DisabledThreshold(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
tokens := UsageTokens{InputTokens: 300000, CacheReadTokens: 0}
|
||||
|
||||
// threshold <= 0 应禁用长上下文计费
|
||||
cost1, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 0, 2.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
cost2, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.InDelta(t, cost2.ActualCost, cost1.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateCostWithLongContext_ExtraMultiplierLessEqualOne(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
tokens := UsageTokens{InputTokens: 300000}
|
||||
|
||||
// extraMultiplier <= 1 应禁用长上下文计费
|
||||
cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 1.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
normalCost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.InDelta(t, normalCost.ActualCost, cost.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateImageCost(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
@@ -1178,19 +1087,6 @@ func TestForceUpdatePricing_NilService(t *testing.T) {
|
||||
require.Contains(t, err.Error(), "not initialized")
|
||||
}
|
||||
|
||||
func TestCalculateCostWithLongContext_PropagatesError(t *testing.T) {
|
||||
// 使用空的 fallback prices 让 GetModelPricing 失败
|
||||
svc := &BillingService{
|
||||
cfg: &config.Config{},
|
||||
fallbackPrices: make(map[string]*ModelPricing),
|
||||
}
|
||||
|
||||
tokens := UsageTokens{InputTokens: 300000, CacheReadTokens: 0}
|
||||
_, err := svc.CalculateCostWithLongContext("unknown-model", tokens, 1.0, 200000, 2.0)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "pricing not found")
|
||||
}
|
||||
|
||||
func TestGetModelPricing_Grok45OfficialFallback(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
@@ -1850,6 +1746,32 @@ func TestGetModelPricingWithChannel_CacheWritePriceAffects5mAnd1h(t *testing.T)
|
||||
require.InDelta(t, 7e-6, pricing.CacheCreation1hPrice, 1e-12)
|
||||
}
|
||||
|
||||
func TestGetModelPricingWithChannel_CacheWriteTTLPricesCanDiffer(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
pricing, err := svc.GetModelPricingWithChannel("claude-fable-5-1", &ChannelModelPricing{
|
||||
CacheWritePrice: testPtrFloat64(13e-6),
|
||||
CacheWrite1hPrice: testPtrFloat64(21e-6),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, pricing.SupportsCacheBreakdown)
|
||||
require.InDelta(t, 13e-6, pricing.CacheCreationPricePerToken, 1e-12)
|
||||
require.InDelta(t, 13e-6, pricing.CacheCreation5mPrice, 1e-12)
|
||||
require.InDelta(t, 21e-6, pricing.CacheCreation1hPrice, 1e-12)
|
||||
}
|
||||
|
||||
func TestGetModelPricing_Fable51FallbackPricing(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
pricing, err := svc.GetModelPricing("claude-fable-5-1")
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 10e-6, pricing.InputPricePerToken, 1e-12)
|
||||
require.InDelta(t, 50e-6, pricing.OutputPricePerToken, 1e-12)
|
||||
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)
|
||||
}
|
||||
|
||||
func TestGetModelPricingWithChannel_CacheReadPriceAffectsPriority(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
|
||||
@@ -5,33 +5,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// LegacyLongContextRule 平台级"超出阈值部分按倍率计费"的旧规则。
|
||||
//
|
||||
// 语义为边际计费:仅 input/cache_read 中超过 Threshold 的部分乘 Multiplier,
|
||||
// output 与 cache_write 不受影响(见 CalculateCostWithLongContext)。
|
||||
// 只有 Gemini 原生 /v1beta 入口在该分组对该模型没有分组/渠道定价时才使用;
|
||||
// 规则常量由 BillingService 统一持有,网关与模型广场都从这里读取。
|
||||
type LegacyLongContextRule struct {
|
||||
Threshold int
|
||||
Multiplier float64
|
||||
}
|
||||
|
||||
const (
|
||||
geminiLegacyLongContextThreshold = 200000
|
||||
geminiLegacyLongContextMultiplier = 2.0
|
||||
)
|
||||
|
||||
// LegacyLongContextRule 返回平台的旧长上下文规则;无规则的平台返回 nil。
|
||||
func (s *BillingService) LegacyLongContextRule(platform string) *LegacyLongContextRule {
|
||||
if platform == PlatformGemini {
|
||||
return &LegacyLongContextRule{
|
||||
Threshold: geminiLegacyLongContextThreshold,
|
||||
Multiplier: geminiLegacyLongContextMultiplier,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TokenCostRequest 通用网关 token 计费请求。
|
||||
type TokenCostRequest struct {
|
||||
Ctx context.Context
|
||||
@@ -44,27 +17,12 @@ type TokenCostRequest struct {
|
||||
Resolver *ModelPricingResolver
|
||||
// Resolved 为调用方预先解析的定价(Resolver.Resolve 的结果),nil 表示未解析。
|
||||
Resolved *ResolvedPricing
|
||||
// LegacyLongContext 入口携带的旧长上下文规则,nil 表示该入口不使用。
|
||||
LegacyLongContext *LegacyLongContextRule
|
||||
}
|
||||
|
||||
// legacyLongContextApplies 判定请求是否走旧长上下文规则:
|
||||
// 分组/渠道显式定价优先;否则在规则存在且分组长上下文开关开启时生效。
|
||||
func legacyLongContextApplies(resolved *ResolvedPricing, group *Group, rule *LegacyLongContextRule) bool {
|
||||
if rule == nil || rule.Threshold <= 0 {
|
||||
return false
|
||||
}
|
||||
if resolved != nil && (resolved.Source == PricingSourceGroup || resolved.Source == PricingSourceChannel) {
|
||||
return false
|
||||
}
|
||||
return group == nil || group.LongContextPricingEnabled
|
||||
}
|
||||
|
||||
// CalculateTokenCostForRequest 按通用网关的路径选择计算 token 费用:
|
||||
// 1. 分组/渠道显式定价 → 统一计费(区间、分组卡、目录阶梯均在其中);
|
||||
// 2. 否则入口带旧长上下文规则且分组开关开启 → 旧边际计费;
|
||||
// 3. 否则有解析器与分组 → 统一计费(内置目录定价);
|
||||
// 4. 否则按模型目录直接计费。
|
||||
// 1. 分组/渠道显式定价,或有解析器与分组 → 统一计费
|
||||
// (区间、分组卡、目录长上下文阶梯均在其中,阶梯由目录数据驱动);
|
||||
// 2. 否则按模型目录直接计费。
|
||||
//
|
||||
// 模型广场的阶梯表查询与网关使用同一入口,保证展示与扣费同源。
|
||||
func (s *BillingService) CalculateTokenCostForRequest(req TokenCostRequest) (*CostBreakdown, error) {
|
||||
@@ -72,10 +30,6 @@ func (s *BillingService) CalculateTokenCostForRequest(req TokenCostRequest) (*Co
|
||||
if resolved != nil && (resolved.Source == PricingSourceGroup || resolved.Source == PricingSourceChannel) {
|
||||
return s.CalculateCostUnified(s.tokenCostInput(req, resolved))
|
||||
}
|
||||
if legacyLongContextApplies(resolved, req.Group, req.LegacyLongContext) {
|
||||
return s.CalculateCostWithLongContext(req.Model, req.Tokens, req.RateMultiplier,
|
||||
req.LegacyLongContext.Threshold, req.LegacyLongContext.Multiplier)
|
||||
}
|
||||
if req.Resolver != nil && req.Group != nil {
|
||||
return s.CalculateCostUnified(s.tokenCostInput(req, resolved))
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ func newTokenCostTestEnv(t *testing.T, groupPlatform string, pricing []ChannelMo
|
||||
return bs, NewModelPricingResolver(cs, bs)
|
||||
}
|
||||
|
||||
// geminiCatalogStub 无阶梯字段的 gemini 目录条目(用于验证"无数据即无阶梯")。
|
||||
func geminiCatalogStub() *PricingService {
|
||||
return newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
|
||||
"gemini-2.5-pro": {
|
||||
@@ -39,22 +40,32 @@ func geminiCatalogStub() *PricingService {
|
||||
})
|
||||
}
|
||||
|
||||
func TestLegacyLongContextRule_OnlyGemini(t *testing.T) {
|
||||
bs := NewBillingService(&config.Config{}, nil)
|
||||
rule := bs.LegacyLongContextRule(PlatformGemini)
|
||||
require.NotNil(t, rule)
|
||||
require.Equal(t, 200000, rule.Threshold)
|
||||
require.InDelta(t, 2.0, rule.Multiplier, 1e-12)
|
||||
for _, platform := range []string{PlatformOpenAI, PlatformAnthropic, PlatformAntigravity, PlatformComposite, ""} {
|
||||
require.Nil(t, bs.LegacyLongContextRule(platform), platform)
|
||||
}
|
||||
// geminiLadderCatalogJSON 镜像真实目录的 gemini-2.5-pro 条目(含 cache_creation 基础价修正后的
|
||||
// 数值):above_200k 绝对价由解析层折算成 200K 阈值 + 输入 ×2 / 输出 ×1.5;cache 侧 above 档
|
||||
// 恰为基础价 × 输入倍率(缓存写入按标准输入价,Google 对缓存写入不另收费)。
|
||||
const geminiLadderCatalogJSON = `{
|
||||
"gemini-2.5-pro": {"litellm_provider": "vertex_ai-language-models", "mode": "chat",
|
||||
"input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05,
|
||||
"cache_read_input_token_cost": 1.25e-07,
|
||||
"cache_creation_input_token_cost": 1.25e-06,
|
||||
"input_cost_per_token_above_200k_tokens": 2.5e-06,
|
||||
"output_cost_per_token_above_200k_tokens": 1.5e-05,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 2.5e-07,
|
||||
"cache_creation_input_token_cost_above_200k_tokens": 2.5e-06}
|
||||
}`
|
||||
|
||||
func geminiLadderCatalogStub(t *testing.T) *PricingService {
|
||||
t.Helper()
|
||||
return newStubPricingServiceFromJSON(t, geminiLadderCatalogJSON)
|
||||
}
|
||||
|
||||
func TestCalculateTokenCostForRequest_ChannelPricingWinsOverLegacyRule(t *testing.T) {
|
||||
// 渠道平价之上叠加目录阶梯:与分组价卡/OpenAI 渠道价的既有语义一致,
|
||||
// 超阈值整单按渠道价 × 目录倍率。
|
||||
func TestCalculateTokenCostForRequest_ChannelFlatPriceStacksCatalogLadder(t *testing.T) {
|
||||
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, []ChannelModelPricing{{
|
||||
Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken,
|
||||
InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(40e-6),
|
||||
}}, geminiCatalogStub())
|
||||
}}, geminiLadderCatalogStub(t))
|
||||
group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true}
|
||||
gid := group.ID
|
||||
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group})
|
||||
@@ -63,23 +74,41 @@ func TestCalculateTokenCostForRequest_ChannelPricingWinsOverLegacyRule(t *testin
|
||||
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
|
||||
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||||
Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
|
||||
Resolver: resolver, Resolved: resolved, LegacyLongContext: bs.LegacyLongContextRule(PlatformGemini),
|
||||
Resolver: resolver, Resolved: resolved,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
want, err := bs.CalculateCostUnified(CostInput{
|
||||
Ctx: context.Background(), Model: "gemini-2.5-pro", GroupID: &gid, Group: group, Tokens: tokens,
|
||||
RequestCount: 1, RateMultiplier: 1, Resolver: resolver, Resolved: resolved,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, got)
|
||||
// 渠道平价 10e-6 × 300K,旧规则未叠加
|
||||
require.InDelta(t, 3.0, got.InputCost, 1e-9)
|
||||
require.InDelta(t, 300000*10e-6*2, got.InputCost, 1e-9)
|
||||
require.InDelta(t, 1000*40e-6*1.5, got.OutputCost, 1e-9)
|
||||
require.True(t, got.LongContextBillingApplied)
|
||||
}
|
||||
|
||||
func TestCalculateTokenCostForRequest_LegacyRuleFollowsGroupToggle(t *testing.T) {
|
||||
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiCatalogStub())
|
||||
// 渠道配置了定价区间时以渠道区间为准:目录阶梯(倍率)不再叠加。
|
||||
func TestCalculateTokenCostForRequest_ChannelIntervalsOverrideCatalogLadder(t *testing.T) {
|
||||
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, []ChannelModelPricing{{
|
||||
Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken,
|
||||
Intervals: []PricingInterval{{MinTokens: 0, InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(40e-6)}},
|
||||
}}, geminiLadderCatalogStub(t))
|
||||
group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true}
|
||||
gid := group.ID
|
||||
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group})
|
||||
require.Equal(t, PricingSourceChannel, resolved.Source)
|
||||
require.NotEmpty(t, resolved.Intervals)
|
||||
|
||||
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
|
||||
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||||
Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
|
||||
Resolver: resolver, Resolved: resolved,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 300000*10e-6, got.InputCost, 1e-9)
|
||||
require.InDelta(t, 1000*40e-6, got.OutputCost, 1e-9)
|
||||
require.False(t, got.LongContextBillingApplied)
|
||||
}
|
||||
|
||||
// 目录阶梯跟随分组长上下文开关;开启时为整单换档(输入 ×2、输出 ×1.5)。
|
||||
func TestCalculateTokenCostForRequest_CatalogLadderFollowsGroupToggle(t *testing.T) {
|
||||
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiLadderCatalogStub(t))
|
||||
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
|
||||
rule := bs.LegacyLongContextRule(PlatformGemini)
|
||||
|
||||
for _, enabled := range []bool{true, false} {
|
||||
group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: enabled}
|
||||
@@ -89,32 +118,74 @@ func TestCalculateTokenCostForRequest_LegacyRuleFollowsGroupToggle(t *testing.T)
|
||||
|
||||
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||||
Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
|
||||
Resolver: resolver, Resolved: resolved, LegacyLongContext: rule,
|
||||
Resolver: resolver, Resolved: resolved,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if enabled {
|
||||
want, err := bs.CalculateCostWithLongContext("gemini-2.5-pro", tokens, 1, rule.Threshold, rule.Multiplier)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, got)
|
||||
// 输入 200K × 1.25e-6 + 超出 100K × 1.25e-6 × 2 = 0.5;输出 1000 × 10e-6 = 0.01。
|
||||
// 旧路径的加倍只体现在 ActualCost(分项 InputCost 不含倍率),探针也据此取值。
|
||||
require.InDelta(t, 0.51, got.ActualCost, 1e-9)
|
||||
// 300K × 1.25e-6 × 2 = 0.75;1000 × 10e-6 × 1.5 = 0.015
|
||||
require.InDelta(t, 0.765, got.ActualCost, 1e-9)
|
||||
require.True(t, got.LongContextBillingApplied)
|
||||
} else {
|
||||
want, err := bs.CalculateCostUnified(CostInput{
|
||||
Ctx: context.Background(), Model: "gemini-2.5-pro", GroupID: &gid, Group: group, Tokens: tokens,
|
||||
RequestCount: 1, RateMultiplier: 1, Resolver: resolver, Resolved: resolved,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, got)
|
||||
require.InDelta(t, 0.385, got.ActualCost, 1e-9)
|
||||
require.False(t, got.LongContextBillingApplied)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 目录阶梯对缓存分项同样生效:cache_read / cache_creation 随输入倍率整单换档,
|
||||
// 阈值判定计入全部输入侧 token(input + cache_creation + cache_read)。
|
||||
func TestCalculateTokenCostForRequest_GeminiLadderAppliesToCacheItems(t *testing.T) {
|
||||
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiLadderCatalogStub(t))
|
||||
group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true}
|
||||
gid := group.ID
|
||||
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group})
|
||||
require.Equal(t, PricingSourceLiteLLM, resolved.Source)
|
||||
|
||||
calc := func(tokens UsageTokens) *CostBreakdown {
|
||||
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||||
Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
|
||||
Resolver: resolver, Resolved: resolved,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return got
|
||||
}
|
||||
|
||||
// 输入侧合计 90K + 100K + 20K = 210K > 200K:所有分项按高档计。
|
||||
// 不计 cache_creation 时只有 110K,不会过阈值——用例同时守住"缓存写入 token 计入阈值"。
|
||||
above := calc(UsageTokens{InputTokens: 90000, CacheCreationTokens: 100000, CacheReadTokens: 20000, OutputTokens: 1000})
|
||||
require.True(t, above.LongContextBillingApplied)
|
||||
require.InDelta(t, 90000*1.25e-6*2, above.InputCost, 1e-9)
|
||||
require.InDelta(t, 100000*1.25e-6*2, above.CacheCreationCost, 1e-9)
|
||||
require.InDelta(t, 20000*1.25e-7*2, above.CacheReadCost, 1e-9)
|
||||
require.InDelta(t, 1000*1e-5*1.5, above.OutputCost, 1e-9)
|
||||
|
||||
// 输入侧合计 50K + 100K + 40K = 190K ≤ 200K:按基础价计
|
||||
below := calc(UsageTokens{InputTokens: 50000, CacheCreationTokens: 100000, CacheReadTokens: 40000, OutputTokens: 1000})
|
||||
require.False(t, below.LongContextBillingApplied)
|
||||
require.InDelta(t, 50000*1.25e-6, below.InputCost, 1e-9)
|
||||
require.InDelta(t, 100000*1.25e-6, below.CacheCreationCost, 1e-9)
|
||||
require.InDelta(t, 40000*1.25e-7, below.CacheReadCost, 1e-9)
|
||||
}
|
||||
|
||||
// 目录条目没有阶梯字段时,开关开启也不产生阶梯。
|
||||
func TestCalculateTokenCostForRequest_NoLadderFieldsMeansNoLadder(t *testing.T) {
|
||||
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiCatalogStub())
|
||||
group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true}
|
||||
gid := group.ID
|
||||
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group})
|
||||
|
||||
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
|
||||
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||||
Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
|
||||
Resolver: resolver, Resolved: resolved,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 0.385, got.ActualCost, 1e-9)
|
||||
require.False(t, got.LongContextBillingApplied)
|
||||
}
|
||||
|
||||
func TestCalculateTokenCostForRequest_BuiltInPricingUsesUnifiedPath(t *testing.T) {
|
||||
bs, resolver := newTokenCostTestEnv(t, PlatformOpenAI, nil, nil)
|
||||
bs, resolver := newTokenCostTestEnv(t, PlatformOpenAI, nil, newStubPricingServiceFromJSON(t, openAILadderCatalogJSON))
|
||||
group := &Group{ID: 100, Platform: PlatformOpenAI, LongContextPricingEnabled: true}
|
||||
gid := group.ID
|
||||
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
|
||||
|
||||
@@ -87,24 +87,25 @@ 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"`
|
||||
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"`
|
||||
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 渠道模型定价的分时倍率配置。
|
||||
@@ -131,6 +132,7 @@ type PricingInterval struct {
|
||||
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"`
|
||||
InputMultiplier *float64 `json:"input_multiplier"`
|
||||
OutputMultiplier *float64 `json:"output_multiplier"`
|
||||
@@ -377,6 +379,7 @@ func validateIntervalPrices(iv *PricingInterval, idx int) error {
|
||||
{"input_price", iv.InputPrice},
|
||||
{"output_price", iv.OutputPrice},
|
||||
{"cache_write_price", iv.CacheWritePrice},
|
||||
{"cache_write_1h_price", iv.CacheWrite1hPrice},
|
||||
{"cache_read_price", iv.CacheReadPrice},
|
||||
{"per_request_price", iv.PerRequestPrice},
|
||||
}
|
||||
|
||||
@@ -141,13 +141,13 @@ func pricingNeedsFallback(p *ChannelModelPricing) bool {
|
||||
return true
|
||||
}
|
||||
if p.InputPrice != nil || p.OutputPrice != nil ||
|
||||
p.CacheWritePrice != nil || p.CacheReadPrice != nil ||
|
||||
p.CacheWritePrice != nil || p.CacheWrite1hPrice != nil || p.CacheReadPrice != nil ||
|
||||
p.ImageOutputPrice != nil || p.PerRequestPrice != nil {
|
||||
return false
|
||||
}
|
||||
for _, iv := range p.Intervals {
|
||||
if iv.InputPrice != nil || iv.OutputPrice != nil ||
|
||||
iv.CacheWritePrice != nil || iv.CacheReadPrice != nil ||
|
||||
iv.CacheWritePrice != nil || iv.CacheWrite1hPrice != nil || iv.CacheReadPrice != nil ||
|
||||
iv.PerRequestPrice != nil {
|
||||
return false
|
||||
}
|
||||
@@ -188,12 +188,13 @@ func synthesizePricingFromLiteLLM(lp *LiteLLMModelPricing, existing *ChannelMode
|
||||
}
|
||||
}
|
||||
return &ChannelModelPricing{
|
||||
BillingMode: mode,
|
||||
InputPrice: nonZeroPtr(lp.InputCostPerToken),
|
||||
OutputPrice: nonZeroPtr(lp.OutputCostPerToken),
|
||||
CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -725,6 +725,7 @@ func checkPricesNotNegative(p ChannelModelPricing) error {
|
||||
{"input_price", p.InputPrice},
|
||||
{"output_price", p.OutputPrice},
|
||||
{"cache_write_price", p.CacheWritePrice},
|
||||
{"cache_write_1h_price", p.CacheWrite1hPrice},
|
||||
{"cache_read_price", p.CacheReadPrice},
|
||||
{"image_input_price", p.ImageInputPrice},
|
||||
{"image_output_price", p.ImageOutputPrice},
|
||||
@@ -752,7 +753,7 @@ func checkPricesNotNegative(p ChannelModelPricing) error {
|
||||
func checkIntervalsHavePrices(p ChannelModelPricing) error {
|
||||
for _, iv := range p.Intervals {
|
||||
if iv.InputPrice == nil && iv.OutputPrice == nil &&
|
||||
iv.CacheWritePrice == nil && iv.CacheReadPrice == nil &&
|
||||
iv.CacheWritePrice == nil && iv.CacheWrite1hPrice == nil && iv.CacheReadPrice == nil &&
|
||||
iv.PerRequestPrice == nil && iv.InputMultiplier == nil &&
|
||||
iv.OutputMultiplier == nil && iv.CacheWriteMultiplier == nil &&
|
||||
iv.CacheReadMultiplier == nil {
|
||||
|
||||
@@ -469,7 +469,7 @@ func TestBuildUpstreamModelsRequest_CNProviders(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestGetAPIProtocol 验证协议凭证维度的平台校验矩阵:
|
||||
// responses 仅 deepseek;缺失/非法值回退 chat_completions(与旧行为一致)。
|
||||
// responses 仅 deepseek / kimi;缺失/非法值回退 chat_completions(与旧行为一致)。
|
||||
func TestGetAPIProtocol(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -486,15 +486,24 @@ func TestGetAPIProtocol(t *testing.T) {
|
||||
require.Equal(t, APIProtocolAnthropic, mk(PlatformKimi, APIProtocolAnthropic).GetAPIProtocol())
|
||||
require.Equal(t, APIProtocolAnthropic, mk(PlatformDeepseek, APIProtocolAnthropic).GetAPIProtocol())
|
||||
require.Equal(t, APIProtocolResponses, mk(PlatformDeepseek, APIProtocolResponses).GetAPIProtocol())
|
||||
require.Equal(t, APIProtocolResponses, mk(PlatformKimi, APIProtocolResponses).GetAPIProtocol())
|
||||
require.Equal(t, APIProtocolAdaptive, mk(PlatformKimi, APIProtocolAdaptive).GetAPIProtocol())
|
||||
require.Equal(t, APIProtocolAdaptive, mk(PlatformZhipu, APIProtocolAdaptive).GetAPIProtocol())
|
||||
require.Equal(t, APIProtocolAdaptive, mk(PlatformDeepseek, APIProtocolAdaptive).GetAPIProtocol())
|
||||
require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, APIProtocolResponses).GetAPIProtocol(), "kimi 无 responses 端点")
|
||||
require.Equal(t, APIProtocolChatCompletions, mk(PlatformZhipu, APIProtocolResponses).GetAPIProtocol(), "zhipu 无 responses 端点")
|
||||
require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, "bogus").GetAPIProtocol(), "非法值回退默认")
|
||||
require.Equal(t, APIProtocolChatCompletions, (&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}).GetAPIProtocol(), "非 CN 供应商恒为默认")
|
||||
}
|
||||
|
||||
func TestSupportsNativeCNResponses(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.True(t, (&Account{Platform: PlatformDeepseek}).SupportsNativeCNResponses())
|
||||
require.True(t, (&Account{Platform: PlatformKimi}).SupportsNativeCNResponses())
|
||||
require.True(t, (&Account{Platform: PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}}).SupportsNativeCNResponses())
|
||||
require.False(t, (&Account{Platform: PlatformZhipu}).SupportsNativeCNResponses())
|
||||
require.False(t, (&Account{Platform: PlatformOpenAI}).SupportsNativeCNResponses())
|
||||
}
|
||||
|
||||
func TestAdaptiveProtocolBaseURLs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -639,10 +648,12 @@ func TestBuildOpenAIResponsesURLForPlatform(t *testing.T) {
|
||||
require.Equal(t, "https://relay.example.com/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformDeepseek, "https://relay.example.com/v1"))
|
||||
require.Equal(t, "https://api.openai.com/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformOpenAI, "https://api.openai.com"))
|
||||
require.Equal(t, "https://open.bigmodel.cn/api/paas/v4/responses", buildOpenAIResponsesURLForPlatform(PlatformZhipu, "https://open.bigmodel.cn/api/paas/v4"))
|
||||
require.Equal(t, "https://api.moonshot.cn/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformKimi, "https://api.moonshot.cn/v1"))
|
||||
require.Equal(t, "https://api.kimi.com/coding/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformKimi, "https://api.kimi.com/coding/v1"))
|
||||
}
|
||||
|
||||
// TestNormalizeDeepSeekResponsesRequestBody 无状态适配:强制 store=false、
|
||||
// 清除 previous_response_id;非 deepseek responses 协议原样返回。
|
||||
// 清除 previous_response_id;非原生 CN Responses 协议原样返回。
|
||||
func TestNormalizeDeepSeekResponsesRequestBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -668,6 +679,22 @@ func TestNormalizeDeepSeekResponsesRequestBody(t *testing.T) {
|
||||
deepseekCC := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}
|
||||
require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(deepseekCC, body)))
|
||||
|
||||
kimiResponses := &Account{
|
||||
Platform: PlatformKimi, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_protocol": APIProtocolResponses},
|
||||
}
|
||||
kimiNormalized := normalizeDeepSeekResponsesRequestBody(kimiResponses, body)
|
||||
require.False(t, gjson.GetBytes(kimiNormalized, "store").Bool())
|
||||
require.False(t, gjson.GetBytes(kimiNormalized, "previous_response_id").Exists())
|
||||
|
||||
kimiCodingAdaptive := &Account{
|
||||
Platform: PlatformKimi, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_protocol": APIProtocolAdaptive, "account_mode": AccountModeCoding},
|
||||
}
|
||||
kimiCodingNormalized := normalizeDeepSeekResponsesRequestBody(kimiCodingAdaptive, body)
|
||||
require.False(t, gjson.GetBytes(kimiCodingNormalized, "store").Bool())
|
||||
require.False(t, gjson.GetBytes(kimiCodingNormalized, "previous_response_id").Exists())
|
||||
|
||||
// openai 账号原样返回
|
||||
openai := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(openai, body)))
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 背景
|
||||
// ============================================================================
|
||||
//
|
||||
// Anthropic 上游对 body.fallbacks / body.fallback_credit_token 字段实施
|
||||
// Pydantic extra='forbid' 校验:当且仅当 anthropic-beta header 含
|
||||
// server-side-fallback-2026-07-01(fallback_credit_token 额外接受
|
||||
// fallback-credit-2026-07-01 / fallback-credit-2026-06-01)时接受。
|
||||
// 否则报:
|
||||
// "fallbacks: Extra inputs are not permitted"
|
||||
//
|
||||
// fallbacks 是 beta Messages API 的 server-side refusal fallback 字段;本仓
|
||||
// 不写入该字段,全部来自客户端(Claude Code / SDK / OpenCode 等)透传。
|
||||
// OAuth mimic 用 FullClaudeCodeMimicryBetas 覆盖客户端 beta(不含 fallback
|
||||
// beta),因此必须在出口按最终 beta header 条件 strip,与 context_management
|
||||
// 的对称约束同构。策略是"剥字段,不注入 beta":fallback 会换模型、改计费,
|
||||
// 不允许当默认打开。
|
||||
//
|
||||
// 本文件覆盖:
|
||||
// 1) sanitizeAnthropicBodyForBetaTokens 对 fallbacks / fallback_credit_token
|
||||
// 的条件 strip(以及与 context_management 的组合行为)
|
||||
// 2) buildUpstreamRequest OAuth mimic / API-key passthrough 端到端
|
||||
// 3) Bedrock 路径的对称 strip(PrepareBedrockRequestBodyWithTokens /
|
||||
// sanitizeBedrockCCFields)
|
||||
|
||||
// ============================================================================
|
||||
// sanitizeAnthropicBodyForBetaTokens — fallbacks / fallback_credit_token
|
||||
// ============================================================================
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_NoFallbackFieldsNoChange(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5","messages":[]}`)
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20")
|
||||
require.False(t, changed)
|
||||
require.Equal(t, string(body), string(out))
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_FallbacksKeptWhenBetaPresent(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-opus-4-7","fallbacks":"default","messages":[]}`)
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body,
|
||||
"claude-code-20250219,oauth-2025-04-20,server-side-fallback-2026-07-01")
|
||||
require.False(t, changed, "客户端 header 已带 server-side-fallback beta → 字段保留(不过度删除)")
|
||||
require.True(t, gjson.GetBytes(out, "fallbacks").Exists())
|
||||
require.Equal(t, "default", gjson.GetBytes(out, "fallbacks").String())
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_FallbacksStrippedWhenBetaMissing(t *testing.T) {
|
||||
// 客户端透传的两种形态:字符串 "default" 与模型数组
|
||||
for name, fallbacks := range map[string]string{
|
||||
"string_default": `"fallbacks":"default"`,
|
||||
"model_array": `"fallbacks":["claude-opus-4-6","claude-sonnet-4-6"]`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5",` + fallbacks + `,"messages":[]}`)
|
||||
// 模拟 OAuth mimic / 默认 API-key beta:只有 oauth/interleaved,无 fallback beta
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body,
|
||||
"oauth-2025-04-20,interleaved-thinking-2025-05-14")
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(out, "fallbacks").Exists(),
|
||||
"header 不含 server-side-fallback beta 时必须 strip fallbacks,否则上游 400")
|
||||
require.True(t, gjson.GetBytes(out, "messages").Exists(), "strip 不得误伤其他字段")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_FallbacksStrippedWhenHeaderEmpty(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5","fallbacks":"default","messages":[]}`)
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "")
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(out, "fallbacks").Exists())
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_FallbackCreditTokenStrippedWhenCreditBetaMissing(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5","fallback_credit_token":"tok_123","messages":[]}`)
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20,interleaved-thinking-2025-05-14")
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(out, "fallback_credit_token").Exists(),
|
||||
"缺 credit/fallback beta 时必须 strip fallback_credit_token")
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_FallbackCreditTokenKeptWithAnyAcceptedBeta(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5","fallback_credit_token":"tok_123","messages":[]}`)
|
||||
// 三个 beta token 任意一个在 header 中都必须保留字段
|
||||
for _, beta := range []string{
|
||||
claude.BetaServerSideFallback,
|
||||
claude.BetaFallbackCredit,
|
||||
claude.BetaFallbackCreditLegacy,
|
||||
} {
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20,"+beta)
|
||||
require.Falsef(t, changed, "header 含 %s 时 fallback_credit_token 必须保留", beta)
|
||||
require.Truef(t, gjson.GetBytes(out, "fallback_credit_token").Exists(),
|
||||
"header 含 %s 时 fallback_credit_token 必须保留", beta)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 组合场景:只带 context-management beta → 剥 fallbacks,保留 context_management
|
||||
// (守住"早退导致 fallbacks 漏洗"与"过度删除 context_management"两个方向的回归)
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_StripsFallbacksKeepsContextManagement(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"fallbacks":"default","messages":[]}`)
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "context-management-2025-06-27")
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(out, "fallbacks").Exists(),
|
||||
"header 只有 context-management beta → fallbacks 必须 strip")
|
||||
require.True(t, gjson.GetBytes(out, "context_management").Exists(),
|
||||
"context-management beta 在 header 中 → context_management 不得被误删")
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_KeepsBothWhenBothBetasPresent(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-opus-4-7","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"fallbacks":"default","fallback_credit_token":"tok_123","messages":[]}`)
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body,
|
||||
"context-management-2025-06-27,server-side-fallback-2026-07-01")
|
||||
require.False(t, changed, "两个 beta 都在 header 中 → 所有字段保留")
|
||||
require.True(t, gjson.GetBytes(out, "context_management").Exists())
|
||||
require.True(t, gjson.GetBytes(out, "fallbacks").Exists())
|
||||
require.True(t, gjson.GetBytes(out, "fallback_credit_token").Exists())
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_EmptyBodyUnchanged(t *testing.T) {
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens([]byte{}, "server-side-fallback-2026-07-01")
|
||||
require.False(t, changed)
|
||||
require.Empty(t, out)
|
||||
|
||||
out, changed = sanitizeAnthropicBodyForBetaTokens(nil, "server-side-fallback-2026-07-01")
|
||||
require.False(t, changed)
|
||||
require.Empty(t, out)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// buildUpstreamRequest 端到端
|
||||
// 挡住未来某人忘调 sanitize / 将 sanitize 挪到 CCH 之后 等 regression。
|
||||
// ============================================================================
|
||||
|
||||
// OAuth mimic:FullClaudeCodeMimicryBetas 不含 fallback beta → body.fallbacks
|
||||
// 必须被 strip,且 outgoing anthropic-beta 不得注入 server-side-fallback beta
|
||||
// (剥字段,不注入 beta)。
|
||||
func TestBuildUpstreamRequest_OAuthMimicHaiku_StripsFallbacksEndToEnd(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
account := &Account{ID: 601, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{"access_token": "oauth-tok"},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
// 客户端默认透传 "fallbacks":"default"(Claude Code / SDK / OpenCode 等)
|
||||
body := []byte(`{"model":"claude-haiku-4-5","fallbacks":"default","messages":[]}`)
|
||||
svc := &GatewayService{cfg: &config.Config{}}
|
||||
req, _, err := svc.buildUpstreamRequest(
|
||||
context.Background(), c, account, body,
|
||||
"oauth-tok", "oauth", "claude-haiku-4-5", false, true, // mimicClaudeCode=true
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
outBody := readUpstreamBodyForTest(t, req)
|
||||
outBeta := getHeaderRaw(req.Header, "anthropic-beta")
|
||||
|
||||
require.False(t, gjson.GetBytes(outBody, "fallbacks").Exists(),
|
||||
"OAuth mimic 端到端:mimic beta 集合不含 fallback beta → outgoing body 必须没有 fallbacks,"+
|
||||
"否则上游报 fallbacks: Extra inputs are not permitted")
|
||||
require.False(t, anthropicBetaTokensContains(outBeta, claude.BetaServerSideFallback),
|
||||
"修复策略是剥字段而非注入 beta:outgoing anthropic-beta 不得含 server-side-fallback beta")
|
||||
require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement),
|
||||
"mimic beta 集合本身不受影响")
|
||||
}
|
||||
|
||||
// API-key passthrough + 客户端 header 未带 fallback beta → strip
|
||||
func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_StripsFallbacksWhenClientHeaderMissingBeta(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
// 客户端仅带 oauth beta,不带 server-side-fallback-2026-07-01
|
||||
c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20")
|
||||
|
||||
body := []byte(`{"model":"claude-haiku-4-5","fallbacks":"default","messages":[]}`)
|
||||
svc := &GatewayService{cfg: &config.Config{}}
|
||||
req, _, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
|
||||
context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.False(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "fallbacks").Exists(),
|
||||
"API-key passthrough + 客户端未带 fallback beta → strip body 字段")
|
||||
}
|
||||
|
||||
// API-key passthrough + 客户端 header 带 fallback beta → 保留(不过度删除)
|
||||
func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_PreservesFallbacksWhenClientHeaderHasBeta(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20,server-side-fallback-2026-07-01")
|
||||
|
||||
// 模型数组形态:有 beta 时必须原样保留
|
||||
body := []byte(`{"model":"claude-opus-4-7","fallbacks":["claude-opus-4-6","claude-sonnet-4-6"],"messages":[]}`)
|
||||
svc := &GatewayService{cfg: &config.Config{}}
|
||||
req, _, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
|
||||
context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
outBody := readUpstreamBodyForTest(t, req)
|
||||
require.True(t, gjson.GetBytes(outBody, "fallbacks").Exists(),
|
||||
"客户端 header 带 server-side-fallback beta → 字段保留(不过度删除)")
|
||||
fallbacks := gjson.GetBytes(outBody, "fallbacks").Array()
|
||||
require.Len(t, fallbacks, 2)
|
||||
require.Equal(t, "claude-opus-4-6", fallbacks[0].String())
|
||||
require.Equal(t, "claude-sonnet-4-6", fallbacks[1].String())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bedrock 对称 strip
|
||||
// ============================================================================
|
||||
|
||||
// fallback beta token 不在 bedrockSupportedBetaTokens 白名单内(会被
|
||||
// filterBedrockBetaTokens 过滤),因此条件 strip 实际总会剥除——这是预期。
|
||||
func TestPrepareBedrockRequestBodyWithTokens_FallbacksRequireSupportedBeta(t *testing.T) {
|
||||
modelID := "us.anthropic.claude-opus-4-6-v1"
|
||||
|
||||
t.Run("strips fallbacks when final tokens omit server-side-fallback beta", func(t *testing.T) {
|
||||
input := `{
|
||||
"messages":[{"role":"user","content":"hi"}],
|
||||
"max_tokens":100,
|
||||
"fallbacks":"default",
|
||||
"fallback_credit_token":"tok_123"
|
||||
}`
|
||||
betaTokens := []string{"context-1m-2025-08-07"}
|
||||
|
||||
result, err := PrepareBedrockRequestBodyWithTokens([]byte(input), modelID, betaTokens, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, gjson.GetBytes(result, "fallbacks").Exists())
|
||||
assert.False(t, gjson.GetBytes(result, "fallback_credit_token").Exists())
|
||||
assert.Equal(t, "hi", gjson.GetBytes(result, "messages.0.content").String())
|
||||
assert.Equal(t, int64(100), gjson.GetBytes(result, "max_tokens").Int())
|
||||
})
|
||||
|
||||
t.Run("strips fallbacks even when client passes server-side-fallback token (not whitelisted)", func(t *testing.T) {
|
||||
input := `{"messages":[{"role":"user","content":"hi"}],"max_tokens":100,"fallbacks":"default"}`
|
||||
|
||||
result, err := PrepareBedrockRequestBodyWithTokens(
|
||||
[]byte(input), modelID, []string{claude.BetaServerSideFallback}, false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, gjson.GetBytes(result, "fallbacks").Exists(),
|
||||
"Bedrock 白名单不含 fallback beta → 条件 strip 总会剥除(预期)")
|
||||
for _, name := range bedrockAnthropicBetaNames(result) {
|
||||
assert.NotEqual(t, claude.BetaServerSideFallback, name,
|
||||
"fallback beta 不得进入 Bedrock anthropic_beta 白名单输出")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("leaves body without fallback fields otherwise intact", func(t *testing.T) {
|
||||
input := `{"messages":[{"role":"user","content":"hi"}],"max_tokens":100}`
|
||||
|
||||
result, err := PrepareBedrockRequestBodyWithTokens([]byte(input), modelID, nil, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, gjson.GetBytes(result, "fallbacks").Exists())
|
||||
assert.False(t, gjson.GetBytes(result, "fallback_credit_token").Exists())
|
||||
assert.False(t, gjson.GetBytes(result, "context_management").Exists())
|
||||
assert.Equal(t, "hi", gjson.GetBytes(result, "messages.0.content").String())
|
||||
})
|
||||
}
|
||||
|
||||
// sanitizeBedrockCCFields:fallbacks / fallback_credit_token 是 Anthropic 直连
|
||||
// beta API 专有字段,Bedrock 无对应 beta → 无条件剥除。
|
||||
func TestSanitizeBedrockCCFields_StripsFallbacksUnconditionally(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-opus-4-6","context_management":{"edits":[]},"fallbacks":"default","fallback_credit_token":"tok_123","messages":[]}`)
|
||||
result := sanitizeBedrockCCFields(body)
|
||||
|
||||
assert.False(t, gjson.GetBytes(result, "fallbacks").Exists())
|
||||
assert.False(t, gjson.GetBytes(result, "fallback_credit_token").Exists())
|
||||
assert.False(t, gjson.GetBytes(result, "context_management").Exists())
|
||||
assert.True(t, gjson.GetBytes(result, "messages").Exists())
|
||||
}
|
||||
@@ -456,45 +456,6 @@ func TestGatewayServiceRecordUsage_UsageLogWriteErrorDoesNotSkipBilling(t *testi
|
||||
require.Equal(t, 1, quotaSvc.quotaCalls)
|
||||
}
|
||||
|
||||
func TestGatewayServiceRecordUsageWithLongContext_BillingUsesDetachedContext(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: false, err: context.DeadlineExceeded}
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
subRepo := &openAIRecordUsageSubRepoStub{}
|
||||
quotaSvc := &openAIRecordUsageAPIKeyQuotaStub{}
|
||||
svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, subRepo)
|
||||
|
||||
reqCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := svc.RecordUsageWithLongContext(reqCtx, &RecordUsageLongContextInput{
|
||||
Result: &ForwardResult{
|
||||
RequestID: "gateway_long_context_detached_ctx",
|
||||
Usage: ClaudeUsage{
|
||||
InputTokens: 12,
|
||||
OutputTokens: 8,
|
||||
},
|
||||
Model: "claude-sonnet-4",
|
||||
Duration: time.Second,
|
||||
},
|
||||
APIKey: &APIKey{
|
||||
ID: 502,
|
||||
Quota: 100,
|
||||
},
|
||||
User: &User{ID: 602},
|
||||
Account: &Account{ID: 702},
|
||||
LongContextThreshold: 200000,
|
||||
LongContextMultiplier: 2,
|
||||
APIKeyService: quotaSvc,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, usageRepo.calls)
|
||||
require.Equal(t, 1, userRepo.deductCalls)
|
||||
require.NoError(t, userRepo.lastCtxErr)
|
||||
require.Equal(t, 1, quotaSvc.quotaCalls)
|
||||
require.NoError(t, quotaSvc.lastQuotaCtxErr)
|
||||
}
|
||||
|
||||
func TestGatewayServiceRecordUsage_UsesFallbackRequestIDForUsageLog(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{}
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
@@ -898,9 +899,22 @@ const anthropicBetaContextManagementToken = "context-management-2025-06-27"
|
||||
// - 若两侧不一致上游 Pydantic schema 拒收:
|
||||
// "context_management: Extra inputs are not permitted"
|
||||
//
|
||||
// 本函数按最终发送的 anthropic-beta header 决定是否保留 body 中的
|
||||
// context_management 字段:缺 beta token → strip。这将限制完全建立在
|
||||
// "能力维度" 上,与 model 名 / token type / mimicry 子路径无关。
|
||||
// fallbacks 场景(与 context_management 同构):
|
||||
// - `fallbacks` / `fallback_credit_token` 是 beta Messages API 的
|
||||
// server-side refusal fallback 字段;标准 Messages schema 没有它们,
|
||||
// 客户端(Claude Code / SDK / OpenCode 等)最近开始默认透传
|
||||
// `"fallbacks":"default"`(或模型列表)
|
||||
// - 上游接受的前提是 anthropic-beta 含 `server-side-fallback-2026-07-01`
|
||||
// (fallback_credit_token 额外接受 credit beta,见下)
|
||||
// - 缺 token 时上游 Pydantic extra='forbid' 拒收:
|
||||
// "fallbacks: Extra inputs are not permitted"
|
||||
// - 本仓不写入该字段,全部来自客户端透传;OAuth mimic 用
|
||||
// FullClaudeCodeMimicryBetas 覆盖客户端 beta(该列表不含 fallback beta),
|
||||
// 若不 strip,body 字段与 header 不对称 → 所有模型 400
|
||||
//
|
||||
// 本函数按最终发送的 anthropic-beta header 决定是否保留 body 中的上述字段:
|
||||
// 缺对应 beta token → strip;客户端 header 已带对应 beta → 保留(不过度删除)。
|
||||
// 这将限制完全建立在 "能力维度" 上,与 model 名 / token type / mimicry 子路径无关。
|
||||
//
|
||||
// 调用约束:必须在 CCH 签名之前调用,否则签名 hash 与最终 body
|
||||
// 不一致,上游会以 third-party 拒收。
|
||||
@@ -911,23 +925,58 @@ func sanitizeAnthropicBodyForBetaTokens(body []byte, anthropicBetaHeader string)
|
||||
if len(body) == 0 {
|
||||
return body, false
|
||||
}
|
||||
if !gjson.GetBytes(body, "context_management").Exists() {
|
||||
|
||||
changed := false
|
||||
|
||||
// context_management:需要 context-management beta。
|
||||
if b, deleted := stripAnthropicBodyFieldUnlessBeta(
|
||||
body, "context_management", anthropicBetaHeader, anthropicBetaContextManagementToken,
|
||||
); deleted {
|
||||
body, changed = b, true
|
||||
}
|
||||
|
||||
// fallbacks:server-side refusal fallback,仅接受 server-side-fallback beta。
|
||||
if b, deleted := stripAnthropicBodyFieldUnlessBeta(
|
||||
body, "fallbacks", anthropicBetaHeader, claude.BetaServerSideFallback,
|
||||
); deleted {
|
||||
body, changed = b, true
|
||||
}
|
||||
|
||||
// fallback_credit_token:server-side-fallback 或(新旧任一)fallback-credit beta
|
||||
// 任意一个即可保留。
|
||||
if b, deleted := stripAnthropicBodyFieldUnlessBeta(
|
||||
body, "fallback_credit_token", anthropicBetaHeader,
|
||||
claude.BetaServerSideFallback, claude.BetaFallbackCredit, claude.BetaFallbackCreditLegacy,
|
||||
); deleted {
|
||||
body, changed = b, true
|
||||
}
|
||||
|
||||
return body, changed
|
||||
}
|
||||
|
||||
// stripAnthropicBodyFieldUnlessBeta 当 field 存在且 anthropic-beta header 不含
|
||||
// requiredTokens 中**任何一个** token 时删除该字段(保留条件:含任一 required token)。
|
||||
// 单 token 调用即「缺该 beta 则 strip」。返回 (newBody, deleted)。
|
||||
func stripAnthropicBodyFieldUnlessBeta(body []byte, field, anthropicBetaHeader string, requiredTokens ...string) ([]byte, bool) {
|
||||
if !gjson.GetBytes(body, field).Exists() {
|
||||
return body, false
|
||||
}
|
||||
if anthropicBetaTokensContains(anthropicBetaHeader, anthropicBetaContextManagementToken) {
|
||||
return body, false
|
||||
for _, token := range requiredTokens {
|
||||
if anthropicBetaTokensContains(anthropicBetaHeader, token) {
|
||||
return body, false
|
||||
}
|
||||
}
|
||||
if b, err := sjson.DeleteBytes(body, "context_management"); err == nil {
|
||||
return b, true
|
||||
} else {
|
||||
b, err := sjson.DeleteBytes(body, field)
|
||||
if err != nil {
|
||||
// 不应发生:gjson 刚验证过字段存在 + body 是合法 JSON。如果 sjson 仍报错,
|
||||
// 调用方会拿到 (body, false),但此前 computeFinalAnthropicBeta 已按“strip 后”
|
||||
// 计算了 finalBeta——两侧会不一致。记录 warning 最小限度提醒运维。
|
||||
// 调用方会拿到原 body(视为未删除),但此前 computeFinalAnthropicBeta 可能已按
|
||||
// "strip 后" 计算了 finalBeta——两侧会不一致。记录 warning 最小限度提醒运维。
|
||||
logger.LegacyPrintf("service.gateway",
|
||||
"[CtxMgmtSanitize] sjson.DeleteBytes failed unexpectedly: %v (body len=%d). "+
|
||||
"body and final anthropic-beta header may be out of sync.", err, len(body))
|
||||
"[BetaFieldSanitize] sjson.DeleteBytes(%s) failed unexpectedly: %v (body len=%d). "+
|
||||
"body and final anthropic-beta header may be out of sync.", field, err, len(body))
|
||||
return body, false
|
||||
}
|
||||
return body, false
|
||||
return b, true
|
||||
}
|
||||
|
||||
// anthropicBetaTokensContains 检测逗号分隔的 anthropic-beta header 是否含指定 token。
|
||||
|
||||
@@ -596,13 +596,6 @@ func writeUsageLogBestEffort(ctx context.Context, repo UsageLogRepository, usage
|
||||
}
|
||||
}
|
||||
|
||||
// recordUsageOpts 内部选项,参数化普通计费与长上下文计费的差异点。
|
||||
type recordUsageOpts struct {
|
||||
// 长上下文计费(仅 Gemini 路径需要)
|
||||
LongContextThreshold int
|
||||
LongContextMultiplier float64
|
||||
}
|
||||
|
||||
// RecordUsage 记录使用量并扣费(或更新订阅用量)
|
||||
func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInput) error {
|
||||
return s.recordUsageCore(ctx, &recordUsageCoreInput{
|
||||
@@ -622,54 +615,6 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
|
||||
APIKeyService: input.APIKeyService,
|
||||
QuotaPlatform: input.QuotaPlatform,
|
||||
ChannelUsageFields: input.ChannelUsageFields,
|
||||
}, &recordUsageOpts{})
|
||||
}
|
||||
|
||||
// RecordUsageLongContextInput 记录使用量的输入参数(支持长上下文双倍计费)
|
||||
type RecordUsageLongContextInput struct {
|
||||
Result *ForwardResult
|
||||
APIKey *APIKey
|
||||
User *User
|
||||
Account *Account
|
||||
Subscription *UserSubscription // 可选:订阅信息
|
||||
PricingAt time.Time // token 售价固定时刻;零值保持既有的记录时刻语义
|
||||
InboundEndpoint string // 入站端点(客户端请求路径)
|
||||
UpstreamEndpoint string // 上游端点(标准化后的上游路径)
|
||||
UserAgent string // 请求的 User-Agent
|
||||
IPAddress string // 请求的客户端 IP 地址
|
||||
SessionID string // 客户端显式会话标识(session_id / X-Session-Id 等请求头),仅用于用量行会话关联
|
||||
RequestPayloadHash string // 请求体语义哈希,用于降低 request_id 误复用时的静默误去重风险
|
||||
LongContextThreshold int // 长上下文阈值(如 200000)
|
||||
LongContextMultiplier float64 // 超出阈值部分的倍率(如 2.0)
|
||||
ForceCacheBilling bool // 强制缓存计费:将 input_tokens 转为 cache_read 计费(用于粘性会话切换)
|
||||
APIKeyService APIKeyQuotaUpdater // API Key 配额服务(可选)
|
||||
QuotaPlatform string // user×platform 配额计量平台:handler 在请求 ctx 内经 QuotaPlatform() 算定后传入(后扣运行在 worker 池 background ctx 上,取不到 ForcePlatform)
|
||||
|
||||
ChannelUsageFields // 渠道映射信息(由 handler 在 Forward 前解析)
|
||||
}
|
||||
|
||||
// RecordUsageWithLongContext 记录使用量并扣费,支持长上下文双倍计费(用于 Gemini)
|
||||
func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *RecordUsageLongContextInput) error {
|
||||
return s.recordUsageCore(ctx, &recordUsageCoreInput{
|
||||
Result: input.Result,
|
||||
APIKey: input.APIKey,
|
||||
User: input.User,
|
||||
Account: input.Account,
|
||||
Subscription: input.Subscription,
|
||||
PricingAt: input.PricingAt,
|
||||
InboundEndpoint: input.InboundEndpoint,
|
||||
UpstreamEndpoint: input.UpstreamEndpoint,
|
||||
UserAgent: input.UserAgent,
|
||||
IPAddress: input.IPAddress,
|
||||
SessionID: input.SessionID,
|
||||
RequestPayloadHash: input.RequestPayloadHash,
|
||||
ForceCacheBilling: input.ForceCacheBilling,
|
||||
APIKeyService: input.APIKeyService,
|
||||
QuotaPlatform: input.QuotaPlatform,
|
||||
ChannelUsageFields: input.ChannelUsageFields,
|
||||
}, &recordUsageOpts{
|
||||
LongContextThreshold: input.LongContextThreshold,
|
||||
LongContextMultiplier: input.LongContextMultiplier,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -767,9 +712,8 @@ func logResponseModelBillingApplied(component string, account *Account, requestI
|
||||
slog.Info("billing.response_model_applied", attrs...)
|
||||
}
|
||||
|
||||
// recordUsageCore 是 RecordUsage 和 RecordUsageWithLongContext 的统一实现。
|
||||
// LongContextThreshold > 0 时 Token 计费回退走 CalculateCostWithLongContext。
|
||||
func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput, opts *recordUsageOpts) error {
|
||||
// recordUsageCore 是 RecordUsage 的核心实现。
|
||||
func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput) error {
|
||||
result := input.Result
|
||||
apiKey := input.APIKey
|
||||
user := input.User
|
||||
@@ -839,7 +783,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
|
||||
}
|
||||
|
||||
// 计算费用
|
||||
cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, pricingAt, opts)
|
||||
cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, pricingAt)
|
||||
// response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。
|
||||
// 采纳条件见 responseModelBillingDeclaration + hasIdentifiedResponseModelPricing
|
||||
// + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的
|
||||
@@ -851,7 +795,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
|
||||
result.ImageCount > 0 || result.AudioUsage != nil || result.SearchCount > 0,
|
||||
); responseModel != "" && !strings.EqualFold(responseModel, strings.TrimSpace(billingModel)) {
|
||||
if identified, responseChannelPriced := s.hasIdentifiedResponseModelPricing(ctx, responseModel, apiKey); identified {
|
||||
responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, pricingAt, opts)
|
||||
responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, pricingAt)
|
||||
baselineChannelPriced := s.resolveChannelPricing(ctx, billingModel, apiKey) != nil
|
||||
if responseModelBillingAdoptable(cost, responseCost, baselineChannelPriced, responseChannelPriced) {
|
||||
// billingModel 到此为止只是定价查表的入参,后续流程只消费 cost,
|
||||
@@ -872,7 +816,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
|
||||
// 创建使用日志
|
||||
accountRateMultiplier := account.BillingRateMultiplier()
|
||||
usageLog := s.buildRecordUsageLog(ctx, input, result, apiKey, user, account, subscription,
|
||||
requestedModel, multiplier, imageMultiplier, accountRateMultiplier, billingType, cacheTTLOverridden, cost, opts)
|
||||
requestedModel, multiplier, imageMultiplier, accountRateMultiplier, billingType, cacheTTLOverridden, cost)
|
||||
|
||||
// 计算账号统计定价费用(使用最终上游模型匹配自定义规则)
|
||||
if apiKey.GroupID != nil {
|
||||
@@ -932,7 +876,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
|
||||
return nil
|
||||
}
|
||||
|
||||
// calculateRecordUsageCost 根据请求类型和选项计算费用。
|
||||
// calculateRecordUsageCost 根据请求类型计算费用。
|
||||
func (s *GatewayService) calculateRecordUsageCost(
|
||||
ctx context.Context,
|
||||
result *ForwardResult,
|
||||
@@ -941,12 +885,11 @@ func (s *GatewayService) calculateRecordUsageCost(
|
||||
multiplier float64,
|
||||
imageMultiplier float64,
|
||||
pricingAt time.Time,
|
||||
opts *recordUsageOpts,
|
||||
) *CostBreakdown {
|
||||
// 图片生成:渠道定价为 token 计费时走 token 路径,否则走图片计费
|
||||
if result.ImageCount > 0 {
|
||||
if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil && resolved.Mode == BillingModeToken {
|
||||
return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt, opts)
|
||||
return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt)
|
||||
}
|
||||
return s.calculateImageCost(ctx, result, apiKey, billingModel, imageMultiplier)
|
||||
}
|
||||
@@ -970,7 +913,7 @@ func (s *GatewayService) calculateRecordUsageCost(
|
||||
}
|
||||
|
||||
// Token 计费;SearchCount 为叠加 surcharge(不替代 token)。
|
||||
tokenCost := s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt, opts)
|
||||
tokenCost := s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt)
|
||||
if result.SearchCount > 0 {
|
||||
price := groupSearchPricePer1kFromAPIKey(apiKey)
|
||||
if price != nil && *price == 0 {
|
||||
@@ -1122,7 +1065,7 @@ func (s *GatewayService) calculateImageCost(
|
||||
return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
|
||||
}
|
||||
|
||||
// calculateTokenCost 计算 Token 计费:路径选择(分组/渠道定价 → 旧长上下文规则 → 内置定价)
|
||||
// calculateTokenCost 计算 Token 计费:路径选择(分组/渠道定价 → 内置定价)
|
||||
// 统一交给 BillingService.CalculateTokenCostForRequest,与模型广场的阶梯表查询同源。
|
||||
func (s *GatewayService) calculateTokenCost(
|
||||
ctx context.Context,
|
||||
@@ -1131,7 +1074,6 @@ func (s *GatewayService) calculateTokenCost(
|
||||
billingModel string,
|
||||
multiplier float64,
|
||||
pricingAt time.Time,
|
||||
opts *recordUsageOpts,
|
||||
) *CostBreakdown {
|
||||
tokens := UsageTokens{
|
||||
InputTokens: result.Usage.InputTokens,
|
||||
@@ -1148,22 +1090,17 @@ func (s *GatewayService) calculateTokenCost(
|
||||
gid := apiKey.Group.ID
|
||||
resolved = s.resolver.Resolve(ctx, PricingInput{Model: billingModel, GroupID: &gid, Group: apiKey.Group})
|
||||
}
|
||||
var legacy *LegacyLongContextRule
|
||||
if opts.LongContextThreshold > 0 {
|
||||
legacy = &LegacyLongContextRule{Threshold: opts.LongContextThreshold, Multiplier: opts.LongContextMultiplier}
|
||||
}
|
||||
|
||||
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,
|
||||
LegacyLongContext: legacy,
|
||||
Ctx: ctx,
|
||||
Model: billingModel,
|
||||
Group: apiKey.Group,
|
||||
Tokens: tokens,
|
||||
RateMultiplier: multiplier,
|
||||
PricingAt: pricingAt,
|
||||
ServiceTier: optionalStringValue(result.ServiceTier),
|
||||
Resolver: s.resolver,
|
||||
Resolved: resolved,
|
||||
})
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.gateway", "Calculate cost failed: %v", err)
|
||||
@@ -1172,14 +1109,6 @@ func (s *GatewayService) calculateTokenCost(
|
||||
return cost
|
||||
}
|
||||
|
||||
// LegacyLongContextRule 透传 BillingService 的平台旧长上下文规则,供入口 handler 取用。
|
||||
func (s *GatewayService) LegacyLongContextRule(platform string) *LegacyLongContextRule {
|
||||
if s == nil || s.billingService == nil {
|
||||
return nil
|
||||
}
|
||||
return s.billingService.LegacyLongContextRule(platform)
|
||||
}
|
||||
|
||||
// buildRecordUsageLog 构建使用日志并设置计费模式。
|
||||
func (s *GatewayService) buildRecordUsageLog(
|
||||
ctx context.Context,
|
||||
@@ -1196,7 +1125,6 @@ func (s *GatewayService) buildRecordUsageLog(
|
||||
billingType int8,
|
||||
cacheTTLOverridden bool,
|
||||
cost *CostBreakdown,
|
||||
opts *recordUsageOpts,
|
||||
) *UsageLog {
|
||||
durationMs := int(result.Duration.Milliseconds())
|
||||
requestID := resolveUsageBillingRequestID(ctx, result.RequestID)
|
||||
|
||||
@@ -898,6 +898,11 @@ func TestExtractGeminiUsage(t *testing.T) {
|
||||
if got.CacheReadInputTokens != tt.wantUsage.CacheReadInputTokens {
|
||||
t.Errorf("CacheReadInputTokens: 期望 %d,实际 %d", tt.wantUsage.CacheReadInputTokens, got.CacheReadInputTokens)
|
||||
}
|
||||
// Gemini usageMetadata 只有 cachedContentTokenCount(缓存命中),没有缓存写入
|
||||
// 的 token 类别:cache_creation_input_tokens 恒为 0,计费侧不会产生缓存创建分项。
|
||||
if got.CacheCreationInputTokens != 0 {
|
||||
t.Errorf("CacheCreationInputTokens: 期望 0,实际 %d", got.CacheCreationInputTokens)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,8 @@ type Group struct {
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch bool
|
||||
AllowLive bool
|
||||
ForceOpenAIFast bool // 强制 OpenAI 网关请求使用 service_tier=priority
|
||||
FreeOpenAIFast bool // OpenAI Fast 请求按 Standard 价格向用户计费
|
||||
RequireOAuthOnly bool // 仅允许非 apikey 类型账号关联(OpenAI/Antigravity/Anthropic/Gemini)
|
||||
RequirePrivacySet bool // 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini)
|
||||
DefaultMappedModel string
|
||||
@@ -113,6 +115,9 @@ type Group struct {
|
||||
// MaxReasoningEffort limits the effective OpenAI/Codex 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
|
||||
// exceeds the ceiling: downgrade (default) or deny.
|
||||
MaxReasoningEffortOverLimit string
|
||||
// ReasoningEffortMappings rewrites explicit request values before applying the ceiling.
|
||||
ReasoningEffortMappings []ReasoningEffortMapping
|
||||
|
||||
|
||||
@@ -362,7 +362,8 @@ func TestListGroups_TokenLadderFollowsGroupToggle(t *testing.T) {
|
||||
{ID: 10, Name: "on", Platform: PlatformOpenAI, RateMultiplier: 1, LongContextPricingEnabled: true},
|
||||
{ID: 20, Name: "off", Platform: PlatformOpenAI, RateMultiplier: 2, LongContextPricingEnabled: false},
|
||||
}
|
||||
svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformOpenAI, 20: PlatformOpenAI}, nil)
|
||||
svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformOpenAI, 20: PlatformOpenAI},
|
||||
newStubPricingServiceFromJSON(t, openAILadderCatalogJSON))
|
||||
out, err := svc.ListGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 2)
|
||||
@@ -395,26 +396,27 @@ func TestListGroups_TokenLadderFollowsGroupToggle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGroups_GeminiLegacyRuleShownAsMarginal(t *testing.T) {
|
||||
func TestListGroups_GeminiCatalogLadderShownWholeRequest(t *testing.T) {
|
||||
channels := []Channel{{
|
||||
ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10},
|
||||
ModelMapping: map[string]map[string]string{PlatformGemini: {"gemini-2.5-pro": "gemini-2.5-pro"}},
|
||||
}}
|
||||
groups := []Group{{ID: 10, Name: "g", Platform: PlatformGemini, RateMultiplier: 1, LongContextPricingEnabled: true}}
|
||||
svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformGemini}, geminiCatalogStub())
|
||||
svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformGemini},
|
||||
newStubPricingServiceFromJSON(t, geminiLadderCatalogJSON))
|
||||
out, err := svc.ListGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 1)
|
||||
m := out[0].Models[0]
|
||||
require.Equal(t, ContextPricingBasisMarginal, m.LongContextBasis)
|
||||
require.Equal(t, ContextPricingBasisWholeRequest, m.LongContextBasis)
|
||||
require.Len(t, m.Pricing.Intervals, 2)
|
||||
require.Equal(t, "≤200K", m.Pricing.Intervals[0].TierLabel)
|
||||
require.Equal(t, ">200K", m.Pricing.Intervals[1].TierLabel)
|
||||
require.InDelta(t, 2.5e-6, *m.Pricing.Intervals[1].InputPrice, 1e-15)
|
||||
require.InDelta(t, 10e-6, *m.Pricing.Intervals[1].OutputPrice, 1e-15)
|
||||
// 官方参考不套用站内旧规则
|
||||
require.InDelta(t, 15e-6, *m.Pricing.Intervals[1].OutputPrice, 1e-15)
|
||||
// 官方参考价与实付同源:都来自目录数据的阶梯字段
|
||||
require.NotNil(t, m.OfficialPricing)
|
||||
require.Empty(t, m.OfficialPricing.Intervals)
|
||||
require.Len(t, m.OfficialPricing.Intervals, 2)
|
||||
}
|
||||
|
||||
func TestListGroups_GroupTokenCardOverridesChannelPricing(t *testing.T) {
|
||||
|
||||
@@ -234,6 +234,17 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin
|
||||
}
|
||||
|
||||
applyChannelTokenPriceOverrides(resolved.BasePricing, chPricing)
|
||||
if chPricing.CacheWrite1hPrice != nil {
|
||||
resolved.SupportsCacheBreakdown = true
|
||||
resolved.BasePricing.SupportsCacheBreakdown = true
|
||||
}
|
||||
for i := range chPricing.Intervals {
|
||||
if chPricing.Intervals[i].CacheWrite1hPrice != nil {
|
||||
resolved.SupportsCacheBreakdown = true
|
||||
resolved.BasePricing.SupportsCacheBreakdown = true
|
||||
break
|
||||
}
|
||||
}
|
||||
resolved.BasePricing.FastMultiplier = chPricing.FastMultiplier
|
||||
resolved.BasePricing.FlexMultiplier = chPricing.FlexMultiplier
|
||||
// 渠道定价覆盖一切:显式配置则用配置值,未配置则归零(不回退到 LiteLLM)
|
||||
@@ -276,7 +287,7 @@ func filterValidIntervals(intervals []PricingInterval) []PricingInterval {
|
||||
var valid []PricingInterval
|
||||
for _, iv := range intervals {
|
||||
if iv.InputPrice != nil || iv.OutputPrice != nil ||
|
||||
iv.CacheWritePrice != nil || iv.CacheReadPrice != nil ||
|
||||
iv.CacheWritePrice != nil || iv.CacheWrite1hPrice != nil || iv.CacheReadPrice != nil ||
|
||||
iv.PerRequestPrice != nil || iv.InputMultiplier != nil ||
|
||||
iv.OutputMultiplier != nil || iv.CacheWriteMultiplier != nil ||
|
||||
iv.CacheReadMultiplier != nil {
|
||||
@@ -336,13 +347,19 @@ func intervalToModelPricing(iv *PricingInterval, base *ModelPricing, chPricing *
|
||||
pricing.CacheCreationPricePerToken = *iv.CacheWritePrice
|
||||
pricing.CacheCreationPriceExplicit = true
|
||||
pricing.CacheCreation5mPrice = *iv.CacheWritePrice
|
||||
pricing.CacheCreation1hPrice = *iv.CacheWritePrice
|
||||
if iv.CacheWrite1hPrice == nil {
|
||||
pricing.CacheCreation1hPrice = *iv.CacheWritePrice
|
||||
}
|
||||
} else if iv.CacheWriteMultiplier != nil {
|
||||
pricing.CacheCreationPricePerToken = applyMultiplier(pricing.CacheCreationPricePerToken, iv.CacheWriteMultiplier)
|
||||
pricing.CacheCreationPricePerTokenPriority = applyMultiplier(pricing.CacheCreationPricePerTokenPriority, iv.CacheWriteMultiplier)
|
||||
pricing.CacheCreation5mPrice = applyMultiplier(pricing.CacheCreation5mPrice, iv.CacheWriteMultiplier)
|
||||
pricing.CacheCreation1hPrice = applyMultiplier(pricing.CacheCreation1hPrice, iv.CacheWriteMultiplier)
|
||||
}
|
||||
if iv.CacheWrite1hPrice != nil {
|
||||
pricing.CacheCreation1hPrice = *iv.CacheWrite1hPrice
|
||||
pricing.SupportsCacheBreakdown = true
|
||||
}
|
||||
if iv.CacheReadPrice != nil {
|
||||
pricing.CacheReadPricePerTokenPriority = channelTierOverridePrice(pricing.CacheReadPricePerToken, pricing.CacheReadPricePerTokenPriority, *iv.CacheReadPrice)
|
||||
pricing.CacheReadPricePerToken = *iv.CacheReadPrice
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestAccountTestServiceOpenAICompactAgentIdentityUsesFreshAssertion(t *testing.T) {
|
||||
@@ -441,6 +442,51 @@ func TestOpenAIAgentIdentityCompatRoutesRecoverInvalidTaskOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAgentIdentityChatRecoveryKeepsAutoDerivedSessionIsolationStable(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
key, privateKey := newTestAgentIdentityKey(t)
|
||||
account := &Account{
|
||||
ID: 52, Name: "agent-identity", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
|
||||
Status: StatusActive, Schedulable: true, Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"auth_mode": OpenAIAuthModeAgentIdentity,
|
||||
"agent_runtime_id": key.runtimeID,
|
||||
"agent_private_key": privateKey,
|
||||
"task_id": "task-cache-old",
|
||||
},
|
||||
Extra: map[string]any{"openai_responses_supported": true},
|
||||
}
|
||||
repo := &agentIdentityForwardRepo{account: account}
|
||||
registerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"task_id":"task-cache-new"}`)
|
||||
}))
|
||||
defer registerServer.Close()
|
||||
oldBase := openAIAgentIdentityAuthAPIBaseURL
|
||||
openAIAgentIdentityAuthAPIBaseURL = registerServer.URL
|
||||
t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = oldBase })
|
||||
|
||||
invalidTask := func() *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_task_id"}}`))}
|
||||
}
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{invalidTask(), invalidTask()}}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}, accountRepo: repo, httpUpstream: upstream}
|
||||
body := []byte(`{"model":"gpt-5.4","stream":false,"messages":[{"role":"user","content":"hi"}]}`)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||
c.Set("api_key", &APIKey{ID: 99})
|
||||
|
||||
_, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.4")
|
||||
require.Error(t, err)
|
||||
require.Len(t, upstream.requests, 2)
|
||||
firstKey := gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").String()
|
||||
secondKey := gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").String()
|
||||
require.NotEmpty(t, firstKey)
|
||||
require.Equal(t, firstKey, secondKey)
|
||||
require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(99, codexAccountIdentitySource(c, account), firstKey)), upstream.requests[0].Header.Get("session_id"))
|
||||
require.Equal(t, upstream.requests[0].Header.Get("session_id"), upstream.requests[1].Header.Get("session_id"))
|
||||
}
|
||||
|
||||
func decodeAgentAssertionTask(t *testing.T, header string) string {
|
||||
t.Helper()
|
||||
encoded := strings.TrimPrefix(header, "AgentAssertion ")
|
||||
|
||||
@@ -123,12 +123,12 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte
|
||||
return
|
||||
}
|
||||
if account.IsCNProvider() {
|
||||
// 国产 OpenAI 兼容上游(kimi/zhipu/deepseek)普遍仅支持 /v1/chat/completions,
|
||||
// 不存在 /v1/responses 端点。直接落标 false 走 Chat Completions 直转,跳过网络探测。
|
||||
// 例外:deepseek 的固定 responses 和 adaptive 账号使用官方原生 /responses
|
||||
// 端点,落标 force_responses;其余协议显式重置为 auto,避免切换后残留强制模式。
|
||||
if account.GetAPIProtocol() == APIProtocolResponses ||
|
||||
(account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol()) {
|
||||
// 国产 OpenAI 兼容上游默认仅支持 /v1/chat/completions。直接落标 false
|
||||
// 走 Chat Completions 直转,跳过网络探测。
|
||||
// 例外:deepseek / kimi 的固定 responses 和 adaptive 账号使用官方原生
|
||||
// Responses 端点,落标 force_responses;其余协议显式重置为 auto,避免
|
||||
// 切换后残留强制模式。
|
||||
if account.UsesNativeCNResponses() {
|
||||
_ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{
|
||||
openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses),
|
||||
openai_compat.ExtraKeyResponsesSupported: true,
|
||||
|
||||
@@ -60,7 +60,8 @@ func TestProbeOpenAIAPIKeyResponsesSupportCNProviders(t *testing.T) {
|
||||
}{
|
||||
{name: "deepseek adaptive supports responses", id: 201, platform: PlatformDeepseek, protocol: APIProtocolAdaptive, wantSupport: true, wantMode: string(openai_compat.ResponsesSupportModeForceResponses)},
|
||||
{name: "deepseek chat clears forced responses", id: 202, platform: PlatformDeepseek, protocol: APIProtocolChatCompletions, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)},
|
||||
{name: "kimi adaptive falls back to chat", id: 203, platform: PlatformKimi, protocol: APIProtocolAdaptive, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)},
|
||||
{name: "kimi adaptive supports responses", id: 203, platform: PlatformKimi, protocol: APIProtocolAdaptive, wantSupport: true, wantMode: string(openai_compat.ResponsesSupportModeForceResponses)},
|
||||
{name: "kimi responses protocol supports responses", id: 205, platform: PlatformKimi, protocol: APIProtocolResponses, wantSupport: true, wantMode: string(openai_compat.ResponsesSupportModeForceResponses)},
|
||||
{name: "zhipu adaptive falls back to chat", id: 204, platform: PlatformZhipu, protocol: APIProtocolAdaptive, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)},
|
||||
}
|
||||
|
||||
|
||||
@@ -198,8 +198,9 @@ func TestBuildCodexModelsManifestForGroupIntersectsDifferentMappedTargetsWithout
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario: temporarily unschedulable mapped accounts still participate in capability intersection.
|
||||
func TestBuildCodexModelsManifestForGroupIntersectsUnschedulableMappedAccounts(t *testing.T) {
|
||||
// Scenario: accounts that leave the current scheduling pool only because of
|
||||
// transient state still participate in capability intersection.
|
||||
func TestBuildCodexModelsManifestForGroupIntersectsTransientlyUnschedulableMappedAccounts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 741
|
||||
@@ -213,19 +214,19 @@ func TestBuildCodexModelsManifestForGroupIntersectsUnschedulableMappedAccounts(t
|
||||
true,
|
||||
nil,
|
||||
)
|
||||
unschedulable := newCodexCatalogMappedAccount(
|
||||
transientlyUnschedulable := newCodexCatalogMappedAccount(
|
||||
42,
|
||||
"glm-5.3",
|
||||
"GLM 5.3",
|
||||
[]string{"low", "medium", "high"},
|
||||
[]string{"text"},
|
||||
272_000,
|
||||
false,
|
||||
true,
|
||||
map[string]any{"exclusive-model": "exclusive-upstream"},
|
||||
)
|
||||
svc := &GatewayService{accountRepo: splitCodexModelsAccountRepo{
|
||||
schedulable: map[int64][]Account{groupID: {schedulable}},
|
||||
catalog: map[int64][]Account{groupID: {schedulable, unschedulable}},
|
||||
catalog: map[int64][]Account{groupID: {schedulable, transientlyUnschedulable}},
|
||||
}}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
@@ -241,8 +242,8 @@ func TestBuildCodexModelsManifestForGroupIntersectsUnschedulableMappedAccounts(t
|
||||
require.EqualValues(t, 272_000, models[0]["context_window"])
|
||||
}
|
||||
|
||||
// Scenario: deleting an account can widen the advertised contract.
|
||||
func TestBuildCodexModelsManifestForGroupWidensAfterUnschedulableAccountIsRemoved(t *testing.T) {
|
||||
// Scenario: a persistently disabled account cannot narrow the advertised contract.
|
||||
func TestBuildCodexModelsManifestForGroupIgnoresPersistentlyDisabledMappedAccounts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 742
|
||||
@@ -256,9 +257,20 @@ func TestBuildCodexModelsManifestForGroupWidensAfterUnschedulableAccountIsRemove
|
||||
true,
|
||||
nil,
|
||||
)
|
||||
disabled := newCodexCatalogMappedAccount(
|
||||
42,
|
||||
"glm-5.3",
|
||||
"GLM 5.3",
|
||||
[]string{"low", "medium", "high"},
|
||||
[]string{"text"},
|
||||
272_000,
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
svc := &GatewayService{accountRepo: splitCodexModelsAccountRepo{
|
||||
schedulable: map[int64][]Account{groupID: {remaining}},
|
||||
catalog: map[int64][]Account{groupID: {remaining}},
|
||||
all: map[int64][]Account{groupID: {remaining, disabled}},
|
||||
}}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
@@ -271,7 +283,7 @@ func TestBuildCodexModelsManifestForGroupWidensAfterUnschedulableAccountIsRemove
|
||||
require.EqualValues(t, 1_000_000, models[0]["context_window"])
|
||||
}
|
||||
|
||||
func TestBuildCodexModelsManifestForGroupFallsBackToSchedulableWhenListByGroupFails(t *testing.T) {
|
||||
func TestBuildCodexModelsManifestForGroupFallsBackToSchedulableWhenAvailabilityLookupFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 743
|
||||
@@ -286,7 +298,7 @@ func TestBuildCodexModelsManifestForGroupFallsBackToSchedulableWhenListByGroupFa
|
||||
true,
|
||||
nil,
|
||||
)},
|
||||
listByGroupErr: errors.New("group listing unavailable"),
|
||||
availabilityErr: errors.New("group listing unavailable"),
|
||||
}
|
||||
svc := &GatewayService{accountRepo: repo}
|
||||
|
||||
|
||||
@@ -208,11 +208,12 @@ func (s *OpenAIGatewayService) groupConfiguredCodexModelIDs(ctx context.Context,
|
||||
|
||||
// loadCodexGroupCatalogAccounts separates picker membership from capability
|
||||
// intersection. visible accounts are currently schedulable and decide which
|
||||
// public aliases appear. catalog accounts are all non-deleted active group
|
||||
// members; their snapshots keep advertised capabilities from widening when a
|
||||
// mapped account is only temporarily unschedulable. If ListByGroup fails, the
|
||||
// catalog falls back to the schedulable set so a listing error does not fail
|
||||
// the client request.
|
||||
// public aliases appear. catalog accounts are persistently enabled group
|
||||
// members; the availability query ignores transient rate-limit, overload, and
|
||||
// temporary-unschedulable state so those conditions cannot widen advertised
|
||||
// capabilities. Persistently disabled accounts are excluded because routing
|
||||
// cannot select them. If the availability query fails, the catalog falls back
|
||||
// to the schedulable set so a listing error does not fail the client request.
|
||||
func loadCodexGroupCatalogAccounts(ctx context.Context, repo AccountRepository, groupID int64) (visible []Account, catalog []Account, err error) {
|
||||
if repo == nil {
|
||||
return nil, nil, nil
|
||||
@@ -222,7 +223,21 @@ func loadCodexGroupCatalogAccounts(ctx context.Context, repo AccountRepository,
|
||||
return nil, nil, err
|
||||
}
|
||||
catalog = visible
|
||||
groupAccounts, listErr := repo.ListByGroup(ctx, groupID)
|
||||
groupAccounts, listErr := repo.ListModelAvailabilityCandidates(
|
||||
ctx,
|
||||
&groupID,
|
||||
[]string{
|
||||
PlatformAnthropic,
|
||||
PlatformOpenAI,
|
||||
PlatformGemini,
|
||||
PlatformAntigravity,
|
||||
PlatformGrok,
|
||||
PlatformKimi,
|
||||
PlatformZhipu,
|
||||
PlatformDeepseek,
|
||||
},
|
||||
false,
|
||||
)
|
||||
if listErr != nil {
|
||||
return visible, catalog, nil
|
||||
}
|
||||
@@ -309,6 +324,12 @@ type configuredCodexTruncationPolicy struct {
|
||||
Limit int64 `json:"limit"`
|
||||
}
|
||||
|
||||
type configuredCodexServiceTier struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type configuredCodexModelMessages struct {
|
||||
InstructionsTemplate string `json:"instructions_template"`
|
||||
InstructionsVariables any `json:"instructions_variables"`
|
||||
@@ -336,7 +357,7 @@ type configuredCodexModelDescriptor struct {
|
||||
SupportedInAPI bool `json:"supported_in_api"`
|
||||
Priority int `json:"priority"`
|
||||
AdditionalSpeedTiers []string `json:"additional_speed_tiers"`
|
||||
ServiceTiers []any `json:"service_tiers"`
|
||||
ServiceTiers []configuredCodexServiceTier `json:"service_tiers"`
|
||||
DefaultServiceTier any `json:"default_service_tier"`
|
||||
AvailabilityNUX any `json:"availability_nux"`
|
||||
Upgrade any `json:"upgrade"`
|
||||
@@ -392,7 +413,7 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr
|
||||
SupportedInAPI: true,
|
||||
Priority: configuredCodexModelPriority,
|
||||
AdditionalSpeedTiers: []string{},
|
||||
ServiceTiers: []any{},
|
||||
ServiceTiers: []configuredCodexServiceTier{},
|
||||
ModelMessages: configuredCodexModelMessages{InstructionsTemplate: openai.CodexBaseInstructionsForModel(modelID)},
|
||||
SupportsReasoningSummaryParameter: true,
|
||||
DefaultReasoningSummary: "auto",
|
||||
@@ -448,6 +469,15 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr
|
||||
descriptor.DisplayName = openaiCodexDisplayName(modelID)
|
||||
descriptor.Description = "OpenAI GPT coding model routed through Sub2API."
|
||||
descriptor.SupportsParallelToolCalls = true
|
||||
if configuredCodexSupportsPriorityServiceTier(modelID) {
|
||||
descriptor.ServiceTiers = []configuredCodexServiceTier{
|
||||
{
|
||||
ID: "priority",
|
||||
Name: "Fast",
|
||||
Description: "Priority processing for lower latency.",
|
||||
},
|
||||
}
|
||||
}
|
||||
if isOpenAICodexReasoningGPTModel(modelID) {
|
||||
defaultReasoningLevel := "medium"
|
||||
if getNormalizedCodexModel(modelID) == "gpt-5.6-sol" {
|
||||
@@ -471,6 +501,16 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr
|
||||
return descriptor
|
||||
}
|
||||
|
||||
func configuredCodexSupportsPriorityServiceTier(modelID string) bool {
|
||||
normalized := canonicalizeOpenAIModelAliasSpelling(modelID)
|
||||
for _, family := range []string{"gpt-5.4", "gpt-5.5", "gpt-5.6"} {
|
||||
if normalized == family || strings.HasPrefix(normalized, family+"-") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func configuredCodexGrokReasoningLevels(modelID string) []configuredCodexReasoningLevel {
|
||||
levels := []configuredCodexReasoningLevel{
|
||||
{Effort: "low", Description: "Fast responses with lighter reasoning"},
|
||||
@@ -1041,6 +1081,11 @@ func accountCodexModelSupportsImageInput(account *Account, upstreamModel string)
|
||||
}
|
||||
switch account.Platform {
|
||||
case PlatformOpenAI:
|
||||
if metadata, ok := account.GetUpstreamModelMetadata(upstreamModel); ok {
|
||||
if modalities := normalizeCodexInputModalities(metadata.InputModalities); len(modalities) > 0 {
|
||||
return stringSliceContains(modalities, "image")
|
||||
}
|
||||
}
|
||||
if !isOpenAICodexImageInputModel(upstreamModel) {
|
||||
return false
|
||||
}
|
||||
@@ -1050,8 +1095,9 @@ func accountCodexModelSupportsImageInput(account *Account, upstreamModel string)
|
||||
if !account.IsOpenAIApiKey() {
|
||||
return false
|
||||
}
|
||||
baseURL := strings.TrimSpace(account.GetCredential("base_url"))
|
||||
return baseURL == "" || isOfficialOpenAIModelsBaseURL(baseURL)
|
||||
// Compatible model lists often omit modalities. Preserve the known GPT
|
||||
// fallback unless a synced snapshot above explicitly narrows it.
|
||||
return true
|
||||
case PlatformGrok:
|
||||
if !isOfficialGrokCodexBaseURL(account.GetGrokBaseURL()) {
|
||||
return false
|
||||
@@ -1715,7 +1761,7 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont
|
||||
upstreamBody := body
|
||||
convertedFromOpenAIModelList := false
|
||||
if request.useAPIKeyUpstream {
|
||||
convertedBody := convertOpenAIModelListToCodexManifest(body)
|
||||
convertedBody := convertOpenAIModelListToCodexManifestForAccount(body, request.credentialAccount)
|
||||
convertedFromOpenAIModelList = !bytes.Equal(convertedBody, body)
|
||||
body = convertedBody
|
||||
}
|
||||
@@ -1734,7 +1780,7 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont
|
||||
body, err = completeAPIKeyCodexModelsManifestMetadata(
|
||||
body,
|
||||
false,
|
||||
request.credentialAccount != nil && isOfficialOpenAIModelsBaseURL(request.credentialAccount.GetOpenAIBaseURL()),
|
||||
request.credentialAccount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, &codexModelsManifestUpstreamError{
|
||||
@@ -1855,6 +1901,10 @@ func adjustAPIKeyCodexModelsManifest(body []byte) ([]byte, error) {
|
||||
// standard list shape, or yield no usable model IDs are returned unchanged so
|
||||
// envelope validation reports the original payload.
|
||||
func convertOpenAIModelListToCodexManifest(body []byte) []byte {
|
||||
return convertOpenAIModelListToCodexManifestForAccount(body, nil)
|
||||
}
|
||||
|
||||
func convertOpenAIModelListToCodexManifestForAccount(body []byte, account *Account) []byte {
|
||||
var envelope map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &envelope); err != nil || envelope == nil {
|
||||
return body
|
||||
@@ -1883,7 +1933,13 @@ func convertOpenAIModelListToCodexManifest(body []byte) []byte {
|
||||
if len(modelIDs) == 0 {
|
||||
return body
|
||||
}
|
||||
converted, err := BuildCodexModelsManifest(modelIDs)
|
||||
imageInputModels := make(map[string]bool, len(modelIDs))
|
||||
for _, modelID := range modelIDs {
|
||||
if accountCodexModelSupportsImageInput(account, modelID) {
|
||||
imageInputModels[modelID] = true
|
||||
}
|
||||
}
|
||||
converted, err := buildCodexModelsManifest(modelIDs, imageInputModels, nil, nil)
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
@@ -1904,7 +1960,7 @@ func (s *OpenAIGatewayService) CompleteAPIKeyCodexModelsManifestForClient(manife
|
||||
if len(manifest.upstreamSourceBody) > 0 {
|
||||
body = append([]byte(nil), manifest.upstreamSourceBody...)
|
||||
if manifest.convertedFromOpenAIModelList {
|
||||
body = convertOpenAIModelListToCodexManifest(body)
|
||||
body = convertOpenAIModelListToCodexManifestForAccount(body, account)
|
||||
}
|
||||
}
|
||||
var err error
|
||||
@@ -1915,7 +1971,7 @@ func (s *OpenAIGatewayService) CompleteAPIKeyCodexModelsManifestForClient(manife
|
||||
body, err = completeAPIKeyCodexModelsManifestMetadata(
|
||||
body,
|
||||
true,
|
||||
isOfficialOpenAIModelsBaseURL(account.GetOpenAIBaseURL()),
|
||||
account,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -2034,7 +2090,7 @@ func applySyncedAPIKeyCodexModelMetadata(body []byte, account *Account, overwrit
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func completeAPIKeyCodexModelsManifestMetadata(body []byte, completeAll, officialOpenAI bool) ([]byte, error) {
|
||||
func completeAPIKeyCodexModelsManifestMetadata(body []byte, completeAll bool, account *Account) ([]byte, error) {
|
||||
var envelope map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("decode JSON object: %w", err)
|
||||
@@ -2044,6 +2100,7 @@ func completeAPIKeyCodexModelsManifestMetadata(body []byte, completeAll, officia
|
||||
return nil, fmt.Errorf("decode top-level models array: %w", err)
|
||||
}
|
||||
|
||||
officialOpenAI := account != nil && isOfficialOpenAIModelsBaseURL(account.GetOpenAIBaseURL())
|
||||
changed := false
|
||||
if officialOpenAI {
|
||||
filtered := make([]json.RawMessage, 0, len(models))
|
||||
@@ -2084,6 +2141,9 @@ func completeAPIKeyCodexModelsManifestMetadata(body []byte, completeAll, officia
|
||||
}
|
||||
|
||||
descriptor := newConfiguredCodexModelDescriptor(slug)
|
||||
if accountCodexModelSupportsImageInput(account, slug) {
|
||||
descriptor.InputModalities = []string{"text", "image"}
|
||||
}
|
||||
if forceOfficialImage {
|
||||
descriptor.InputModalities = []string{"text", "image"}
|
||||
descriptor.SupportsImageDetailOriginal = true
|
||||
|
||||
@@ -39,17 +39,23 @@ func (r codexModelsVisibilityAccountRepo) ListSchedulableByGroupID(_ context.Con
|
||||
return append([]Account(nil), accounts...), nil
|
||||
}
|
||||
|
||||
func (r codexModelsVisibilityAccountRepo) ListByGroup(_ context.Context, groupID int64) ([]Account, error) {
|
||||
accounts := r.byGroup[groupID]
|
||||
func (r codexModelsVisibilityAccountRepo) ListModelAvailabilityCandidates(_ context.Context, groupID *int64, _ []string, _ bool) ([]Account, error) {
|
||||
if groupID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
accounts := r.byGroup[*groupID]
|
||||
return append([]Account(nil), accounts...), nil
|
||||
}
|
||||
|
||||
type countingCodexModelsAccountRepo struct {
|
||||
AccountRepository
|
||||
accounts []Account
|
||||
err error
|
||||
listByGroupErr error
|
||||
calls atomic.Int32
|
||||
accounts []Account
|
||||
err error
|
||||
availabilityErr error
|
||||
groupID *int64
|
||||
platforms []string
|
||||
includeGrouped bool
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (r *countingCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, _ int64) ([]Account, error) {
|
||||
@@ -60,9 +66,15 @@ func (r *countingCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Cont
|
||||
return append([]Account(nil), r.accounts...), nil
|
||||
}
|
||||
|
||||
func (r *countingCodexModelsAccountRepo) ListByGroup(_ context.Context, _ int64) ([]Account, error) {
|
||||
if r.listByGroupErr != nil {
|
||||
return nil, r.listByGroupErr
|
||||
func (r *countingCodexModelsAccountRepo) ListModelAvailabilityCandidates(_ context.Context, groupID *int64, platforms []string, includeGrouped bool) ([]Account, error) {
|
||||
if groupID != nil {
|
||||
value := *groupID
|
||||
r.groupID = &value
|
||||
}
|
||||
r.platforms = append([]string(nil), platforms...)
|
||||
r.includeGrouped = includeGrouped
|
||||
if r.availabilityErr != nil {
|
||||
return nil, r.availabilityErr
|
||||
}
|
||||
return append([]Account(nil), r.accounts...), nil
|
||||
}
|
||||
@@ -71,6 +83,7 @@ type splitCodexModelsAccountRepo struct {
|
||||
AccountRepository
|
||||
schedulable map[int64][]Account
|
||||
catalog map[int64][]Account
|
||||
all map[int64][]Account
|
||||
}
|
||||
|
||||
func (r splitCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, groupID int64) ([]Account, error) {
|
||||
@@ -78,7 +91,18 @@ func (r splitCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context,
|
||||
}
|
||||
|
||||
func (r splitCodexModelsAccountRepo) ListByGroup(_ context.Context, groupID int64) ([]Account, error) {
|
||||
return append([]Account(nil), r.catalog[groupID]...), nil
|
||||
accounts := r.all[groupID]
|
||||
if accounts == nil {
|
||||
accounts = r.catalog[groupID]
|
||||
}
|
||||
return append([]Account(nil), accounts...), nil
|
||||
}
|
||||
|
||||
func (r splitCodexModelsAccountRepo) ListModelAvailabilityCandidates(_ context.Context, groupID *int64, _ []string, _ bool) ([]Account, error) {
|
||||
if groupID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return append([]Account(nil), r.catalog[*groupID]...), nil
|
||||
}
|
||||
|
||||
func newCodexCatalogMappedAccount(
|
||||
@@ -181,7 +205,7 @@ func requireCompleteConfiguredCodexModel(t *testing.T, model map[string]any, slu
|
||||
require.Equal(t, true, model["supported_in_api"])
|
||||
require.NotNil(t, model["priority"])
|
||||
require.Equal(t, []any{}, model["additional_speed_tiers"])
|
||||
require.Equal(t, []any{}, model["service_tiers"])
|
||||
require.IsType(t, []any{}, model["service_tiers"])
|
||||
require.Contains(t, model, "default_service_tier")
|
||||
require.Contains(t, model, "availability_nux")
|
||||
require.Contains(t, model, "upgrade")
|
||||
@@ -403,6 +427,51 @@ func TestBuildCodexModelsManifestKeepsKnownReasoningChoices(t *testing.T) {
|
||||
require.NotEqual(t, "none", firstLevel["effort"])
|
||||
}
|
||||
|
||||
// Scenario: 支持 Fast 的 GPT 型号在目录中声明 priority service tier。
|
||||
func TestBuildCodexModelsManifestAdvertisesPriorityServiceTierForFastGPTModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body, err := BuildCodexModelsManifest([]string{
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 3)
|
||||
|
||||
for _, model := range models {
|
||||
require.Equal(t, []any{
|
||||
map[string]any{
|
||||
"id": "priority",
|
||||
"name": "Fast",
|
||||
"description": "Priority processing for lower latency.",
|
||||
},
|
||||
}, model["service_tiers"])
|
||||
require.Nil(t, model["default_service_tier"])
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario: 未明确支持 Fast 的型号不推测 service tier。
|
||||
func TestBuildCodexModelsManifestLeavesServiceTiersEmptyForOtherModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body, err := BuildCodexModelsManifest([]string{
|
||||
"gpt-4o",
|
||||
"claude-opus-4-6",
|
||||
"deepseek-v4-pro",
|
||||
"company-coding-model",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 4)
|
||||
|
||||
for _, model := range models {
|
||||
require.Equal(t, []any{}, model["service_tiers"])
|
||||
require.Nil(t, model["default_service_tier"])
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario: 专用图片生成模型不进入 Codex 主模型目录。
|
||||
func TestBuildCodexModelsManifestOmitsDedicatedImageModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -492,7 +561,7 @@ func TestBuildCodexModelsManifestForGroupAdvertisesOfficialOpenAIResponsesImageI
|
||||
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
||||
}
|
||||
|
||||
func TestBuildCodexModelsManifestForGroupUsesConservativeProviderImageCapabilities(t *testing.T) {
|
||||
func TestBuildCodexModelsManifestForGroupUsesProviderImageCapabilities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
@@ -602,6 +671,15 @@ func TestBuildCodexModelsManifestForGroupUsesConservativeProviderImageCapabiliti
|
||||
ID: 18, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"base_url": "https://openai-compatible.example.test/v1"},
|
||||
}},
|
||||
modalities: []any{"text", "image"},
|
||||
},
|
||||
{
|
||||
name: "custom OpenAI-compatible host with unknown model",
|
||||
model: "company-coding-model",
|
||||
accounts: []Account{{
|
||||
ID: 29, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"base_url": "https://openai-compatible.example.test/v1"},
|
||||
}},
|
||||
modalities: []any{"text"},
|
||||
},
|
||||
}
|
||||
@@ -628,6 +706,67 @@ func TestBuildCodexModelsManifestForGroupUsesConservativeProviderImageCapabiliti
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCodexModelsManifestForGroupPrefersSyncedOpenAIImageCapabilities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
newAccount := func(id int64, modalities []string) Account {
|
||||
account := Account{
|
||||
ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"base_url": "https://openai-compatible.example.test/v1"},
|
||||
}
|
||||
if modalities != nil {
|
||||
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
|
||||
"gpt-5.6-sol": {ID: "gpt-5.6-sol", InputModalities: modalities},
|
||||
}})
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
accounts []Account
|
||||
modalities []any
|
||||
}{
|
||||
{
|
||||
name: "explicit text-only snapshot narrows local fallback",
|
||||
accounts: []Account{
|
||||
newAccount(30, nil),
|
||||
newAccount(31, []string{"text"}),
|
||||
},
|
||||
modalities: []any{"text"},
|
||||
},
|
||||
{
|
||||
name: "explicit multimodal snapshot preserves local fallback",
|
||||
accounts: []Account{
|
||||
newAccount(32, nil),
|
||||
newAccount(33, []string{"text", "image"}),
|
||||
},
|
||||
modalities: []any{"text", "image"},
|
||||
},
|
||||
}
|
||||
|
||||
for i, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
groupID := int64(760 + i)
|
||||
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
||||
groupID: tt.accounts,
|
||||
}}}
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(),
|
||||
&Group{ID: groupID, Platform: PlatformOpenAI},
|
||||
"",
|
||||
[]string{"gpt-5.6-sol"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, tt.modalities, models[0]["input_modalities"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCodexModelsManifestForGroupUsesExplicitCompositeResponsesRouteModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -814,6 +953,13 @@ func TestBuildCodexModelsManifestForGroupLoadsAccountsOnce(t *testing.T) {
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int32(1), repo.calls.Load())
|
||||
require.NotNil(t, repo.groupID)
|
||||
require.Equal(t, groupID, *repo.groupID)
|
||||
require.False(t, repo.includeGrouped)
|
||||
require.Contains(t, repo.platforms, PlatformOpenAI)
|
||||
require.Contains(t, repo.platforms, PlatformGrok)
|
||||
require.Contains(t, repo.platforms, PlatformDeepseek)
|
||||
require.NotContains(t, repo.platforms, PlatformComposite)
|
||||
}
|
||||
|
||||
func TestBuildCodexModelsManifestForGroupUsesFallbackWhenTextOnlyPlatformHasNoSnapshot(t *testing.T) {
|
||||
@@ -1003,8 +1149,9 @@ func TestBuildGroupConfiguredCodexModelsManifestExpandsSelectedModelCoveredByWil
|
||||
require.NotContains(t, string(manifest.Body), "gpt-*")
|
||||
}
|
||||
|
||||
// Scenario: OpenAI 配置目录对暂时不可调度账号取能力交集,且不发布其独有模型。
|
||||
func TestBuildGroupConfiguredCodexModelsManifestIntersectsUnschedulableMappedAccounts(t *testing.T) {
|
||||
// Scenario: OpenAI 配置目录对仅因瞬态状态退出当前调度池的账号取能力交集,
|
||||
// 且不发布其独有模型。持久 schedulable 仍为 true。
|
||||
func TestBuildGroupConfiguredCodexModelsManifestIntersectsTransientlyUnschedulableMappedAccounts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 79
|
||||
@@ -1018,19 +1165,19 @@ func TestBuildGroupConfiguredCodexModelsManifestIntersectsUnschedulableMappedAcc
|
||||
true,
|
||||
nil,
|
||||
)
|
||||
unschedulable := newCodexCatalogMappedAccount(
|
||||
transientlyUnschedulable := newCodexCatalogMappedAccount(
|
||||
42,
|
||||
"glm-5.3",
|
||||
"GLM 5.3",
|
||||
[]string{"low", "medium", "high"},
|
||||
[]string{"text"},
|
||||
272_000,
|
||||
false,
|
||||
true,
|
||||
map[string]any{"exclusive-model": "exclusive-upstream"},
|
||||
)
|
||||
svc := &OpenAIGatewayService{accountRepo: splitCodexModelsAccountRepo{
|
||||
schedulable: map[int64][]Account{groupID: {schedulable}},
|
||||
catalog: map[int64][]Account{groupID: {schedulable, unschedulable}},
|
||||
catalog: map[int64][]Account{groupID: {schedulable, transientlyUnschedulable}},
|
||||
}}
|
||||
|
||||
manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(
|
||||
@@ -1049,6 +1196,52 @@ func TestBuildGroupConfiguredCodexModelsManifestIntersectsUnschedulableMappedAcc
|
||||
require.EqualValues(t, 272_000, models[0]["context_window"])
|
||||
}
|
||||
|
||||
// Scenario: 管理员持久禁用的账号不能继续收窄 Codex 能力目录。
|
||||
func TestBuildGroupConfiguredCodexModelsManifestIgnoresPersistentlyDisabledMappedAccounts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 80
|
||||
enabled := newCodexCatalogMappedAccount(
|
||||
51,
|
||||
"gpt-5.6-sol",
|
||||
"GPT-5.6 Sol",
|
||||
[]string{"low", "medium", "high", "xhigh"},
|
||||
[]string{"text", "image"},
|
||||
1_000_000,
|
||||
true,
|
||||
nil,
|
||||
)
|
||||
disabled := newCodexCatalogMappedAccount(
|
||||
52,
|
||||
"glm-5.3",
|
||||
"GLM 5.3",
|
||||
[]string{"low", "medium", "high"},
|
||||
[]string{"text"},
|
||||
272_000,
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
svc := &OpenAIGatewayService{accountRepo: splitCodexModelsAccountRepo{
|
||||
schedulable: map[int64][]Account{groupID: {enabled}},
|
||||
catalog: map[int64][]Account{groupID: {enabled}},
|
||||
all: map[int64][]Account{groupID: {enabled, disabled}},
|
||||
}}
|
||||
|
||||
manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(
|
||||
context.Background(),
|
||||
&Group{ID: groupID, Platform: PlatformOpenAI},
|
||||
"",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.True(t, configured)
|
||||
models := decodeCodexManifestModels(t, manifest.Body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, "my-coder", models[0]["slug"])
|
||||
require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromManifestModel(t, models[0]))
|
||||
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
||||
require.EqualValues(t, 1_000_000, models[0]["context_window"])
|
||||
}
|
||||
|
||||
// Scenario: 没有管理员模型配置时保留现有上游发现路径。
|
||||
func TestBuildGroupConfiguredCodexModelsManifestFallsThroughWithoutConfiguration(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -1723,6 +1916,8 @@ func TestFetchCodexModelsManifestAPIKeyConvertsStandardOpenAIModelList(t *testin
|
||||
require.Len(t, models, 2)
|
||||
requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.6")
|
||||
requireCompleteConfiguredCodexModel(t, models[1], "gpt-5.6-codex")
|
||||
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
||||
require.Equal(t, []any{"text", "image"}, models[1]["input_modalities"])
|
||||
require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
|
||||
require.Equal(t, `W/"openai-list"`, manifest.upstreamETag)
|
||||
}
|
||||
@@ -1745,7 +1940,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientPreservesProviderMetadata(t *
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
manifest := &CodexModelsManifest{
|
||||
Body: []byte(`{"models":[{"slug":"grok-4.6","description":"Provider supplied","model_messages":{"auto_review":{"enabled":true}},"truncation_policy":{"mode":"tokens"},"unknown":{"kept":true}}],"metadata":{"source":"upstream"}}`),
|
||||
Body: []byte(`{"models":[{"slug":"grok-4.6","description":"Provider supplied","service_tiers":[{"id":"provider-priority","name":"Provider Fast","description":"Provider supplied tier."}],"model_messages":{"auto_review":{"enabled":true}},"truncation_policy":{"mode":"tokens"},"unknown":{"kept":true}}],"metadata":{"source":"upstream"}}`),
|
||||
}
|
||||
account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
|
||||
|
||||
@@ -1755,6 +1950,13 @@ func TestCompleteAPIKeyCodexModelsManifestForClientPreservesProviderMetadata(t *
|
||||
requireCompleteConfiguredCodexModel(t, models[0], "grok-4.6")
|
||||
require.Equal(t, "Provider supplied", models[0]["description"])
|
||||
require.Equal(t, map[string]any{"kept": true}, models[0]["unknown"])
|
||||
require.Equal(t, []any{
|
||||
map[string]any{
|
||||
"id": "provider-priority",
|
||||
"name": "Provider Fast",
|
||||
"description": "Provider supplied tier.",
|
||||
},
|
||||
}, models[0]["service_tiers"])
|
||||
require.Equal(t, []any{"text"}, models[0]["input_modalities"])
|
||||
require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromManifestModel(t, models[0]))
|
||||
modelMessages, ok := models[0]["model_messages"].(map[string]any)
|
||||
@@ -1769,6 +1971,32 @@ func TestCompleteAPIKeyCodexModelsManifestForClientPreservesProviderMetadata(t *
|
||||
require.Equal(t, map[string]any{"source": "upstream"}, envelope["metadata"])
|
||||
}
|
||||
|
||||
func TestCompleteAPIKeyCodexModelsManifestForClientUsesKnownGPTImageFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
manifest := &CodexModelsManifest{Body: []byte(`{"models":[
|
||||
{"slug":"gpt-5.6-sol"},
|
||||
{"slug":"company-coding-model"},
|
||||
{"slug":"gpt-4o","input_modalities":["text"]}
|
||||
]}`)}
|
||||
account := newCodexModelsAPIKeyTestAccount("https://openai-compatible.example.test/v1")
|
||||
|
||||
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
|
||||
models := decodeCodexManifestModels(t, manifest.Body)
|
||||
require.Len(t, models, 3)
|
||||
|
||||
bySlug := make(map[string]map[string]any, len(models))
|
||||
for _, model := range models {
|
||||
slug, ok := model["slug"].(string)
|
||||
require.True(t, ok)
|
||||
bySlug[slug] = model
|
||||
}
|
||||
require.Equal(t, []any{"text", "image"}, bySlug["gpt-5.6-sol"]["input_modalities"])
|
||||
require.Equal(t, []any{"text"}, bySlug["company-coding-model"]["input_modalities"])
|
||||
require.Equal(t, []any{"text"}, bySlug["gpt-4o"]["input_modalities"])
|
||||
}
|
||||
|
||||
// Scenario: 标准 /models 型号列表优先使用已同步账号能力,再使用本地 descriptor 兜底。
|
||||
func TestCompleteAPIKeyCodexModelsManifestForClientUsesSyncedMetadataForConvertedModelList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -13,7 +13,7 @@ const compatPromptCacheKeyPrefix = "compat_cc_"
|
||||
|
||||
func shouldAutoInjectPromptCacheKeyForCompat(model string) bool {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(model))
|
||||
// 仅对 Codex OAuth 路径支持的 GPT-5 族开启自动注入,避免 normalizeCodexModel
|
||||
// 仅对 Responses 兼容路径支持的 GPT-5 族开启自动注入,避免 normalizeCodexModel
|
||||
// 的默认兜底把任意模型(如 gpt-4o、claude-*)误判为 gpt-5.4。
|
||||
if !strings.Contains(trimmed, "gpt-5") && !strings.Contains(trimmed, "codex") {
|
||||
return false
|
||||
|
||||
@@ -36,7 +36,13 @@ func (s *openAIFastPolicyRepoStub) Set(ctx context.Context, key, value string) e
|
||||
}
|
||||
|
||||
func (s *openAIFastPolicyRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
|
||||
panic("unexpected GetMultiple call")
|
||||
result := make(map[string]string, len(keys))
|
||||
for _, key := range keys {
|
||||
if value, ok := s.values[key]; ok {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *openAIFastPolicyRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error {
|
||||
@@ -262,6 +268,85 @@ func TestApplyOpenAIFastPolicyToBody_ForcePriorityRewritesKnownTier(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOpenAIFastPolicyToBody_GroupForceInjectsAndOverridesTier(t *testing.T) {
|
||||
svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings())
|
||||
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{
|
||||
ID: 7, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true,
|
||||
})
|
||||
|
||||
for _, body := range [][]byte{
|
||||
[]byte(`{"model":"gpt-5.6-sol","input":"hi"}`),
|
||||
[]byte(`{"model":"gpt-5.6-sol","service_tier":"default"}`),
|
||||
[]byte(`{"model":"gpt-5.6-sol","service_tier":"flex"}`),
|
||||
[]byte(`{"model":"gpt-5.6-sol","service_tier":"client-unknown"}`),
|
||||
} {
|
||||
updated, err := svc.applyOpenAIFastPolicyToBody(ctx, account, "gpt-5.6-sol", body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OpenAIFastTierPriority, gjson.GetBytes(updated, "service_tier").String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOpenAIFastPolicyToBody_GroupForceStillHonorsGlobalPolicy(t *testing.T) {
|
||||
svc := newOpenAIGatewayServiceWithSettings(t, openAIFastFilterPriorityPolicy())
|
||||
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{
|
||||
ID: 7, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true,
|
||||
})
|
||||
|
||||
updated, err := svc.applyOpenAIFastPolicyToBody(ctx, account, "gpt-5.6-sol", []byte(`{"model":"gpt-5.6-sol"}`))
|
||||
require.NoError(t, err)
|
||||
require.False(t, gjson.GetBytes(updated, "service_tier").Exists(),
|
||||
"the global filter remains authoritative after the group requests priority")
|
||||
}
|
||||
|
||||
func TestApplyOpenAIFastPolicyToBody_GroupForceRequiresHydratedGroup(t *testing.T) {
|
||||
svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings())
|
||||
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
body := []byte(`{"model":"gpt-5.6-sol"}`)
|
||||
ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{
|
||||
ID: 7, Platform: PlatformOpenAI, Status: StatusActive, ForceOpenAIFast: true,
|
||||
})
|
||||
|
||||
updated, err := svc.applyOpenAIFastPolicyToBody(ctx, account, "gpt-5.6-sol", body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(body), string(updated))
|
||||
}
|
||||
|
||||
func TestApplyOpenAIFastPolicyToBody_GroupForceOnlyTargetsOpenAIAccounts(t *testing.T) {
|
||||
svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings())
|
||||
body := []byte(`{"model":"grok-4.1"}`)
|
||||
ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{
|
||||
ID: 7, Platform: PlatformComposite, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true,
|
||||
})
|
||||
|
||||
updated, err := svc.applyOpenAIFastPolicyToBody(
|
||||
ctx,
|
||||
&Account{Platform: PlatformGrok, Type: AccountTypeOAuth},
|
||||
"grok-4.1",
|
||||
body,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(body), string(updated))
|
||||
}
|
||||
|
||||
func TestApplyOpenAIFastPolicyToBody_GroupForceRequiresSupportedGroupPlatform(t *testing.T) {
|
||||
svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings())
|
||||
body := []byte(`{"model":"gpt-5.6-sol"}`)
|
||||
ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{
|
||||
ID: 7, Platform: PlatformAnthropic, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true,
|
||||
})
|
||||
|
||||
updated, err := svc.applyOpenAIFastPolicyToBody(
|
||||
ctx,
|
||||
&Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth},
|
||||
"gpt-5.6-sol",
|
||||
body,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(body), string(updated))
|
||||
}
|
||||
|
||||
// TestApplyOpenAIFastPolicyToBody_OfficialTiersBypassDefaultRule 验证默认配置
|
||||
// 下客户端显式发送的 OpenAI 官方合法 tier 能透传到上游而不被静默剥离。
|
||||
func TestApplyOpenAIFastPolicyToBody_OfficialTiersBypassDefaultRule(t *testing.T) {
|
||||
|
||||
@@ -122,6 +122,34 @@ func TestWSResponseCreate_ForcePriorityRewritesKnownTier(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSResponseCreate_GroupForceInjectsMissingTier(t *testing.T) {
|
||||
svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings())
|
||||
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{
|
||||
ID: 7, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true,
|
||||
})
|
||||
frame := []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[]}`)
|
||||
|
||||
updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, "gpt-5.6-sol", frame)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, blocked)
|
||||
require.Equal(t, OpenAIFastTierPriority, gjson.GetBytes(updated, "service_tier").String())
|
||||
}
|
||||
|
||||
func TestWSResponseCreate_GroupForceDoesNotTouchOtherFrames(t *testing.T) {
|
||||
svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings())
|
||||
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{
|
||||
ID: 7, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true,
|
||||
})
|
||||
frame := []byte(`{"type":"response.cancel","model":"gpt-5.6-sol"}`)
|
||||
|
||||
updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, "gpt-5.6-sol", frame)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, blocked)
|
||||
require.Equal(t, string(frame), string(updated))
|
||||
}
|
||||
|
||||
func TestWSResponseCreate_FlexPassThrough(t *testing.T) {
|
||||
svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings())
|
||||
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
|
||||
@@ -58,6 +58,18 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
|
||||
body []byte,
|
||||
promptCacheKey string,
|
||||
defaultMappedModel string,
|
||||
) (*OpenAIForwardResult, error) {
|
||||
return s.forwardAsChatCompletions(ctx, c, account, body, promptCacheKey, defaultMappedModel, false)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) forwardAsChatCompletions(
|
||||
ctx context.Context,
|
||||
c *gin.Context,
|
||||
account *Account,
|
||||
body []byte,
|
||||
promptCacheKey string,
|
||||
defaultMappedModel string,
|
||||
compatPromptCacheTenantIsolated bool,
|
||||
) (*OpenAIForwardResult, error) {
|
||||
beginUpstreamResponseModelObservation(c)
|
||||
ClearActualOpenAIUpstreamEndpoint(c)
|
||||
@@ -102,13 +114,13 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
|
||||
isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists()
|
||||
|
||||
// 自适应账号的标准 Chat Completions 入站使用供应商原生 CC 端点。
|
||||
// Responses 形状下,DeepSeek 继续走下方原生 Responses 链;Kimi/GLM
|
||||
// Responses 形状下,DeepSeek / Kimi 继续走下方原生 Responses 链;GLM
|
||||
// 没有 Responses 端点,先转换成 Chat Completions 再直转。
|
||||
if account.IsAdaptiveAPIProtocol() {
|
||||
if !isResponsesShape {
|
||||
return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel)
|
||||
}
|
||||
if account.Platform != PlatformDeepseek {
|
||||
if !account.SupportsNativeCNResponses() {
|
||||
var responsesReq apicompat.ResponsesRequest
|
||||
if err := json.Unmarshal(body, &responsesReq); err != nil {
|
||||
return nil, fmt.Errorf("parse responses-shaped chat completions request: %w", err)
|
||||
@@ -126,7 +138,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
|
||||
}
|
||||
return s.forwardAsRawChatCompletions(ctx, c, account, chatBody, defaultMappedModel)
|
||||
}
|
||||
// DeepSeek 原生 Responses 请求继续走下方 Responses→Chat 回程转换。
|
||||
// DeepSeek / Kimi 原生 Responses 请求继续走下方 Responses→Chat 回程转换。
|
||||
}
|
||||
|
||||
// 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点,
|
||||
@@ -160,9 +172,13 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
|
||||
|
||||
promptCacheKey = strings.TrimSpace(promptCacheKey)
|
||||
compatPromptCacheInjected := false
|
||||
if promptCacheKey == "" && account.UsesOpenAICodexProtocol() && shouldAutoInjectPromptCacheKeyForCompat(upstreamModel) {
|
||||
if promptCacheKey == "" && !isResponsesShape && (account.UsesOpenAICodexProtocol() || account.IsOpenAIApiKey()) && shouldAutoInjectPromptCacheKeyForCompat(upstreamModel) {
|
||||
promptCacheKey = deriveCompatPromptCacheKey(&chatReq, upstreamModel)
|
||||
compatPromptCacheInjected = promptCacheKey != ""
|
||||
if compatPromptCacheInjected && account.IsOpenAIApiKey() {
|
||||
promptCacheKey = isolateOpenAISessionID(getAPIKeyIDFromContext(c), promptCacheKey)
|
||||
compatPromptCacheTenantIsolated = true
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Build the upstream (Responses API) body.
|
||||
@@ -305,6 +321,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
|
||||
return nil, policyErr
|
||||
}
|
||||
responsesBody = updatedBody
|
||||
responsesReq.ServiceTier = normalizedOpenAIServiceTierValue(gjson.GetBytes(responsesBody, "service_tier").String())
|
||||
|
||||
// 5. Get access token
|
||||
token, _, err := s.GetAccessToken(ctx, account)
|
||||
@@ -322,7 +339,11 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
|
||||
|
||||
if promptCacheKey != "" {
|
||||
apiKeyID := getAPIKeyIDFromContext(c)
|
||||
upstreamReq.Header.Set("session_id", generateSessionUUID(isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), promptCacheKey)))
|
||||
sessionKey := promptCacheKey
|
||||
if !compatPromptCacheTenantIsolated {
|
||||
sessionKey = isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), promptCacheKey)
|
||||
}
|
||||
upstreamReq.Header.Set("session_id", generateSessionUUID(sessionKey))
|
||||
}
|
||||
|
||||
// 7. Send request
|
||||
@@ -344,7 +365,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
|
||||
if err := s.recoverAgentIdentityTask(ctx, account, expectedTaskID); err != nil {
|
||||
return nil, fmt.Errorf("agent identity task recovery failed: %w", err)
|
||||
}
|
||||
return s.ForwardAsChatCompletions(markAgentIdentityTaskRecoveryTried(ctx), c, account, body, promptCacheKey, defaultMappedModel)
|
||||
return s.forwardAsChatCompletions(markAgentIdentityTaskRecoveryTried(ctx), c, account, body, promptCacheKey, defaultMappedModel, compatPromptCacheTenantIsolated)
|
||||
}
|
||||
if account.Type == AccountTypeAPIKey &&
|
||||
openai_compat.ResolveResponsesSupport(account.Extra) == openai_compat.ResponsesSupportUnknown &&
|
||||
|
||||
@@ -39,7 +39,8 @@ var openaiCCRawAllowedHeaders = map[string]bool{
|
||||
// `{base_url}/v1/chat/completions`,**不**做 CC↔Responses 协议转换。
|
||||
//
|
||||
// 适用场景:account.platform=openai && account.type=apikey && 上游已被探测确认
|
||||
// 不支持 /v1/responses 端点(如 DeepSeek/Kimi/GLM/Qwen 等第三方 OpenAI 兼容上游)。
|
||||
// 不支持 /v1/responses 端点(如 GLM/Qwen 等第三方 OpenAI 兼容上游);CN 供应商
|
||||
// 固定 chat_completions 协议也走此路径。
|
||||
//
|
||||
// 与 ForwardAsChatCompletions 的关键差异:
|
||||
//
|
||||
|
||||
@@ -238,6 +238,95 @@ func TestForwardAsChatCompletions_APIKeyPropagatesPromptCacheKeyInResponsesBody(
|
||||
require.Equal(t, generateSessionUUID(isolateOpenAISessionID(99, "cache-key-123")), upstream.lastReq.Header.Get("session_id"))
|
||||
}
|
||||
|
||||
func TestForwardAsChatCompletions_APIKeyAutoDerivesStableIsolatedPromptCacheKey(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
response := func() *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop before response parsing"}}`)),
|
||||
}
|
||||
}
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{response(), response(), response()}}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 2, Name: "openai-compatible", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1,
|
||||
Credentials: map[string]any{"api_key": "sk-compatible"},
|
||||
Extra: map[string]any{"openai_responses_supported": true},
|
||||
}
|
||||
firstBody := []byte(`{"model":"gpt-5.4","messages":[{"role":"system","content":"be concise"},{"role":"user","content":"hello"}],"stream":false}`)
|
||||
appendedBody := []byte(`{"model":"gpt-5.4","messages":[{"role":"system","content":"be concise"},{"role":"user","content":"hello"},{"role":"assistant","content":"hi"},{"role":"user","content":"continue"}],"stream":false}`)
|
||||
|
||||
forward := func(apiKeyID int64, body []byte) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Set("api_key", &APIKey{ID: apiKeyID})
|
||||
result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.4")
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
|
||||
forward(99, firstBody)
|
||||
forward(99, appendedBody)
|
||||
forward(100, appendedBody)
|
||||
|
||||
firstKey := gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").String()
|
||||
appendedKey := gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").String()
|
||||
otherTenantKey := gjson.GetBytes(upstream.bodies[2], "prompt_cache_key").String()
|
||||
require.NotEmpty(t, firstKey)
|
||||
require.Equal(t, firstKey, appendedKey)
|
||||
require.NotEqual(t, firstKey, otherTenantKey)
|
||||
require.Equal(t, generateSessionUUID(firstKey), upstream.requests[0].Header.Get("session_id"))
|
||||
require.Equal(t, upstream.requests[0].Header.Get("session_id"), upstream.requests[1].Header.Get("session_id"))
|
||||
require.Equal(t, generateSessionUUID(otherTenantKey), upstream.requests[2].Header.Get("session_id"))
|
||||
require.NotEqual(t, upstream.requests[1].Header.Get("session_id"), upstream.requests[2].Header.Get("session_id"))
|
||||
}
|
||||
|
||||
func TestForwardAsChatCompletions_ResponsesShapeDoesNotAutoDerivePromptCacheKey(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
response := func() *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop before response parsing"}}`)),
|
||||
}
|
||||
}
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{response(), response(), response()}}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 2, Name: "openai-compatible", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1,
|
||||
Credentials: map[string]any{"api_key": "sk-compatible"},
|
||||
Extra: map[string]any{"openai_responses_supported": true},
|
||||
}
|
||||
firstBody := []byte(`{"model":"gpt-5.4","input":[{"role":"user","content":[{"type":"input_text","text":"first unrelated input"}]}],"stream":false}`)
|
||||
secondBody := []byte(`{"model":"gpt-5.4","input":[{"role":"user","content":[{"type":"input_text","text":"second unrelated input"}]}],"stream":false}`)
|
||||
|
||||
forward := func(body []byte, promptCacheKey string) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||
c.Set("api_key", &APIKey{ID: 99})
|
||||
result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, promptCacheKey, "gpt-5.4")
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
|
||||
forward(firstBody, "")
|
||||
forward(secondBody, "")
|
||||
forward(secondBody, "explicit-responses-key")
|
||||
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists())
|
||||
require.Empty(t, upstream.requests[0].Header.Get("session_id"))
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists())
|
||||
require.Empty(t, upstream.requests[1].Header.Get("session_id"))
|
||||
require.Equal(t, "explicit-responses-key", gjson.GetBytes(upstream.bodies[2], "prompt_cache_key").String())
|
||||
require.Equal(t, generateSessionUUID(isolateOpenAISessionID(99, "explicit-responses-key")), upstream.requests[2].Header.Get("session_id"))
|
||||
}
|
||||
|
||||
func TestForwardAsChatCompletions_OAuthDoesNotInjectDefaultInstructions(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -133,8 +133,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
}
|
||||
}
|
||||
|
||||
nativeDeepSeekResponses := account.Platform == PlatformDeepseek &&
|
||||
(account.GetAPIProtocol() == APIProtocolResponses || account.IsAdaptiveAPIProtocol())
|
||||
nativeCNResponses := account.UsesNativeCNResponses()
|
||||
nativeDeepSeekResponses := account.Platform == PlatformDeepseek && nativeCNResponses
|
||||
if nativeDeepSeekResponses && account.Type == AccountTypeAPIKey && !compactPath &&
|
||||
needsOpenAIResponsesClientToolAdaptation(body) {
|
||||
adaptedBody, mapping, adaptErr := adaptOpenAIResponsesClientTools(body)
|
||||
@@ -360,7 +360,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
|
||||
instructions := gjson.GetBytes(body, "instructions")
|
||||
instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == ""
|
||||
if instructionsEmpty && !compatMessagesBridge && !nativeDeepSeekResponses {
|
||||
if instructionsEmpty && account.UsesOpenAICodexProtocol() && !compatMessagesBridge && !nativeCNResponses {
|
||||
markPatchSet("instructions", defaultCodexSynthInstructions(reqModel))
|
||||
}
|
||||
|
||||
@@ -625,7 +625,14 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
}
|
||||
}
|
||||
|
||||
if rawTier := requestView.ServiceTier; rawTier != "" {
|
||||
rawTier := requestView.ServiceTier
|
||||
if openAIGroupForcesFast(ctx, account) {
|
||||
rawTier = OpenAIFastTierPriority
|
||||
if requestView.ServiceTier != OpenAIFastTierPriority {
|
||||
markPatchSet("service_tier", OpenAIFastTierPriority)
|
||||
}
|
||||
}
|
||||
if rawTier != "" {
|
||||
if normTier := normalizedOpenAIServiceTierValue(rawTier); normTier != "" {
|
||||
action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, upstreamModel, normTier)
|
||||
switch action {
|
||||
@@ -1268,13 +1275,13 @@ func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool {
|
||||
return false
|
||||
}
|
||||
if account.IsCNProvider() {
|
||||
// CN 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek 有原生
|
||||
// Responses,Kimi/GLM 回退 Chat Completions。
|
||||
// CN 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek / Kimi
|
||||
// 有原生 Responses,GLM 回退 Chat Completions。
|
||||
switch account.GetAPIProtocol() {
|
||||
case APIProtocolChatCompletions:
|
||||
return true
|
||||
case APIProtocolAdaptive:
|
||||
return account.Platform != PlatformDeepseek
|
||||
return !account.SupportsNativeCNResponses()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -1298,7 +1305,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
|
||||
case AccountTypeAPIKey:
|
||||
// API Key accounts use Platform API or custom base URL
|
||||
baseURL := account.GetOpenAIBaseURL()
|
||||
if account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol() {
|
||||
if account.UsesNativeCNResponses() && account.IsAdaptiveAPIProtocol() {
|
||||
baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses)
|
||||
}
|
||||
if baseURL == "" {
|
||||
@@ -1315,7 +1322,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
|
||||
}
|
||||
targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c))
|
||||
|
||||
// DeepSeek 原生 Responses 端点为无状态实现:强制 store=false、清除
|
||||
// DeepSeek / Kimi 原生 Responses 端点为无状态实现:强制 store=false、清除
|
||||
// previous_response_id,避免携带状态字段被上游拒绝。
|
||||
body = normalizeDeepSeekResponsesRequestBody(account, body)
|
||||
|
||||
|
||||
@@ -270,7 +270,16 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
}
|
||||
}
|
||||
if account.Platform == PlatformOpenAI {
|
||||
if policyBody, changed := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, responsesBody); changed {
|
||||
policyBody, changed, policyErr := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, responsesBody)
|
||||
if policyErr != nil {
|
||||
var overLimit *ReasoningEffortOverLimitError
|
||||
if errors.As(policyErr, &overLimit) {
|
||||
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied)
|
||||
writeAnthropicError(c, http.StatusForbidden, "forbidden_error", overLimit.Error())
|
||||
}
|
||||
return nil, policyErr
|
||||
}
|
||||
if changed {
|
||||
responsesBody = policyBody
|
||||
if responsesReq.Reasoning != nil {
|
||||
responsesReq.Reasoning.Effort = gjson.GetBytes(responsesBody, "reasoning.effort").String()
|
||||
@@ -291,6 +300,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
return nil, policyErr
|
||||
}
|
||||
responsesBody = updatedBody
|
||||
responsesReq.ServiceTier = normalizedOpenAIServiceTierValue(gjson.GetBytes(responsesBody, "service_tier").String())
|
||||
grokCacheIdentity := ""
|
||||
if account.Platform == PlatformGrok {
|
||||
grokIntentBody := responsesBody
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -80,7 +81,16 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions(
|
||||
chatBody = normalizedBody
|
||||
}
|
||||
if account.Platform == PlatformOpenAI {
|
||||
if policyBody, changed := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, chatBody); changed {
|
||||
policyBody, changed, policyErr := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, chatBody)
|
||||
if policyErr != nil {
|
||||
var overLimit *ReasoningEffortOverLimitError
|
||||
if errors.As(policyErr, &overLimit) {
|
||||
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied)
|
||||
writeAnthropicError(c, http.StatusForbidden, "forbidden_error", overLimit.Error())
|
||||
}
|
||||
return nil, policyErr
|
||||
}
|
||||
if changed {
|
||||
chatBody = policyBody
|
||||
if effectiveEffort := strings.TrimSpace(gjson.GetBytes(chatBody, "reasoning_effort").String()); effectiveEffort != "" {
|
||||
reasoningEffort = &effectiveEffort
|
||||
|
||||
@@ -114,7 +114,7 @@ func TestForwardAsAnthropic_ForceChatCompletionsPreservesFinalModelReasoningEffo
|
||||
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
|
||||
ctx := context.Background()
|
||||
if tt.maxPolicy != "" {
|
||||
ctx = WithOpenAIReasoningEffortPolicy(ctx, tt.maxPolicy, nil)
|
||||
ctx = WithOpenAIReasoningEffortPolicy(ctx, tt.maxPolicy, nil, "")
|
||||
}
|
||||
result, err := svc.ForwardAsAnthropic(ctx, c, account, []byte(body), "", "")
|
||||
require.NoError(t, err)
|
||||
@@ -464,7 +464,7 @@ func TestForwardAsAnthropic_ResponsesSupportedAccountStillUsesResponsesEndpoint(
|
||||
openai_compat.ExtraKeyResponsesSupported: true,
|
||||
}
|
||||
|
||||
ctx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", nil)
|
||||
ctx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", nil, "")
|
||||
result, err := svc.ForwardAsAnthropic(ctx, c, account, body, "", "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
@@ -591,6 +591,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
|
||||
}
|
||||
case AccountTypeAPIKey:
|
||||
baseURL := account.GetOpenAIBaseURL()
|
||||
if account.UsesNativeCNResponses() && account.IsAdaptiveAPIProtocol() {
|
||||
baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses)
|
||||
}
|
||||
if baseURL != "" {
|
||||
validatedURL, err := s.validateUpstreamBaseURL(baseURL)
|
||||
if err != nil {
|
||||
@@ -601,7 +604,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
|
||||
}
|
||||
targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c))
|
||||
|
||||
// DeepSeek 原生 Responses 端点为无状态实现(见 normalizeDeepSeekResponsesRequestBody)。
|
||||
// DeepSeek / Kimi 原生 Responses 端点为无状态实现(见 normalizeDeepSeekResponsesRequestBody)。
|
||||
body = normalizeDeepSeekResponsesRequestBody(account, body)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
|
||||
|
||||
@@ -1182,11 +1182,21 @@ func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledWhenGrou
|
||||
require.Equal(t, 1, userRepo.deductCalls)
|
||||
}
|
||||
|
||||
// swapInOpenAILadderCatalog 给测试服务换上带 above_272k 阶梯字段的目录:
|
||||
// 静态兜底价已不带阶梯,长上下文相关测试需要目录数据。
|
||||
func swapInOpenAILadderCatalog(t *testing.T, svc *OpenAIGatewayService) {
|
||||
t.Helper()
|
||||
cfg := &config.Config{}
|
||||
cfg.Default.RateMultiplier = 1.1
|
||||
svc.billingService = NewBillingService(cfg, newStubPricingServiceFromJSON(t, openAILadderCatalogJSON))
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingEnabledPerAccount(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
subRepo := &openAIRecordUsageSubRepoStub{}
|
||||
svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil)
|
||||
swapInOpenAILadderCatalog(t, svc)
|
||||
|
||||
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
||||
Result: &OpenAIForwardResult{
|
||||
@@ -1227,6 +1237,7 @@ func TestOpenAIGatewayServiceRecordUsage_GroupOrAccountLongContextAllows(t *test
|
||||
t.Run("group on account off", func(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
|
||||
swapInOpenAILadderCatalog(t, svc)
|
||||
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
||||
Result: &OpenAIForwardResult{RequestID: "resp_and_off", Usage: tokens, Model: "gpt-5.4-2026-03-05", Duration: time.Second},
|
||||
APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1020, true),
|
||||
@@ -1242,6 +1253,7 @@ func TestOpenAIGatewayServiceRecordUsage_GroupOrAccountLongContextAllows(t *test
|
||||
t.Run("group off account on", func(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
|
||||
swapInOpenAILadderCatalog(t, svc)
|
||||
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
||||
Result: &OpenAIForwardResult{RequestID: "resp_and_group_off", Usage: tokens, Model: "gpt-5.4-2026-03-05", Duration: time.Second},
|
||||
APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1021, false),
|
||||
@@ -1260,6 +1272,7 @@ func TestOpenAIGatewayServiceRecordUsage_GroupOrAccountLongContextAllows(t *test
|
||||
t.Run("group on account on", func(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
|
||||
swapInOpenAILadderCatalog(t, svc)
|
||||
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
||||
Result: &OpenAIForwardResult{RequestID: "resp_and_on", Usage: tokens, Model: "gpt-5.4-2026-03-05", Duration: time.Second},
|
||||
APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1022, true),
|
||||
@@ -1352,6 +1365,7 @@ func TestOpenAIGatewayServiceRecordUsage_SparkShadowUsesCurrentParentBillingSett
|
||||
&openAIRecordUsageSubRepoStub{},
|
||||
nil,
|
||||
)
|
||||
swapInOpenAILadderCatalog(t, svc)
|
||||
svc.accountRepo = accountRepo
|
||||
parentID := int64(4016)
|
||||
|
||||
@@ -2784,7 +2798,6 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesImageCoun
|
||||
0.15,
|
||||
1.0,
|
||||
time.Time{},
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NotNil(t, cost)
|
||||
@@ -2824,7 +2837,6 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesSizeTier(
|
||||
1.0,
|
||||
1.0,
|
||||
time.Time{},
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NotNil(t, cost)
|
||||
@@ -2857,7 +2869,6 @@ func TestGatewayServiceCalculateRecordUsageCost_GroupImagePriceOverridesChannelI
|
||||
1.0,
|
||||
1.0,
|
||||
time.Time{},
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NotNil(t, cost)
|
||||
@@ -2947,7 +2958,6 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingNormalizesMis
|
||||
1.0,
|
||||
1.0,
|
||||
time.Time{},
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NotNil(t, cost)
|
||||
@@ -3071,6 +3081,77 @@ func TestOpenAIGatewayServiceRecordUsage_ShadowUsesParentCredentialTierContract(
|
||||
require.InDelta(t, fastCost.TotalCost, usageRepo.lastLog.TotalCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceRecordUsage_FreeOpenAIFastChargesStandard(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
svc := newOpenAIRecordUsageServiceForTest(
|
||||
usageRepo,
|
||||
&openAIRecordUsageUserRepoStub{},
|
||||
&openAIRecordUsageSubRepoStub{},
|
||||
nil,
|
||||
)
|
||||
svc.resolver = NewModelPricingResolver(nil, svc.billingService)
|
||||
groupID := int64(77)
|
||||
serviceTier := "priority"
|
||||
tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
|
||||
inputPrice := 0.001
|
||||
outputPrice := 0.002
|
||||
fastMultiplier := 3.0
|
||||
apiKey := &APIKey{
|
||||
ID: 1020,
|
||||
GroupID: &groupID,
|
||||
Group: &Group{
|
||||
ID: groupID, Platform: PlatformOpenAI, Status: StatusActive,
|
||||
Hydrated: true, RateMultiplier: 0.5, FreeOpenAIFast: true,
|
||||
ModelPricing: []ChannelModelPricing{{
|
||||
Models: []string{"gpt-5.6-sol"},
|
||||
BillingMode: BillingModeToken,
|
||||
InputPrice: &inputPrice,
|
||||
OutputPrice: &outputPrice,
|
||||
FastMultiplier: &fastMultiplier,
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
||||
Result: &OpenAIForwardResult{
|
||||
RequestID: "resp_free_fast",
|
||||
ServiceTier: &serviceTier,
|
||||
UpstreamResponseServiceTier: "default",
|
||||
Usage: OpenAIUsage{InputTokens: tokens.InputTokens, OutputTokens: tokens.OutputTokens},
|
||||
Model: "gpt-5.6-sol",
|
||||
Duration: time.Second,
|
||||
},
|
||||
APIKey: apiKey,
|
||||
User: &User{ID: 2020},
|
||||
Account: &Account{ID: 3020, Platform: PlatformOpenAI, Type: AccountTypeOAuth},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, usageRepo.lastLog)
|
||||
require.NotNil(t, usageRepo.lastLog.ServiceTier)
|
||||
require.Equal(t, "priority", *usageRepo.lastLog.ServiceTier)
|
||||
require.InDelta(t, 0.5, usageRepo.lastLog.RateMultiplier, 1e-12)
|
||||
|
||||
standardTotal := float64(tokens.InputTokens)*inputPrice + float64(tokens.OutputTokens)*outputPrice
|
||||
require.InDelta(t, standardTotal*fastMultiplier, usageRepo.lastLog.TotalCost, 1e-10)
|
||||
require.InDelta(t, standardTotal*0.5, usageRepo.lastLog.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestGroupBillsOpenAIFastAtStandardRequiresOpenAIAccount(t *testing.T) {
|
||||
apiKey := &APIKey{Group: &Group{Platform: PlatformComposite, FreeOpenAIFast: true}}
|
||||
|
||||
require.True(t, groupBillsOpenAIFastAtStandard(
|
||||
apiKey,
|
||||
&Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth},
|
||||
"priority",
|
||||
))
|
||||
require.False(t, groupBillsOpenAIFastAtStandard(
|
||||
apiKey,
|
||||
&Account{Platform: PlatformGrok, Type: AccountTypeAPIKey},
|
||||
"priority",
|
||||
))
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceRecordUsage_ServiceTierNeverRaisedByUpstreamResponse(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user