package service // 本文件由 openai_gateway_service.go 纯移动拆分而来:粘性会话哈希、账号选择与 // 负载感知调度、配额自动暂停判定、并发槽位获取。仅做代码搬迁,无任何行为变更。 import ( "context" "encoding/json" "fmt" "log/slog" "sort" "strconv" "strings" "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" ) const ( openCodeSessionAffinityHeader = "X-Session-Affinity" openCodeSessionIDHeader = "X-Session-Id" openCodeNativeSessionHeader = "X-OpenCode-Session" codeBuddyConversationHeader = "X-Conversation-ID" ) var explicitOpenAIHeaderSessionNames = []string{ "session_id", "conversation_id", openCodeSessionAffinityHeader, openCodeSessionIDHeader, openCodeNativeSessionHeader, codeBuddyConversationHeader, } // explicitOpenAIHeaderSessionID resolves stable conversation identifiers sent // by OpenAI-compatible clients. Keep this list limited to session-scoped // fields: request/message IDs rotate every turn and would defeat sticky routing // and upstream prompt caching. func explicitOpenAIHeaderSessionID(c *gin.Context) string { if c == nil { return "" } for _, header := range explicitOpenAIHeaderSessionNames { if sessionID := strings.TrimSpace(c.GetHeader(header)); sessionID != "" { return sessionID } } return "" } // ExtractSessionID extracts the raw session ID from headers or body without hashing. // Used by ForwardAsAnthropic to pass as prompt_cache_key for upstream cache. func (s *OpenAIGatewayService) ExtractSessionID(c *gin.Context, body []byte) string { return explicitOpenAIRequestSessionID(c, body) } func explicitOpenAISessionID(c *gin.Context, body []byte) string { if c == nil { return "" } sessionID := explicitOpenAIHeaderSessionID(c) if sessionID == "" && len(body) > 0 { sessionID = strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String()) } return sessionID } // explicitOpenAIRequestSessionID extends the common OpenAI session signals // with Grok's native conversation header only for requests authenticated to a // Grok group. This keeps an unrelated x-grok-conv-id header from changing // scheduling or upstream session behavior for non-Grok groups. // // For Grok groups only, previous_response_id is a last-resort sticky seed so // multi-turn Responses chains stay on the same OAuth account when no explicit // session/conversation/prompt_cache_key is present. Non-Grok groups omit this // so HTTP OpenAI paths that delete previous_response_id before upstream are // unchanged. func explicitOpenAIRequestSessionID(c *gin.Context, body []byte) string { if c == nil { return "" } sessionID := explicitOpenAIHeaderSessionID(c) if sessionID == "" && isGrokRequestContext(c) { sessionID = strings.TrimSpace(c.GetHeader(grokConversationIDHeader)) } if sessionID == "" && len(body) > 0 { sessionID = strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String()) } if sessionID == "" && isGrokRequestContext(c) && len(body) > 0 { sessionID = grokPreviousResponseSessionSeed(body) } return sessionID } // grokPreviousResponseSessionSeed returns a stable sticky seed from a Responses // previous_response_id. Only resp_* response ids are accepted; message ids and // unknown shapes must not pin sticky routing or prompt-cache identity. func grokPreviousResponseSessionSeed(body []byte) string { id := strings.TrimSpace(gjson.GetBytes(body, "previous_response_id").String()) if id == "" { return "" } if ClassifyOpenAIPreviousResponseIDKind(id) != OpenAIPreviousResponseIDKindResponseID { return "" } // Namespace so content-derived seeds never collide with response ids. return "grok-prev-resp:" + id } // GenerateExplicitSessionHash generates a sticky-session hash only from explicit // client session signals. It intentionally skips content-derived fallback and is // used by stateless endpoints such as /v1/images. func (s *OpenAIGatewayService) GenerateExplicitSessionHash(c *gin.Context, body []byte) string { sessionID := explicitOpenAIRequestSessionID(c, body) if sessionID == "" { return "" } currentHash, legacyHash := deriveOpenAISessionHashes(sessionID) attachOpenAILegacySessionHashToGin(c, legacyHash) return currentHash } // GenerateSessionHash generates a sticky-session hash for OpenAI requests. // // Priority: // 1. Header: session_id // 2. Header: conversation_id // 3. Header: x-session-affinity / x-session-id / x-opencode-session (OpenCode) // 4. Header: x-conversation-id (CodeBuddy) // 5. Header: x-grok-conv-id (Grok groups only) // 6. Body: prompt_cache_key // 7. Body: content-based fallback (model + system + tools + first user message) // // Grok sticky affinity is intentionally separate from the upstream // prompt_cache_key identity (resolveGrokCacheIdentity): sticky pins an OAuth // account for multi-turn routing, while the cache identity is tenant+model // isolated for xAI server-side prompt cache. For Grok groups we scope the // sticky seed with the client-requested model so switching models does not // inherit a stale account binding (grok2api affinityKey pattern). func (s *OpenAIGatewayService) GenerateSessionHash(c *gin.Context, body []byte) string { if c == nil { return "" } sessionID := explicitOpenAIRequestSessionID(c, body) if sessionID == "" && len(body) > 0 { sessionID = deriveOpenAIContentSessionSeed(body) } if sessionID == "" { return "" } if isGrokRequestContext(c) { sessionID = grokStickyAffinitySeed(sessionID, body) } currentHash, legacyHash := deriveOpenAISessionHashes(sessionID) attachOpenAILegacySessionHashToGin(c, legacyHash) return currentHash } // grokStickyAffinitySeed scopes sticky routing by model without changing the // upstream prompt_cache_key written by applyGrokResponsesCacheIdentity. func grokStickyAffinitySeed(sessionID string, body []byte) string { sessionID = strings.TrimSpace(sessionID) if sessionID == "" { return "" } model := "" if len(body) > 0 { model = strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "model").String())) } if model == "" { return "grok-affinity:v1:" + sessionID } return "grok-affinity:v1:" + model + ":" + sessionID } // GenerateSessionHashWithFallback 先按常规信号生成会话哈希; // 当未携带 session_id/conversation_id/prompt_cache_key 时,使用 fallbackSeed 生成稳定哈希。 // 该方法用于 WS ingress,避免会话信号缺失时发生跨账号漂移。 func (s *OpenAIGatewayService) GenerateSessionHashWithFallback(c *gin.Context, body []byte, fallbackSeed string) string { sessionHash := s.GenerateSessionHash(c, body) if sessionHash != "" { return sessionHash } seed := strings.TrimSpace(fallbackSeed) if seed == "" { return "" } currentHash, legacyHash := deriveOpenAISessionHashes(seed) attachOpenAILegacySessionHashToGin(c, legacyHash) return currentHash } func resolveOpenAIUpstreamOriginator(c *gin.Context, isOfficialClient bool) string { if c != nil { if originator := strings.TrimSpace(c.GetHeader("originator")); originator != "" { return originator } } if isOfficialClient { return openai.CodexDefaultOriginator } return "opencode" } // BindStickySession sets session -> account binding with standard TTL. func (s *OpenAIGatewayService) BindStickySession(ctx context.Context, groupID *int64, sessionHash string, accountID int64) error { if sessionHash == "" || accountID <= 0 { return nil } ttl := openaiStickySessionTTL if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.StickySessionTTLSeconds > 0 { ttl = time.Duration(s.cfg.Gateway.OpenAIWS.StickySessionTTLSeconds) * time.Second } return s.setStickySessionAccountID(ctx, groupID, sessionHash, accountID, ttl) } // SelectAccount selects an OpenAI account with sticky session support func (s *OpenAIGatewayService) SelectAccount(ctx context.Context, groupID *int64, sessionHash string) (*Account, error) { return s.SelectAccountForModel(ctx, groupID, sessionHash, "") } // SelectAccountForModel selects an account supporting the requested model func (s *OpenAIGatewayService) SelectAccountForModel(ctx context.Context, groupID *int64, sessionHash string, requestedModel string) (*Account, error) { return s.SelectAccountForModelWithExclusions(ctx, groupID, sessionHash, requestedModel, nil) } // SelectAccountForModelWithExclusions selects an account supporting the requested model while excluding specified accounts. // SelectAccountForModelWithExclusions 选择支持指定模型的账号,同时排除指定的账号。 func (s *OpenAIGatewayService) SelectAccountForModelWithExclusions(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*Account, error) { return s.selectAccountForModelWithExclusions(s.withOpenAIQuotaAutoPauseContext(ctx), groupID, PlatformOpenAI, sessionHash, requestedModel, excludedIDs, false, 0, "", false) } // NormalizeOpenAICompatiblePlatform 保留 grok 与国产 OpenAI 兼容供应商(kimi/zhipu/ // deepseek)的原值,其他值一律归一为 openai。调度器据此对账号与请求做精确平台匹配: // kimi 分组请求只命中 kimi 账号,语义与 openai/grok 一致。 // (upstream 曾将本函数改为未导出 normalizeOpenAICompatiblePlatform,本分支的 // handler 调度入口仍需导出,保持导出名。) func NormalizeOpenAICompatiblePlatform(platform string) string { switch platform { case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek: return platform default: return PlatformOpenAI } } // noAvailableOpenAISelectionError builds the standard "no account available" error // while preserving the legacy /responses/compact error when applicable. // details carries an optional machine-parseable exclusion summary (e.g. // "pool=2, filtered: quota_auto_pause_7d=1 runtime_blocked=1") appended in // parentheses. It is for server-side logs / ops diagnostics only: handlers // never forward this error text to OpenAI-platform clients (they respond with // the generic classification message). Callers that must preserve the legacy // message pass "". func noAvailableOpenAISelectionError(requestedModel string, compactBlocked bool, details string) error { if compactBlocked { return ErrNoAvailableCompactAccounts } message := "no available OpenAI accounts" if requestedModel != "" { message = fmt.Sprintf("no available OpenAI accounts supporting model: %s", requestedModel) } if details != "" { message += " (" + details + ")" } return openAINoAvailableSelectionError{message: message} } type openAINoAvailableSelectionError struct { message string } func (e openAINoAvailableSelectionError) Error() string { return e.message } func (e openAINoAvailableSelectionError) Unwrap() error { return ErrNoAvailableAccounts } // openAICompactSupportTier classifies an OpenAI-compatible account by compact capability. // 0 = explicitly unsupported, 1 = unknown / not yet probed, 2 = explicitly supported. func openAICompactSupportTier(account *Account) int { if account == nil { return 0 } if account.IsGrok() { return 2 } if !account.IsOpenAI() { return 0 } supported, known := account.OpenAICompactSupportKnown() if !known { return 1 } if supported { return 2 } return 0 } // isOpenAICompatibleAccountEligibleForRequest 判断 OpenAI 兼容账号是否满足本次请求的调度条件。 // 检查内容包括:平台匹配、账号可用性、quota 自动暂停、spark 路由限制、模型支持及端点能力。 // // 注意:对 spark 影子账号,调用方还须额外调用 parentHealthyForShadow(account, lookup) // 检查母账号凭据可用性;该检查未内置于本函数,以避免注入 DB 依赖。 func isOpenAICompatibleAccountEligibleForRequest(ctx context.Context, account *Account, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) bool { if !isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx, account, platform, requestedModel, requireCompact, requiredCapability) { return false } // 分组利润控制:legacy 引擎的粘性/候选循环与 DB recheck 共用 // 本判定,任何 fallback 都不能把利润不合格账号重新放回候选。 if vetoed, _ := openAIProfitControlVetoReason(ctx, account); vetoed { return false } return true } // isOpenAICompatibleAccountEligibleForRequestBeforeProfit applies every // ordinary scheduling gate. Legacy selection uses it before classifying the // profit veto so earlier failures retain their actual reason. func isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx context.Context, account *Account, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) bool { platform = NormalizeOpenAICompatiblePlatform(platform) if account == nil || account.Platform != platform || !account.IsOpenAICompatible() || !account.IsSchedulableForModelWithContext(ctx, requestedModel) { return false } if account.IsOpenAI() { if paused, reason := shouldAutoPauseOpenAIAccountByQuota(ctx, account); paused { // Debug level: this fires per-candidate on the scheduling hot path, so Info // would amplify into log spam once several accounts cross the threshold. slog.Debug("account_auto_paused_by_quota", "account_id", account.ID, "window", reason.window, "threshold", reason.threshold, "utilization", reason.utilization, ) return false } } if account.IsGrok() { if paused, reason := shouldAutoPauseGrokAccountByQuota(account); paused { slog.Debug("grok_account_auto_paused_by_quota", "account_id", account.ID, "window", reason.window, "threshold", reason.threshold, "utilization", reason.utilization, ) return false } } if requestedModel != "" && !account.IsModelSupported(requestedModel) { return false } if !account.SupportsOpenAIEndpointCapability(requiredCapability) { if account.IsGrok() && requiredCapability == OpenAIEndpointCapabilityGrokMediaGeneration { _, reason := account.GrokMediaGenerationEligibility() slog.Debug("grok_media_account_ineligible", "account_id", account.ID, "reason", reason) } return false } if requireCompact && openAICompactSupportTier(account) == 0 { return false } return true } type openAIQuotaAutoPauseDecision struct { window string threshold float64 utilization float64 } func shouldAutoPauseGrokAccountByQuota(account *Account) (bool, openAIQuotaAutoPauseDecision) { if account == nil || !account.IsGrok() || account.Type != AccountTypeOAuth { return false, openAIQuotaAutoPauseDecision{} } snapshot, err := grokQuotaSnapshotFromExtra(account.Extra) if err != nil || snapshot == nil { return false, openAIQuotaAutoPauseDecision{} } now := time.Now() if grokQuotaSnapshotStaleForPause(snapshot, now) { return false, openAIQuotaAutoPauseDecision{} } if grokQuotaRetryAfterActive(snapshot, now) { return true, openAIQuotaAutoPauseDecision{window: "retry_after", threshold: 1, utilization: 1} } if paused, decision := shouldAutoPauseGrokQuotaWindow("requests", snapshot.Requests, now); paused { return true, decision } if paused, decision := shouldAutoPauseGrokQuotaWindow("tokens", snapshot.Tokens, now); paused { return true, decision } return false, openAIQuotaAutoPauseDecision{} } func grokQuotaRetryAfterActive(snapshot *xai.QuotaSnapshot, now time.Time) bool { if snapshot == nil || snapshot.RetryAfterSeconds == nil || *snapshot.RetryAfterSeconds <= 0 { return false } if strings.TrimSpace(snapshot.UpdatedAt) == "" { return true } updatedAt, err := parseTime(snapshot.UpdatedAt) if err != nil { return true } retryAfterUntil := updatedAt.Add(time.Duration(*snapshot.RetryAfterSeconds) * time.Second) return now.Before(retryAfterUntil) } func shouldAutoPauseGrokQuotaWindow(name string, window *xai.QuotaWindow, now time.Time) (bool, openAIQuotaAutoPauseDecision) { if window == nil || window.Limit == nil || window.Remaining == nil || *window.Limit <= 0 { return false, openAIQuotaAutoPauseDecision{} } if window.ResetUnix != nil && *window.ResetUnix > 0 && !now.Before(time.Unix(*window.ResetUnix, 0)) { return false, openAIQuotaAutoPauseDecision{} } utilization := float64(*window.Limit-*window.Remaining) / float64(*window.Limit) if *window.Remaining <= 0 || utilization >= 1 { return true, openAIQuotaAutoPauseDecision{window: name, threshold: 1, utilization: utilization} } return false, openAIQuotaAutoPauseDecision{} } func grokQuotaSnapshotStaleForPause(snapshot *xai.QuotaSnapshot, now time.Time) bool { if snapshot == nil || strings.TrimSpace(snapshot.UpdatedAt) == "" { return false } updatedAt, err := parseTime(snapshot.UpdatedAt) if err != nil { return false } return now.Sub(updatedAt) >= openAICodexAutoPauseStaleAfter } func shouldAutoPauseOpenAIAccountByQuota(ctx context.Context, account *Account) (bool, openAIQuotaAutoPauseDecision) { if account == nil || !account.IsOpenAI() { return false, openAIQuotaAutoPauseDecision{} } // Per-account explicit-disable flags must take precedence over the global default. // Without these, leaving the account threshold blank means "use global default", // so an admin has no way to exempt a single account from auto-pause once a global // default exists. The disable flag is per-window so an account can opt out of // only 5h or only 7d auto-pause. disabled5h := resolveAccountExtraBool(account.Extra, "auto_pause_5h_disabled") disabled7d := resolveAccountExtraBool(account.Extra, "auto_pause_7d_disabled") threshold5h, threshold7d := resolveOpenAIQuotaAutoPauseThresholds(ctx, account) now := time.Now() if !disabled5h && threshold5h > 0 { if utilization, ok := resolveOpenAIQuotaUtilization(account.Extra, "5h", now); ok && utilization >= threshold5h { return true, openAIQuotaAutoPauseDecision{window: "5h", threshold: threshold5h, utilization: utilization} } } if !disabled7d && threshold7d > 0 { if utilization, ok := resolveOpenAIQuotaUtilization(account.Extra, "7d", now); ok && utilization >= threshold7d { return true, openAIQuotaAutoPauseDecision{window: "7d", threshold: threshold7d, utilization: utilization} } } return false, openAIQuotaAutoPauseDecision{} } // resolveAccountExtraBool reads a bool-like value from account extra, tolerating // the few shapes JSON unmarshalling may produce (real bool, "true"/"false" // strings, 0/1 numbers). func resolveAccountExtraBool(extra map[string]any, key string) bool { if len(extra) == 0 { return false } value, ok := extra[key] if !ok || value == nil { return false } switch v := value.(type) { case bool: return v case string: parsed, err := strconv.ParseBool(strings.TrimSpace(v)) return err == nil && parsed case float64: return v != 0 case float32: return v != 0 case int: return v != 0 case int64: return v != 0 case json.Number: if i, err := v.Int64(); err == nil { return i != 0 } } return false } func resolveOpenAIQuotaAutoPauseThresholds(ctx context.Context, account *Account) (float64, float64) { threshold5h, _ := resolveAccountExtraNumber(account.Extra, "auto_pause_5h_threshold") threshold7d, _ := resolveAccountExtraNumber(account.Extra, "auto_pause_7d_threshold") threshold5h = clamp01(threshold5h) threshold7d = clamp01(threshold7d) if threshold5h > 0 && threshold7d > 0 { return threshold5h, threshold7d } settings := openAIQuotaAutoPauseSettingsFromContext(ctx) if threshold5h <= 0 { threshold5h = clamp01(settings.DefaultThreshold5h) } if threshold7d <= 0 { threshold7d = clamp01(settings.DefaultThreshold7d) } return threshold5h, threshold7d } func resolveAccountExtraNumber(extra map[string]any, keys ...string) (float64, bool) { if len(extra) == 0 { return 0, false } for _, key := range keys { value, ok := extra[key] if !ok || value == nil { continue } switch v := value.(type) { case float64: return v, true case float32: return float64(v), true case int: return float64(v), true case int64: return float64(v), true case json.Number: parsed, err := v.Float64() if err == nil { return parsed, true } case string: parsed, err := strconv.ParseFloat(strings.TrimSpace(v), 64) if err == nil { return parsed, true } } } return 0, false } // resolveOpenAIQuotaUtilization returns the current utilization ratio (0..1) for the // given Codex usage window. ok=false means there is no usable signal to pause on: // either no snapshot exists, or the window has already rolled over so the cached // percentage is stale. The stale guard matters because a paused account stops // receiving requests, so its snapshot is never refreshed from upstream headers — // without this check an old used_percent would keep the account paused forever even // after the real window reset. func resolveOpenAIQuotaUtilization(extra map[string]any, window string, now time.Time) (float64, bool) { usedPercent := readOpenAIQuotaUsedPercent(extra, window) if usedPercent <= 0 { return 0, false } if openAIQuotaWindowReset(extra, window, now) { return 0, false } // 快照过于陈旧(账号长期未收到流量刷新)时,不再据此暂停。放行后下一次响应头 // 会刷新快照实现自愈,避免账号在错误/过期的 used% 上被永久跳过(issue #2994)。 if openAICodexSnapshotStaleForPause(extra, now) { return 0, false } return usedPercent / 100, true } // openAICodexSnapshotStaleForPause reports whether the Codex usage snapshot is stale // enough that it should no longer keep an account auto-paused. It anchors on // codex_usage_updated_at (always written by buildCodexUsageExtraUpdates). A missing or // unparseable timestamp returns false (treated as fresh, so the account stays paused) — // this is deliberate: it prevents any snapshot without a write time from silently escaping // auto-pause, and a genuinely-exhausted account that is actively served refreshes the // timestamp on every response so it never crosses the staleness bound. func openAICodexSnapshotStaleForPause(extra map[string]any, now time.Time) bool { if len(extra) == 0 { return false } updatedRaw, ok := extra["codex_usage_updated_at"] if !ok { return false } updatedAt, err := parseTime(fmt.Sprint(updatedRaw)) if err != nil { return false } return now.Sub(updatedAt) >= openAICodexAutoPauseStaleAfter } // openAIQuotaWindowReset reports whether the Codex usage window's reset time has // already passed relative to now. It prefers the absolute codex__reset_at // timestamp and falls back to codex__reset_after_seconds anchored at // codex_usage_updated_at, mirroring AccountUsageService's window-progress logic. func openAIQuotaWindowReset(extra map[string]any, window string, now time.Time) bool { if len(extra) == 0 { return false } if resetAtRaw, ok := extra["codex_"+window+"_reset_at"]; ok { if resetAt, err := parseTime(fmt.Sprint(resetAtRaw)); err == nil { return !now.Before(resetAt) } } resetAfter := parseExtraInt(extra["codex_"+window+"_reset_after_seconds"]) if resetAfter <= 0 { return false } base := now if updatedRaw, ok := extra["codex_usage_updated_at"]; ok { if updatedAt, err := parseTime(fmt.Sprint(updatedRaw)); err == nil { base = updatedAt } } resetAt := base.Add(time.Duration(resetAfter) * time.Second) return !now.Before(resetAt) } func readOpenAIQuotaUsedPercent(extra map[string]any, window string) float64 { if len(extra) == 0 { return 0 } if value, ok := resolveAccountExtraNumber(extra, "codex_"+window+"_used_percent"); ok { return value } return 0 } type openAIQuotaAutoPauseCtxKey struct{} func withOpenAIQuotaAutoPauseSettings(ctx context.Context, settings OpsOpenAIAccountQuotaAutoPauseSettings) context.Context { if ctx == nil { ctx = context.Background() } return context.WithValue(ctx, openAIQuotaAutoPauseCtxKey{}, settings) } func openAIQuotaAutoPauseSettingsFromContext(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings { if ctx == nil { return OpsOpenAIAccountQuotaAutoPauseSettings{} } settings, _ := ctx.Value(openAIQuotaAutoPauseCtxKey{}).(OpsOpenAIAccountQuotaAutoPauseSettings) return settings } func (s *OpenAIGatewayService) withOpenAIQuotaAutoPauseContext(ctx context.Context) context.Context { if s == nil || s.settingService == nil { return ctx } return withOpenAIQuotaAutoPauseSettings(ctx, s.settingService.GetOpenAIQuotaAutoPauseSettings(ctx)) } // prioritizeOpenAICompactAccounts re-orders a slice so that accounts with known // compact support are tried first, followed by unknown, then explicitly unsupported. // The relative order within each tier is preserved. func prioritizeOpenAICompactAccounts(accounts []*Account) []*Account { if len(accounts) == 0 { return nil } supported := make([]*Account, 0, len(accounts)) unknown := make([]*Account, 0, len(accounts)) unsupported := make([]*Account, 0, len(accounts)) for _, account := range accounts { switch openAICompactSupportTier(account) { case 2: supported = append(supported, account) case 1: unknown = append(unknown, account) default: unsupported = append(unsupported, account) } } out := make([]*Account, 0, len(accounts)) out = append(out, supported...) out = append(out, unknown...) out = append(out, unsupported...) return out } // resolveOpenAIAccountUpstreamModelForRequest resolves the upstream model that // would be sent for a given request, honoring the legacy compact-only mapping // when the caller is on the /responses/compact path. func resolveOpenAIAccountUpstreamModelForRequest(account *Account, requestedModel string, requireCompact bool) string { // Forward checks the raw Chat Completions fallback before passthrough. // These API-key accounts therefore apply normal account model_mapping and // upstream normalization, but never compact_model_mapping. if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { upstreamModel := resolveOpenAIForwardModel(account, requestedModel, "") return normalizeOpenAIModelForUpstream(account, upstreamModel) } // Passthrough accounts only replace authentication. Their Forward path // keeps the channel-mapped model in the request body and does not apply the // account's normal model_mapping. Legacy /responses/compact is the one // exception: forwardOpenAIPassthrough applies compact_model_mapping // directly to that channel-mapped model. if account != nil && account.IsOpenAIPassthroughEnabled() { upstreamModel := strings.TrimSpace(requestedModel) if upstreamModel == "" { return "" } if requireCompact { return resolveOpenAICompactForwardModel(account, upstreamModel) } return upstreamModel } upstreamModel := resolveOpenAIForwardModel(account, requestedModel, "") if upstreamModel == "" { return "" } if requireCompact { compactModel := resolveOpenAICompactForwardModel(account, upstreamModel) if compactModel != upstreamModel { return compactModel } } return normalizeOpenAIModelForUpstream(account, upstreamModel) } func (s *OpenAIGatewayService) selectAccountForModelWithExclusions(ctx context.Context, groupID *int64, platform string, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, stickyAccountID int64, requiredCapability OpenAIEndpointCapability, preferLowUpstreamRate bool) (*Account, error) { platform = NormalizeOpenAICompatiblePlatform(platform) if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) { slog.Warn("channel pricing restriction blocked request", "group_id", derefGroupID(groupID), "model", requestedModel) return nil, fmt.Errorf("%w supporting model: %s (channel pricing restriction)", ErrNoAvailableAccounts, requestedModel) } // 1. 尝试粘性会话命中 // Try sticky session hit if account := s.tryStickySessionHit(ctx, groupID, platform, sessionHash, requestedModel, excludedIDs, requireCompact, stickyAccountID, requiredCapability); account != nil { return account, nil } // 2. 获取可调度的 OpenAI 账号 // Get schedulable OpenAI accounts accounts, err := s.listSchedulableAccounts(ctx, groupID, platform) if err != nil { return nil, fmt.Errorf("query accounts failed: %w", err) } // 3. 按优先级 + LRU 选择最佳账号 // Select by priority + LRU selected, compactBlocked, filterStats := s.selectBestAccount(ctx, groupID, platform, accounts, requestedModel, excludedIDs, requireCompact, requiredCapability, preferLowUpstreamRate) if selected == nil { return nil, noAvailableOpenAISelectionError(requestedModel, compactBlocked, filterStats.summary("")) } hydrated, err := s.hydrateSelectedAccount(ctx, selected) if err != nil { return nil, err } // 4. 设置粘性会话绑定(利润门下推迟到 handler 终检通过后再绑定, // 终检否决的账号不得成为新的粘性目标;无门保持既有 eager 绑定与 TTL) // Set sticky session binding (deferred until terminal admission under a profit gate) if sessionHash != "" && !gatewayProfitControlGateActive(ctx) { _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, selected.ID, openaiStickySessionTTL) } return hydrated, nil } // tryStickySessionHit 尝试从粘性会话获取账号。 // 如果命中且账号可用则返回账号;如果账号不可用则清理会话并返回 nil。 // // tryStickySessionHit attempts to get account from sticky session. // Returns account if hit and usable; clears session and returns nil if account is unavailable. func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID *int64, platform string, sessionHash, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, stickyAccountID int64, requiredCapability OpenAIEndpointCapability) *Account { if sessionHash == "" { return nil } platform = NormalizeOpenAICompatiblePlatform(platform) accountID := stickyAccountID if accountID <= 0 { var err error accountID, err = s.getStickySessionAccountID(ctx, groupID, sessionHash) if err != nil || accountID <= 0 { return nil } } if _, excluded := excludedIDs[accountID]; excluded { return nil } account, err := s.getSchedulableAccount(ctx, accountID) if err != nil { return nil } // 检查账号是否需要清理粘性会话 // Check if sticky session should be cleared if shouldClearStickySession(account, requestedModel) { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) return nil } // 验证账号是否可用于当前请求 // Verify account is usable for current request if !isOpenAICompatibleAccountEligibleForRequest(ctx, account, platform, requestedModel, false, requiredCapability) { return nil } if !parentHealthyForShadow(account, s.parentAccountLookup(ctx)) { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) return nil } if s.isOpenAIAccountRequestRuntimeBlocked(account, requestedModel) { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) return nil } account = s.recheckSelectedOpenAIAccountFromDB(ctx, account, groupID, platform, requestedModel, requireCompact, requiredCapability) if account == nil || !s.openAIAccountMatchesSchedulingGroup(account, groupID) { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) return nil } if groupID != nil && s.needsUpstreamChannelRestrictionCheck(ctx, groupID) && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, account, requestedModel, requireCompact) { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) return nil } // 刷新会话 TTL 并返回账号 // Refresh session TTL and return account _ = s.refreshStickySessionTTL(ctx, groupID, sessionHash, openaiStickySessionTTL) return account } // selectBestAccount 从候选账号中选择最佳账号(优先级 + LRU)。 // 返回 nil 表示无可用账号。 // // selectBestAccount selects the best account from candidates (priority + LRU). // Returns nil if no available account. The second return reports whether at // least one candidate was filtered out solely because it lacks compact support // (only meaningful when the legacy /responses/compact requireCompact flag is // true); the third contains deterministic // exclusion diagnostics for the evaluated snapshot. func (s *OpenAIGatewayService) selectBestAccount(ctx context.Context, groupID *int64, platform string, accounts []Account, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, requiredCapability OpenAIEndpointCapability, preferLowUpstreamRate bool) (*Account, bool, openAISelectionFilterStats) { platform = NormalizeOpenAICompatiblePlatform(platform) compactBlocked := false filterStats := openAISelectionFilterStats{pool: len(accounts)} needsUpstreamCheck := s.needsUpstreamChannelRestrictionCheck(ctx, groupID) eligible := make([]*Account, 0, len(accounts)) compactTiers := make(map[int64]int, len(accounts)) for i := range accounts { acc := &accounts[i] // 跳过被排除的账号 // Skip excluded accounts if _, excluded := excludedIDs[acc.ID]; excluded { filterStats.exclude("excluded") continue } fresh := s.resolveFreshSchedulableOpenAIAccountBeforeProfit(ctx, acc, platform, requestedModel, false, requiredCapability) if fresh == nil { filterStats.exclude("ineligible") continue } fresh = s.recheckSelectedOpenAIAccountFromDBBeforeProfit(ctx, fresh, groupID, platform, requestedModel, false, requiredCapability) if fresh == nil { filterStats.exclude("ineligible") continue } if needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, fresh, requestedModel, requireCompact) { filterStats.exclude("channel_restricted") continue } if vetoed, reason := openAIProfitControlVetoReason(ctx, fresh); vetoed { filterStats.exclude(reason) continue } compactTier := 0 if requireCompact { compactTier = openAICompactSupportTier(fresh) if compactTier == 0 { compactBlocked = true filterStats.exclude("compact_unsupported") continue } } eligible = append(eligible, fresh) compactTiers[fresh.ID] = compactTier } if len(eligible) == 0 { return nil, compactBlocked, filterStats } rateOrder := openAILegacyUpstreamRateOrder{} if preferLowUpstreamRate { rateOrder = newOpenAILegacyUpstreamRateOrder(eligible, time.Now(), s.openAIOAuthSchedulingRateMultiplier(ctx)) } sort.SliceStable(eligible, func(i, j int) bool { a, b := eligible[i], eligible[j] if requireCompact && compactTiers[a.ID] != compactTiers[b.ID] { return compactTiers[a.ID] > compactTiers[b.ID] } if rateCmp := rateOrder.compare(a, b); rateCmp != 0 { return rateCmp < 0 } return s.isBetterAccount(a, b) }) return eligible[0], compactBlocked, filterStats } // isBetterAccount 判断 candidate 是否比 current 更优。 // 规则:优先级更高(数值更小)优先;同优先级时,未使用过的优先,其次是最久未使用的。 // // isBetterAccount checks if candidate is better than current. // Rules: higher priority (lower value) wins; same priority: never used > least recently used. func (s *OpenAIGatewayService) isBetterAccount(candidate, current *Account) bool { // 优先级更高(数值更小) // Higher priority (lower value) if candidate.Priority < current.Priority { return true } if candidate.Priority > current.Priority { return false } // 同优先级,比较最后使用时间 // Same priority, compare last used time switch { case candidate.LastUsedAt == nil && current.LastUsedAt != nil: // candidate 从未使用,优先 return true case candidate.LastUsedAt != nil && current.LastUsedAt == nil: // current 从未使用,保持 return false case candidate.LastUsedAt == nil && current.LastUsedAt == nil: // 都未使用,保持 return false default: // 都使用过,选择最久未使用的 return candidate.LastUsedAt.Before(*current.LastUsedAt) } } // SelectAccountWithLoadAwareness selects an account with load-awareness and wait plan. func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*AccountSelectionResult, error) { ctx = s.withOpenAIQuotaAutoPauseContext(ctx) // 分组利润控制:legacy 公共入口同样装门,保证不经 // selectAccountWithScheduler 的调用方也无法绕过利润准入。 ctx = s.withOpenAIProfitControlGate(ctx, groupID) return s.selectAccountWithLoadAwareness(ctx, groupID, PlatformOpenAI, sessionHash, requestedModel, excludedIDs, false, "", true) } func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Context, groupID *int64, platform string, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, requiredCapability OpenAIEndpointCapability, useUpstreamTokenCost bool) (*AccountSelectionResult, error) { platform = NormalizeOpenAICompatiblePlatform(platform) if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) { slog.Warn("channel pricing restriction blocked request", "group_id", derefGroupID(groupID), "model", requestedModel) return nil, fmt.Errorf("%w supporting model: %s (channel pricing restriction)", ErrNoAvailableAccounts, requestedModel) } cfg := s.schedulingConfig() preferLowUpstreamRate := useUpstreamTokenCost && s.isOpenAILowUpstreamRatePriorityEnabled(ctx) needsUpstreamCheck := s.needsUpstreamChannelRestrictionCheck(ctx, groupID) var stickyAccountID int64 if sessionHash != "" && s.cache != nil { if accountID, err := s.getStickySessionAccountID(ctx, groupID, sessionHash); err == nil { stickyAccountID = accountID } } if s.concurrencyService == nil || !cfg.LoadBatchEnabled { account, err := s.selectAccountForModelWithExclusions(ctx, groupID, platform, sessionHash, requestedModel, excludedIDs, requireCompact, stickyAccountID, requiredCapability, preferLowUpstreamRate) if err != nil { return nil, err } result, err := s.tryAcquireAccountSlot(ctx, account.ID, account.Concurrency) if err == nil && result != nil && result.Acquired { return s.newAcquiredSelectionResult(ctx, account, result.ReleaseFunc) } if stickyAccountID > 0 && stickyAccountID == account.ID && s.concurrencyService != nil { waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, account.ID) if waitingCount < cfg.StickySessionMaxWaiting { return s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{ AccountID: account.ID, MaxConcurrency: account.Concurrency, Timeout: cfg.StickySessionWaitTimeout, MaxWaiting: cfg.StickySessionMaxWaiting, }) } } return s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{ AccountID: account.ID, MaxConcurrency: account.Concurrency, Timeout: cfg.FallbackWaitTimeout, MaxWaiting: cfg.FallbackMaxWaiting, }) } accounts, err := s.listSchedulableAccounts(ctx, groupID, platform) if err != nil { return nil, err } if len(accounts) == 0 { return nil, ErrNoAvailableAccounts } isExcluded := func(accountID int64) bool { if excludedIDs == nil { return false } _, excluded := excludedIDs[accountID] return excluded } // ============ Layer 1: Sticky session ============ if sessionHash != "" { accountID := stickyAccountID if accountID > 0 && !isExcluded(accountID) { account, err := s.getSchedulableAccount(ctx, accountID) if err == nil { clearSticky := shouldClearStickySession(account, requestedModel) if clearSticky { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) } if !clearSticky && isOpenAICompatibleAccountEligibleForRequest(ctx, account, platform, requestedModel, false, requiredCapability) { account = s.recheckSelectedOpenAIAccountFromDB(ctx, account, groupID, platform, requestedModel, requireCompact, requiredCapability) if account == nil { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) } else if !s.openAIAccountMatchesSchedulingGroup(account, groupID) { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) } else if s.isOpenAIAccountRequestRuntimeBlocked(account, requestedModel) { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) } else if needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, account, requestedModel, requireCompact) { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) } else if !parentHealthyForShadow(account, s.parentAccountLookup(ctx)) { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) } else { result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency) if err == nil && result != nil && result.Acquired { selection, selectErr := s.newAcquiredSelectionResult(ctx, account, result.ReleaseFunc) if selectErr != nil { return nil, selectErr } _ = s.refreshStickySessionTTL(ctx, groupID, sessionHash, openaiStickySessionTTL) return selection, nil } waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, accountID) if waitingCount < cfg.StickySessionMaxWaiting { return s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{ AccountID: accountID, MaxConcurrency: account.Concurrency, Timeout: cfg.StickySessionWaitTimeout, MaxWaiting: cfg.StickySessionMaxWaiting, }) } } } } } } // ============ Layer 2: Load-aware selection ============ // Per-pass parent-health cache to avoid repeated DB calls when multiple shadow // accounts share the same parent. parentCacheL2 := make(map[int64]*Account) parentLookupL2 := func(id int64) *Account { if a, ok := parentCacheL2[id]; ok { return a } if s.accountRepo == nil { return nil } a, _ := s.accountRepo.GetByID(ctx, id) parentCacheL2[id] = a return a } baseCandidateCount := 0 candidates := make([]*Account, 0, len(accounts)) for i := range accounts { acc := &accounts[i] if isExcluded(acc.ID) { continue } // Scheduler snapshots can be temporarily stale (bucket rebuild is throttled); // re-check schedulability here so recently rate-limited/overloaded accounts // are not selected again before the bucket is rebuilt. if !isOpenAICompatibleAccountEligibleForRequest(ctx, acc, platform, requestedModel, false, requiredCapability) { continue } if !parentHealthyForShadow(acc, parentLookupL2) { continue } if s.isOpenAIAccountRequestRuntimeBlocked(acc, requestedModel) { continue } if needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, acc, requestedModel, requireCompact) { continue } baseCandidateCount++ candidates = append(candidates, acc) } if len(candidates) == 0 { return nil, ErrNoAvailableAccounts } rateOrder := openAILegacyUpstreamRateOrder{} if preferLowUpstreamRate { rateOrder = newOpenAILegacyUpstreamRateOrder(candidates, time.Now(), s.openAIOAuthSchedulingRateMultiplier(ctx)) } accountLoads := make([]AccountWithConcurrency, 0, len(candidates)) for _, acc := range candidates { accountLoads = append(accountLoads, AccountWithConcurrency{ ID: acc.ID, MaxConcurrency: acc.EffectiveLoadFactor(), }) } tryAcquireFromLoadMap := func(loadMap map[int64]*AccountLoadInfo) (*AccountSelectionResult, bool, error) { var available []accountWithLoad for _, acc := range candidates { loadInfo := loadMap[acc.ID] if loadInfo == nil { loadInfo = &AccountLoadInfo{AccountID: acc.ID} } if loadInfo.LoadRate < 100 { available = append(available, accountWithLoad{ account: acc, loadInfo: loadInfo, }) } } if len(available) == 0 { return nil, false, nil } sort.SliceStable(available, func(i, j int) bool { a, b := available[i], available[j] if a.account.Priority != b.account.Priority { return a.account.Priority < b.account.Priority } if a.loadInfo.LoadRate != b.loadInfo.LoadRate { return a.loadInfo.LoadRate < b.loadInfo.LoadRate } switch { case a.account.LastUsedAt == nil && b.account.LastUsedAt != nil: return true case a.account.LastUsedAt != nil && b.account.LastUsedAt == nil: return false case a.account.LastUsedAt == nil && b.account.LastUsedAt == nil: return false default: return a.account.LastUsedAt.Before(*b.account.LastUsedAt) } }) shuffleWithinSortGroups(available) if rateOrder.enabled { sort.SliceStable(available, func(i, j int) bool { return rateOrder.compare(available[i].account, available[j].account) < 0 }) } selectionOrder := make([]accountWithLoad, 0, len(available)) if requireCompact { appendTier := func(out []accountWithLoad, tier int) []accountWithLoad { for _, item := range available { if openAICompactSupportTier(item.account) == tier { out = append(out, item) } } return out } selectionOrder = appendTier(selectionOrder, 2) selectionOrder = appendTier(selectionOrder, 1) // tier 0 候选作为兜底追加:DB recheck 时若发现 cache tier 0 实际 // 已升级为 1/2(探测刚跑完,cache 尚未刷新),仍可正常命中。 selectionOrder = appendTier(selectionOrder, 0) } else { selectionOrder = append(selectionOrder, available...) } for _, item := range selectionOrder { fresh := s.resolveFreshSchedulableOpenAIAccount(ctx, item.account, platform, requestedModel, false, requiredCapability) if fresh == nil { continue } fresh = s.recheckSelectedOpenAIAccountFromDB(ctx, fresh, groupID, platform, requestedModel, requireCompact, requiredCapability) if fresh == nil { continue } if needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, fresh, requestedModel, requireCompact) { continue } result, err := s.tryAcquireAccountSlot(ctx, fresh.ID, fresh.Concurrency) if err == nil && result != nil && result.Acquired { selection, selectErr := s.newAcquiredSelectionResult(ctx, fresh, result.ReleaseFunc) if selectErr != nil { return nil, true, selectErr } if sessionHash != "" && !gatewayProfitControlGateActive(ctx) { _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, fresh.ID, openaiStickySessionTTL) } return selection, true, nil } } return nil, true, nil } loadMap, err := s.concurrencyService.GetAccountsLoadBatch(ctx, accountLoads) if err != nil { ordered := append([]*Account(nil), candidates...) sortAccountsByPriorityAndLastUsed(ordered, false) if rateOrder.enabled { sort.SliceStable(ordered, func(i, j int) bool { return rateOrder.compare(ordered[i], ordered[j]) < 0 }) } if requireCompact { ordered = prioritizeOpenAICompactAccounts(ordered) } for _, acc := range ordered { fresh := s.resolveFreshSchedulableOpenAIAccount(ctx, acc, platform, requestedModel, false, requiredCapability) if fresh == nil { continue } fresh = s.recheckSelectedOpenAIAccountFromDB(ctx, fresh, groupID, platform, requestedModel, requireCompact, requiredCapability) if fresh == nil { continue } if needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, fresh, requestedModel, requireCompact) { continue } result, err := s.tryAcquireAccountSlot(ctx, fresh.ID, fresh.Concurrency) if err == nil && result != nil && result.Acquired { selection, selectErr := s.newAcquiredSelectionResult(ctx, fresh, result.ReleaseFunc) if selectErr != nil { return nil, selectErr } if sessionHash != "" && !gatewayProfitControlGateActive(ctx) { _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, fresh.ID, openaiStickySessionTTL) } return selection, nil } } } else { if selection, attempted, selectErr := tryAcquireFromLoadMap(loadMap); selectErr != nil { return nil, selectErr } else if selection != nil { return selection, nil } else if attempted { if freshLoadMap, loadErr := s.concurrencyService.GetAccountsLoadBatchFresh(ctx, accountLoads); loadErr == nil { if selection, _, selectErr := tryAcquireFromLoadMap(freshLoadMap); selectErr != nil { return nil, selectErr } else if selection != nil { return selection, nil } } } } // ============ Layer 3: Fallback wait ============ sortAccountsByPriorityAndLastUsed(candidates, false) if rateOrder.enabled { sort.SliceStable(candidates, func(i, j int) bool { return rateOrder.compare(candidates[i], candidates[j]) < 0 }) } if requireCompact { candidates = prioritizeOpenAICompactAccounts(candidates) } for _, acc := range candidates { fresh := s.resolveFreshSchedulableOpenAIAccount(ctx, acc, platform, requestedModel, false, requiredCapability) if fresh == nil { continue } fresh = s.recheckSelectedOpenAIAccountFromDB(ctx, fresh, groupID, platform, requestedModel, requireCompact, requiredCapability) if fresh == nil { continue } if needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, fresh, requestedModel, requireCompact) { continue } return s.newSelectionResult(ctx, fresh, false, nil, &AccountWaitPlan{ AccountID: fresh.ID, MaxConcurrency: fresh.Concurrency, Timeout: cfg.FallbackWaitTimeout, MaxWaiting: cfg.FallbackMaxWaiting, }) } if requireCompact && baseCandidateCount > 0 { return nil, ErrNoAvailableCompactAccounts } return nil, ErrNoAvailableAccounts } func (s *OpenAIGatewayService) listSchedulableAccounts(ctx context.Context, groupID *int64, platform string) ([]Account, error) { platform = NormalizeOpenAICompatiblePlatform(platform) if s.schedulerSnapshot != nil { accounts, _, err := s.schedulerSnapshot.ListSchedulableAccounts(ctx, groupID, platform, false) if err != nil { return accounts, err } accounts = s.filterOpenAIAccountsBySchedulingThreshold(ctx, accounts) if platform == PlatformGrok { accounts = s.filterGrokFreeQuotaAccountsForOpenAI(ctx, accounts) } return accounts, nil } var accounts []Account var err error if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform) } else if groupID != nil { accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, platform) } else { accounts, err = s.accountRepo.ListSchedulableUngroupedByPlatform(ctx, platform) } if err != nil { return nil, fmt.Errorf("query accounts failed: %w", err) } accounts = s.filterOpenAIAccountsBySchedulingThreshold(ctx, accounts) if platform == PlatformGrok { accounts = s.filterGrokFreeQuotaAccountsForOpenAI(ctx, accounts) } return accounts, nil } func (s *OpenAIGatewayService) tryAcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int) (*AcquireResult, error) { if s.concurrencyService == nil { return &AcquireResult{Acquired: true, ReleaseFunc: func() {}}, nil } return s.concurrencyService.AcquireAccountSlot(ctx, accountID, maxConcurrency) } func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccount(ctx context.Context, account *Account, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) *Account { fresh := s.resolveFreshSchedulableOpenAIAccountBeforeProfit(ctx, account, platform, requestedModel, requireCompact, requiredCapability) if fresh == nil { return nil } if vetoed, _ := openAIProfitControlVetoReason(ctx, fresh); vetoed { return nil } return fresh } func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccountBeforeProfit(ctx context.Context, account *Account, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) *Account { if account == nil { return nil } platform = NormalizeOpenAICompatiblePlatform(platform) fresh := account if s.schedulerSnapshot != nil { current, err := s.getSchedulableAccount(ctx, account.ID) if err != nil || current == nil { return nil } fresh = current } if !isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx, fresh, platform, requestedModel, requireCompact, requiredCapability) { return nil } if !parentHealthyForShadow(fresh, s.parentAccountLookup(ctx)) { return nil } if s.isOpenAIAccountRequestRuntimeBlocked(fresh, requestedModel) { return nil } if s.isOpenAIAccountBlockedBySchedulingThreshold(ctx, fresh) { return nil } if s.isOpenAIProxyStreamQuarantined(ctx, fresh) { return nil } return fresh } // parentAccountLookup 返回供 parentHealthyForShadow 使用的母账号解析闭包:经 accountRepo // 按 ID 取当前 Account(repo 为空时 fail-closed 返回 nil)。统一调度/粘连各路径的母账号解析, // 取代各调用点重复内联的同一闭包(历史上 recheck 等路径还漏写过 accountRepo==nil 守卫)。 // L2 候选循环改用带 per-pass 缓存的 parentLookupL2,不走此方法。 func (s *OpenAIGatewayService) parentAccountLookup(ctx context.Context) func(int64) *Account { return func(id int64) *Account { if s.accountRepo == nil { return nil } a, _ := s.accountRepo.GetByID(ctx, id) return a } } func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDB(ctx context.Context, account *Account, groupID *int64, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) *Account { latest := s.recheckSelectedOpenAIAccountFromDBBeforeProfit(ctx, account, groupID, platform, requestedModel, requireCompact, requiredCapability) if latest == nil { return nil } if vetoed, _ := openAIProfitControlVetoReason(ctx, latest); vetoed { return nil } return latest } func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDBBeforeProfit(ctx context.Context, account *Account, groupID *int64, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) *Account { if account == nil { return nil } platform = NormalizeOpenAICompatiblePlatform(platform) if s.schedulerSnapshot == nil || s.accountRepo == nil { if !isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx, account, platform, requestedModel, requireCompact, requiredCapability) { return nil } if s.isOpenAIAccountBlockedBySchedulingThreshold(ctx, account) { return nil } if !parentHealthyForShadow(account, s.parentAccountLookup(ctx)) { return nil } if s.isOpenAIProxyStreamQuarantined(ctx, account) { return nil } return account } latest, err := s.accountRepo.GetByID(ctx, account.ID) if err != nil || latest == nil { return nil } if !s.openAIAccountMatchesSchedulingGroup(latest, groupID) { return nil } if !isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx, latest, platform, requestedModel, requireCompact, requiredCapability) { return nil } if !parentHealthyForShadow(latest, s.parentAccountLookup(ctx)) { return nil } if s.isOpenAIAccountRequestRuntimeBlocked(latest, requestedModel) { return nil } if s.isOpenAIAccountBlockedBySchedulingThreshold(ctx, latest) { return nil } if s.isOpenAIProxyStreamQuarantined(ctx, latest) { return nil } return latest } func (s *OpenAIGatewayService) openAIAccountMatchesSchedulingGroup(account *Account, groupID *int64) bool { if s != nil && s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { return account != nil } return openAIStickyAccountMatchesGroup(account, groupID) } func (s *OpenAIGatewayService) getSchedulableAccount(ctx context.Context, accountID int64) (*Account, error) { var ( account *Account err error ) if s.schedulerSnapshot != nil { account, err = s.schedulerSnapshot.GetAccount(ctx, accountID) } else { account, err = s.accountRepo.GetByID(ctx, accountID) } if err != nil || account == nil { return account, err } if s.isOpenAIAccountBlockedBySchedulingThreshold(ctx, account) { return nil, nil } // Legacy sticky (advanced scheduler off) must still free-gate Grok OAuth. if account.IsGrok() { if gated := s.filterGrokFreeQuotaAccountsForOpenAI(ctx, []Account{*account}); len(gated) == 0 { return nil, nil } } return account, nil } // filterGrokFreeQuotaAccountsForOpenAI applies the same local free soft-gate as // GatewayService / advanced scheduler, for OpenAI-compatible legacy selection. func (s *OpenAIGatewayService) filterGrokFreeQuotaAccountsForOpenAI(ctx context.Context, accounts []Account) []Account { if s == nil { return accounts } return filterGrokFreeQuotaAccountsCore(ctx, s.cfg, s.usageLogRepo, &openaiGrokFreeQuotaGateCache, accounts) } func (s *OpenAIGatewayService) filterOpenAIAccountsBySchedulingThreshold(ctx context.Context, accounts []Account) []Account { if len(accounts) == 0 { return accounts } filtered := make([]Account, 0, len(accounts)) for i := range accounts { if s.isOpenAIAccountBlockedBySchedulingThreshold(ctx, &accounts[i]) { continue } filtered = append(filtered, accounts[i]) } return filtered } func (s *OpenAIGatewayService) isOpenAIAccountBlockedBySchedulingThreshold(ctx context.Context, account *Account) bool { if s == nil || s.rateLimitService == nil || account == nil { return false } return s.rateLimitService.ApplyAccountSchedulingThreshold(ctx, account) } func (s *OpenAIGatewayService) hydrateSelectedAccount(ctx context.Context, account *Account) (*Account, error) { if account == nil || s.schedulerSnapshot == nil { return account, nil } hydrated, err := s.schedulerSnapshot.GetAccount(ctx, account.ID) if err != nil { return nil, err } if hydrated == nil { return nil, fmt.Errorf("selected openai account %d not found during hydration", account.ID) } return hydrated, nil } func (s *OpenAIGatewayService) newSelectionResult(ctx context.Context, account *Account, acquired bool, release func(), waitPlan *AccountWaitPlan) (*AccountSelectionResult, error) { hydrated, err := s.hydrateSelectedAccount(ctx, account) if err != nil { return nil, err } return attachSelectionProfitGate(ctx, &AccountSelectionResult{ Account: hydrated, Acquired: acquired, ReleaseFunc: release, WaitPlan: waitPlan, }), nil } func (s *OpenAIGatewayService) newAcquiredSelectionResult(ctx context.Context, account *Account, release func()) (*AccountSelectionResult, error) { selection, err := s.newSelectionResult(ctx, account, true, release, nil) if err != nil && release != nil { release() } return selection, err } func (s *OpenAIGatewayService) schedulingConfig() config.GatewaySchedulingConfig { if s.cfg != nil { return s.cfg.Gateway.Scheduling } return config.GatewaySchedulingConfig{ StickySessionMaxWaiting: 3, StickySessionWaitTimeout: 45 * time.Second, FallbackWaitTimeout: 30 * time.Second, FallbackMaxWaiting: 100, LoadBatchEnabled: true, SlotCleanupInterval: 30 * time.Second, } }