| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753 |
- 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",
- "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(openAIRequestPayloadView(body).Get("prompt_cache_key").String())
- }
- return sessionID
- }
- // openAIRequestPayloadView unwraps Responses WebSocket event envelopes while
- // leaving ordinary HTTP objects untouched even when they contain a response
- // field for another purpose.
- func openAIRequestPayloadView(body []byte) gjson.Result {
- root := parseRawJSONView(body)
- eventType := strings.ToLower(strings.TrimSpace(root.Get("type").String()))
- if strings.HasPrefix(eventType, "response.") {
- if response := root.Get("response"); response.Exists() && response.IsObject() {
- return response
- }
- }
- return root
- }
- // 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(openAIRequestPayloadView(body).Get("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 / 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) {
- ctx = s.withOpenAIQuotaAutoPauseContext(ctx)
- account, err := s.selectAccountForModelWithExclusions(ctx, groupID, PlatformOpenAI, sessionHash, requestedModel, excludedIDs, false, 0, "", false)
- if err != nil {
- return nil, err
- }
- return s.hydrateSelectedAccount(ctx, account)
- }
- // SelectAccountForTokenCount selects an account for a non-billable token-count
- // request. It applies the normal platform, model, capability, and runtime
- // eligibility checks without acquiring or waiting for a generation slot.
- func (s *OpenAIGatewayService) SelectAccountForTokenCount(
- ctx context.Context,
- groupID *int64,
- sessionHash string,
- requestedModel string,
- requiredCapability OpenAIEndpointCapability,
- platform string,
- ) (*Account, error) {
- ctx = WithOpenAIProfitControlSuppressed(ctx)
- ctx = s.withOpenAIQuotaAutoPauseContext(ctx)
- account, err := s.selectAccountForModelWithExclusions(
- ctx,
- groupID,
- platform,
- sessionHash,
- requestedModel,
- nil,
- false,
- 0,
- requiredCapability,
- false,
- )
- if err != nil {
- return nil, err
- }
- return s.hydrateSelectedAccount(ctx, account)
- }
- // 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, PlatformMiniMax:
- 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 {
- return openAICompatibleAccountEligibilityFailureReason(ctx, account, platform, requestedModel, requireCompact, requiredCapability) == ""
- }
- // openAICompatibleAccountEligibilityFailureReason mirrors the legacy boolean
- // eligibility check while naming its first veto point. Load-batch selection uses
- // the reason only for server-side no-account diagnostics; the admission behavior
- // remains unchanged.
- func openAICompatibleAccountEligibilityFailureReason(ctx context.Context, account *Account, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) string {
- if reason := openAICompatibleAccountEligibilityFailureReasonBeforeProfit(ctx, account, platform, requestedModel, requireCompact, requiredCapability); reason != "" {
- return reason
- }
- // 分组利润控制:legacy 引擎的粘性/候选循环与 DB recheck 共用
- // 本判定,任何 fallback 都不能把利润不合格账号重新放回候选。
- if vetoed, reason := openAIProfitControlVetoReason(ctx, account); vetoed {
- return reason
- }
- return ""
- }
- // 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 {
- return openAICompatibleAccountEligibilityFailureReasonBeforeProfit(ctx, account, platform, requestedModel, requireCompact, requiredCapability) == ""
- }
- func openAICompatibleAccountEligibilityFailureReasonBeforeProfit(ctx context.Context, account *Account, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) string {
- platform = NormalizeOpenAICompatiblePlatform(platform)
- if account == nil {
- return "account_nil"
- }
- if account.Platform != platform || !account.IsOpenAICompatible() {
- return "platform_mismatch"
- }
- if !account.IsSchedulableForModelWithContext(ctx, requestedModel) {
- if account.IsSchedulable() {
- return "model_rate_limited"
- }
- return "not_schedulable"
- }
- 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,
- )
- if reason.window != "" {
- return "quota_auto_pause_" + reason.window
- }
- return "quota_auto_pause"
- }
- }
- 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,
- )
- if reason.window != "" {
- return "quota_auto_pause_" + reason.window
- }
- return "quota_auto_pause"
- }
- }
- if requestedModel != "" && !account.IsModelSupported(requestedModel) {
- return "model_not_supported"
- }
- 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 "capability_mismatch"
- }
- if requireCompact && openAICompactSupportTier(account) == 0 {
- return "compact_unsupported"
- }
- return ""
- }
- type openAIQuotaAutoPauseDecision struct {
- window string
- threshold float64
- utilization float64
- reason string
- }
- 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{}
- }
- // 自动用卡有独立阈值:达到消费阈值时必须先退出调度;仅达到普通暂停阈值时,
- // 只有新鲜状态明确存在可用卡才继续放行到消费阈值。
- if config := ResolveOpenAIAutoResetCreditConfig(account); config.Enabled {
- now := time.Now()
- utilization5h, has5h := resolveOpenAIQuotaUtilization(account.Extra, "5h", now)
- utilization7d, has7d := resolveOpenAIQuotaUtilization(account.Extra, "7d", now)
- if has5h && utilization5h >= config.Threshold5h {
- notifyOpenAIAutoReset(account.ID)
- return true, openAIQuotaAutoPauseDecision{window: "5h", threshold: config.Threshold5h, utilization: utilization5h, reason: "quota_auto_reset_pending_5h"}
- }
- if has7d && utilization7d >= config.Threshold7d {
- notifyOpenAIAutoReset(account.ID)
- return true, openAIQuotaAutoPauseDecision{window: "7d", threshold: config.Threshold7d, utilization: utilization7d, reason: "quota_auto_reset_pending_7d"}
- }
- disabled5h := resolveAccountExtraBool(account.Extra, "auto_pause_5h_disabled")
- disabled7d := resolveAccountExtraBool(account.Extra, "auto_pause_7d_disabled")
- pause5h, pause7d := resolveOpenAIQuotaAutoPauseThresholds(ctx, account)
- pauseReached5h := !disabled5h && pause5h > 0 && has5h && utilization5h >= pause5h
- pauseReached7d := !disabled7d && pause7d > 0 && has7d && utilization7d >= pause7d
- if pauseReached5h || pauseReached7d {
- state := openAIAutoResetStateFromExtra(account.Extra)
- if state != nil && state.Status == OpenAIAutoResetStatusAvailable && state.AvailableCount > 0 && !openAIAutoResetStateStale(state, now) {
- return false, openAIQuotaAutoPauseDecision{}
- }
- notifyOpenAIAutoReset(account.ID)
- if pauseReached5h {
- return true, openAIQuotaAutoPauseDecision{window: "5h", threshold: pause5h, utilization: utilization5h, reason: "quota_auto_reset_credit_check_5h"}
- }
- return true, openAIQuotaAutoPauseDecision{window: "7d", threshold: pause7d, utilization: utilization7d, reason: "quota_auto_reset_credit_check_7d"}
- }
- }
- // 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_<window>_reset_at
- // timestamp and falls back to codex_<window>_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
- }
- // Compact mappings are keyed by the client-visible model. Prefer an exact
- // compact rule before ordinary account mapping; otherwise a normal alias can
- // hide the compact-specific rule and make scheduling disagree with Forward.
- if requireCompact && account != nil {
- if compactModel, matched := account.ResolveCompactMappedModel(strings.TrimSpace(requestedModel)); matched {
- if compactModel = strings.TrimSpace(compactModel); compactModel != "" {
- return compactModel
- }
- }
- }
- upstreamModel := resolveOpenAIForwardModel(account, requestedModel, "")
- if upstreamModel == "" {
- return ""
- }
- if requireCompact {
- compactModel := resolveOpenAICompactForwardModel(account, upstreamModel)
- if compactModel != upstreamModel {
- return compactModel
- }
- }
- return normalizeOpenAIModelForUpstream(account, upstreamModel)
- }
- // ResolveOpenAIAccountUpstreamModelForRequest exposes the scheduler's exact
- // account mapping chain to handler-side outcome reporting.
- func ResolveOpenAIAccountUpstreamModelForRequest(account *Account, requestedModel string, requireCompact bool) string {
- return resolveOpenAIAccountUpstreamModelForRequest(account, requestedModel, requireCompact)
- }
- // resolveOpenAIForwardMappedModels is the shared account mapping chain for
- // Forward callers. billingModel retains the ordinary mapping used for usage
- // accounting, while upstreamModel is the model the scheduler has admitted.
- func resolveOpenAIForwardMappedModels(account *Account, requestedModel string, requireCompact bool) (billingModel, upstreamModel string) {
- requestedModel = strings.TrimSpace(requestedModel)
- if account != nil && account.IsOpenAIPassthroughEnabled() {
- billingModel = requestedModel
- } else if account != nil {
- billingModel = strings.TrimSpace(account.GetMappedModel(requestedModel))
- }
- if billingModel == "" {
- billingModel = requestedModel
- }
- upstreamModel = resolveOpenAIAccountUpstreamModelForRequest(account, requestedModel, requireCompact)
- if strings.TrimSpace(upstreamModel) == "" {
- upstreamModel = billingModel
- }
- return billingModel, upstreamModel
- }
- func resolveOpenAIErrorSchedulingModel(billingModel, upstreamModel string) string {
- if upstreamModel = strings.TrimSpace(upstreamModel); upstreamModel != "" {
- return upstreamModel
- }
- return strings.TrimSpace(billingModel)
- }
- 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(""))
- }
- // 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 selected, 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)
- ctx = s.withOpenAIGroupPrivacyRequirement(ctx, groupID)
- // 分组利润控制: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.Mode1EffectiveConcurrency())
- 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.Mode1EffectiveConcurrency(),
- Timeout: cfg.StickySessionWaitTimeout,
- MaxWaiting: cfg.StickySessionMaxWaiting,
- })
- }
- }
- return s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{
- AccountID: account.ID,
- MaxConcurrency: account.Mode1EffectiveConcurrency(),
- 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, noAvailableOpenAISelectionError(requestedModel, false, openAISelectionFilterStats{}.summary(""))
- }
- isExcluded := func(accountID int64) bool {
- if excludedIDs == nil {
- return false
- }
- _, excluded := excludedIDs[accountID]
- return excluded
- }
- // ============ Layer 1: Sticky session ============
- // A healthy sticky account whose bounded wait queue is full may be used as a
- // one-request capacity spillover in Layer 2. Keep that spillover temporary:
- // rewriting the durable binding here would make a short burst migrate the
- // whole conversation to a cache-cold account.
- stickySpillover := false
- 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.Mode1EffectiveConcurrency())
- 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.Mode1EffectiveConcurrency(),
- Timeout: cfg.StickySessionWaitTimeout,
- MaxWaiting: cfg.StickySessionMaxWaiting,
- })
- }
- stickySpillover = true
- }
- }
- }
- }
- }
- // ============ 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
- filterStats := openAISelectionFilterStats{pool: len(accounts)}
- candidates := make([]*Account, 0, len(accounts))
- for i := range accounts {
- acc := &accounts[i]
- if isExcluded(acc.ID) {
- filterStats.exclude("excluded")
- 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 reason := openAICompatibleAccountEligibilityFailureReason(ctx, acc, platform, requestedModel, false, requiredCapability); reason != "" {
- filterStats.exclude(reason)
- continue
- }
- if !parentHealthyForShadow(acc, parentLookupL2) {
- filterStats.exclude("shadow_parent_unhealthy")
- continue
- }
- if s.isOpenAIAccountRequestRuntimeBlocked(acc, requestedModel) {
- filterStats.exclude("runtime_blocked")
- continue
- }
- if needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, acc, requestedModel, requireCompact) {
- filterStats.exclude("channel_upstream_restricted")
- continue
- }
- baseCandidateCount++
- candidates = append(candidates, acc)
- }
- if len(candidates) == 0 {
- return nil, noAvailableOpenAISelectionError(requestedModel, false, filterStats.summary(""))
- }
- 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.Mode1EffectiveConcurrency())
- if err == nil && result != nil && result.Acquired {
- selection, selectErr := s.newAcquiredSelectionResult(ctx, fresh, result.ReleaseFunc)
- if selectErr != nil {
- return nil, true, selectErr
- }
- if sessionHash != "" && !stickySpillover && !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.Mode1EffectiveConcurrency())
- if err == nil && result != nil && result.Acquired {
- selection, selectErr := s.newAcquiredSelectionResult(ctx, fresh, result.ReleaseFunc)
- if selectErr != nil {
- return nil, selectErr
- }
- if sessionHash != "" && !stickySpillover && !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.Mode1EffectiveConcurrency(),
- 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 FilterTierPoolAccounts(accounts, TierPoolFromContext(ctx)), 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 FilterTierPoolAccounts(accounts, TierPoolFromContext(ctx)), 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 s.openAIGroupRequiresPrivacySet(ctx, groupID) && !account.IsPrivacySet() {
- return 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 s.openAIGroupRequiresPrivacySet(ctx, groupID) && !latest.IsPrivacySet() {
- 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 {
- return nil, nil
- }
- hydrated := account
- if s.schedulerSnapshot != nil {
- var err error
- 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)
- }
- }
- if err := ResolveRandomProxyFromSource(ctx, hydrated, s.accountRepo); err != nil {
- return nil, err
- }
- 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,
- }
- }
|