fix: 未登记的 Grok 文本模型回退 grok-4.5 价卡

新文本 ID 在闭集价卡上 fail-closed,请求成功而用量记 0。

- grok / grok-N* / build / composer 未登记别名继承 4.5 价
- imagine/voice/web-search/speech 等非文本族不回退
This commit is contained in:
IanShaw027
2026-08-13 08:38:10 +08:00
parent 678eb22a40
commit 8c4c3c09ce
2 changed files with 86 additions and 0 deletions
@@ -10,6 +10,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
)
// APIKeyRateLimitCacheData holds rate limit usage data cached in Redis.
@@ -843,9 +844,39 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
return s.fallbackPrices["grok-build-0.1"]
}
// Unknown Grok text IDs (grok-5, dated snapshots, provider-prefixed) inherit
// the current default text card so a new model cannot ship unbilled.
if pricing := s.grokUnknownTextFamilyFallback(modelLower); pricing != nil {
return pricing
}
return nil
}
func (s *BillingService) grokUnknownTextFamilyFallback(model string) *ModelPricing {
if s == nil || !isGrokUnknownTextFamilyModel(model) {
return nil
}
return s.fallbackPrices["grok-4.5"]
}
func isGrokUnknownTextFamilyModel(model string) bool {
native := strings.ToLower(strings.TrimSpace(xai.StripGrokProviderPrefix(model)))
switch {
case native == "grok", native == "grok-latest":
return true
case strings.HasPrefix(native, "grok-build"),
strings.HasPrefix(native, "grok-composer"),
strings.HasPrefix(native, "composer-"):
return true
case len(native) > 5 && strings.HasPrefix(native, "grok-"):
rest := native[len("grok-"):]
return rest[0] >= '0' && rest[0] <= '9'
default:
return false
}
}
// HasIdentifiedTokenPricing 判断模型能否在价格表中被"确定性识别"出 token 价格。
//
// 与 GetModelPricing 的关键区别:本函数拒绝按子串猜系列的兜底。GetModelPricing 会
@@ -1185,6 +1185,61 @@ func TestGetModelPricing_Grok46OfficialFallback(t *testing.T) {
}
}
func TestCalculateCostUnified_GroupLongContextToggleUsesPresetLadder(t *testing.T) {
svc := newTestBillingService()
resolver := NewModelPricingResolver(nil, svc)
tokens := UsageTokens{InputTokens: 250000, OutputTokens: 1000}
off := &Group{LongContextPricingEnabled: false}
disabled, err := svc.CalculateCostUnified(CostInput{
Model: "grok-4.5", Group: off, Tokens: tokens, RateMultiplier: 1, Resolver: resolver,
})
require.NoError(t, err)
on := &Group{LongContextPricingEnabled: true}
enabled, err := svc.CalculateCostUnified(CostInput{
Model: "grok-4.5", Group: on, Tokens: tokens, RateMultiplier: 1, Resolver: resolver,
})
require.NoError(t, err)
require.False(t, disabled.LongContextBillingApplied)
require.True(t, enabled.LongContextBillingApplied)
require.InDelta(t, disabled.InputCost*2, enabled.InputCost, 1e-12)
require.InDelta(t, disabled.OutputCost*2, enabled.OutputCost, 1e-12)
}
func TestGetModelPricing_UnknownGrokTextFallsBackToGrok45(t *testing.T) {
svc := newTestBillingService()
baseline, err := svc.GetModelPricing("grok-4.5")
require.NoError(t, err)
for _, model := range []string{"grok-5", "grok-5-latest", "x-ai/grok-7", "grok-4.7-beta"} {
pricing, err := svc.GetModelPricing(model)
require.NoError(t, err, "model %s", model)
require.InDelta(t, baseline.InputPricePerToken, pricing.InputPricePerToken, 1e-12, model)
require.InDelta(t, baseline.OutputPricePerToken, pricing.OutputPricePerToken, 1e-12, model)
require.InDelta(t, baseline.CacheReadPricePerToken, pricing.CacheReadPricePerToken, 1e-12, model)
}
for _, model := range []string{
"grok-imagine-image-3.0",
"grok-imagine-video-2",
"grok-voice-latest",
"grok-web-search",
"grok-x-search",
"grok-speech-1",
} {
_, err := svc.GetModelPricing(model)
require.Error(t, err, "non-text grok family %s must not inherit grok-4.5 token rates", model)
require.ErrorIs(t, err, ErrModelPricingUnavailable)
}
// Known cards stay on their own rate, not the 4.5 family floor.
build, err := svc.GetModelPricing("grok-build-0.1")
require.NoError(t, err)
require.InDelta(t, 1e-6, build.InputPricePerToken, 1e-12)
}
func TestGetModelPricing_GrokCatalogFallbacks(t *testing.T) {
svc := newTestBillingService()