Files
sub2api/backend/internal/service/model_rate_limit.go
T
m11y ea4291a92c fix(anthropic): scope Fable scheduling threshold to Fable models
让 Fable 7d_oi 调度阈值仅限制 Fable 模型族,不暂停整个 Anthropic 账号。
2026-08-29 17:30:18 +08:00

213 lines
6.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
)
const (
modelRateLimitsKey = "model_rate_limits"
antigravityGeminiModelRateLimitKey = "antigravity:gemini"
openAIImageGenerationRateLimitKey = "openai:image_generation"
openAICodexSparkRateLimitReason = "openai_codex_spark_rate_limit"
// anthropicFableRateLimitKey 是 Anthropic 7d_oi(Fable 专属 7d 窗口)限流的
// 家族级 scope:命中后所有 Fable 变体(含 [1m] 等后缀)都不再调度到该账号。
anthropicFableRateLimitKey = "claude-fable-5"
)
// isRateLimitActiveForKey 检查指定 key 的限流是否生效
func (a *Account) isRateLimitActiveForKey(key string) bool {
resetAt := a.modelRateLimitResetAt(key)
return resetAt != nil && time.Now().Before(*resetAt)
}
// getRateLimitRemainingForKey 获取指定 key 的限流剩余时间,0 表示未限流或已过期
func (a *Account) getRateLimitRemainingForKey(key string) time.Duration {
resetAt := a.modelRateLimitResetAt(key)
if resetAt == nil {
return 0
}
remaining := time.Until(*resetAt)
if remaining > 0 {
return remaining
}
return 0
}
func (a *Account) isModelRateLimitedWithContext(ctx context.Context, requestedModel string) bool {
for _, key := range a.modelRateLimitKeysForRequest(ctx, requestedModel) {
if a.isRateLimitActiveForKey(key) {
return true
}
}
return false
}
// GetModelRateLimitRemainingTime 获取模型限流剩余时间
// 返回 0 表示未限流或已过期
func (a *Account) GetModelRateLimitRemainingTime(requestedModel string) time.Duration {
return a.GetModelRateLimitRemainingTimeWithContext(context.Background(), requestedModel)
}
func (a *Account) GetModelRateLimitRemainingTimeWithContext(ctx context.Context, requestedModel string) time.Duration {
remaining := time.Duration(0)
for _, key := range a.modelRateLimitKeysForRequest(ctx, requestedModel) {
if keyRemaining := a.getRateLimitRemainingForKey(key); keyRemaining > remaining {
remaining = keyRemaining
}
}
return remaining
}
func (a *Account) modelRateLimitKeysForRequest(ctx context.Context, requestedModel string) []string {
if a == nil {
return nil
}
modelKey := a.GetMappedModel(requestedModel)
if a.Platform == PlatformAntigravity {
modelKey = resolveFinalAntigravityModelKey(ctx, a, requestedModel)
}
modelKey = strings.TrimSpace(modelKey)
if modelKey == "" {
return nil
}
keys := []string{modelKey}
switch a.Platform {
case PlatformAntigravity:
if isAntigravityGeminiModel(modelKey) && modelKey != antigravityGeminiModelRateLimitKey {
keys = append(keys, antigravityGeminiModelRateLimitKey)
}
case PlatformOpenAI:
if openAIImageGenerationRateLimitApplies(ctx, requestedModel, modelKey) && modelKey != openAIImageGenerationRateLimitKey {
keys = append(keys, openAIImageGenerationRateLimitKey)
}
case PlatformAnthropic:
if isAnthropicFableModel(modelKey) && modelKey != anthropicFableRateLimitKey {
keys = append(keys, anthropicFableRateLimitKey)
}
}
return keys
}
// isAnthropicFableModel 判断是否为 Fable 模型家族(claude-fable-5、claude-fable-5[1m] 等变体)
func isAnthropicFableModel(model string) bool {
return strings.Contains(strings.ToLower(model), "fable")
}
func openAIImageGenerationRateLimitApplies(ctx context.Context, requestedModel, modelKey string) bool {
if isOpenAIImageGenerationModel(requestedModel) || isOpenAIImageGenerationModel(modelKey) {
return true
}
return OpenAIImageGenerationIntentFromContext(ctx)
}
func WithOpenAIImageGenerationIntent(ctx context.Context) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, ctxkey.OpenAIImageGenerationIntent, true)
}
func OpenAIImageGenerationIntentFromContext(ctx context.Context) bool {
if ctx == nil {
return false
}
enabled, ok := ctx.Value(ctxkey.OpenAIImageGenerationIntent).(bool)
return ok && enabled
}
// WithOpenAIImagesEndpoint 标记请求从 /v1/images/* 专用生图端点入站。
func WithOpenAIImagesEndpoint(ctx context.Context) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, ctxkey.OpenAIImagesEndpoint, true)
}
// OpenAIImagesEndpointFromContext 报告请求是否来自 /v1/images/*。
func OpenAIImagesEndpointFromContext(ctx context.Context) bool {
if ctx == nil {
return false
}
enabled, ok := ctx.Value(ctxkey.OpenAIImagesEndpoint).(bool)
return ok && enabled
}
func resolveFinalAntigravityModelKey(ctx context.Context, account *Account, requestedModel string) string {
modelKey := mapAntigravityModel(account, requestedModel)
if modelKey == "" {
return ""
}
// thinking 会影响 Antigravity 最终模型名(例如 claude-sonnet-4-5 -> claude-sonnet-4-5-thinking)
if enabled, ok := ThinkingEnabledFromContext(ctx); ok {
modelKey = applyThinkingModelSuffix(modelKey, enabled)
}
return modelKey
}
func isAntigravityGeminiModel(model string) bool {
return strings.HasPrefix(normalizeAntigravityModelName(model), "gemini-")
}
func antigravityModelRateLimitKeys(model string) []string {
model = strings.TrimSpace(model)
if model == "" {
return nil
}
keys := []string{model}
if isAntigravityGeminiModel(model) && model != antigravityGeminiModelRateLimitKey {
keys = append(keys, antigravityGeminiModelRateLimitKey)
}
return keys
}
func (a *Account) modelRateLimitResetAt(scope string) *time.Time {
if a == nil || a.Extra == nil || scope == "" {
return nil
}
rawLimits, ok := a.Extra[modelRateLimitsKey].(map[string]any)
if !ok {
return nil
}
rawLimit, ok := rawLimits[scope].(map[string]any)
if !ok {
return nil
}
resetAtRaw, ok := rawLimit["rate_limit_reset_at"].(string)
if !ok || strings.TrimSpace(resetAtRaw) == "" {
return nil
}
resetAt, err := time.Parse(time.RFC3339, resetAtRaw)
if err != nil {
return nil
}
return &resetAt
}
func setAccountModelRateLimitSnapshot(account *Account, scope string, resetAt time.Time, reason string, now time.Time) {
if account == nil || strings.TrimSpace(scope) == "" {
return
}
if account.Extra == nil {
account.Extra = make(map[string]any)
}
limits, ok := account.Extra[modelRateLimitsKey].(map[string]any)
if !ok {
limits = make(map[string]any)
account.Extra[modelRateLimitsKey] = limits
}
payload := map[string]any{
"rate_limited_at": now.UTC().Format(time.RFC3339),
"rate_limit_reset_at": resetAt.UTC().Format(time.RFC3339),
}
if reason = strings.TrimSpace(reason); reason != "" {
payload["reason"] = reason
}
limits[scope] = payload
}