| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273 |
- // Package service provides business logic and domain services for the application.
- package service
- import (
- "context"
- "encoding/json"
- "errors"
- "hash/fnv"
- "log/slog"
- "net/url"
- "reflect"
- "sort"
- "strconv"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/domain"
- "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
- "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
- "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
- )
- type Account struct {
- ID int64
- Name string
- Notes *string
- Platform string
- Type string
- Credentials map[string]any
- Extra map[string]any
- ProxyID *int64
- ProxyFallbackOriginID *int64
- ProxyFallbackOriginName *string // 仅展示用
- Concurrency int
- Priority int
- // RateMultiplier 账号计费倍率(>=0,允许 0 表示该账号计费为 0)。
- // 使用指针用于兼容旧版本调度缓存(Redis)中缺字段的情况:nil 表示按 1.0 处理。
- RateMultiplier *float64
- LoadFactor *int // 调度负载因子;nil 表示使用 Concurrency
- Status string
- ErrorMessage string
- LastUsedAt *time.Time
- ExpiresAt *time.Time
- AutoPauseOnExpired bool
- CreatedAt time.Time
- UpdatedAt time.Time
- Schedulable bool
- RateLimitedAt *time.Time
- RateLimitResetAt *time.Time
- OverloadUntil *time.Time
- TempUnschedulableUntil *time.Time
- TempUnschedulableReason string
- SessionWindowStart *time.Time
- SessionWindowEnd *time.Time
- SessionWindowStatus string
- ParentAccountID *int64 // non-nil → 影子账号(不持凭据,透传母账号凭据)
- QuotaDimension string // 用量维度:"" / "global" / "spark"
- Proxy *Proxy
- AccountGroups []AccountGroup
- GroupIDs []int64
- Groups []*Group
- // model_mapping 热路径缓存(非持久化字段)
- modelMappingCache map[string]string
- modelMappingCacheReady bool
- modelMappingCacheCredentialsPtr uintptr
- modelMappingCacheRawPtr uintptr
- modelMappingCacheRawLen int
- modelMappingCacheRawSig uint64
- modelMappingCacheRuntimeVersion uint64
- // header_overrides 热路径缓存(非持久化字段,同 model_mapping 缓存先例)
- headerOverrideCache map[string]string
- headerOverrideCacheReady bool
- headerOverrideCacheCredentialsPtr uintptr
- headerOverrideCacheRawPtr uintptr
- headerOverrideCacheRawLen int
- headerOverrideCacheRawSig uint64
- }
- // ProxyModeExtraKey stores the account proxy selection mode in the existing
- // JSONB extra column. Keeping this in extra preserves compatibility with
- // existing installations without requiring an account table migration.
- const ProxyModeExtraKey = "proxy_mode"
- const ProxyModeRandom = "random"
- // NormalizeProxyModeExtra enforces the proxy_mode whitelist on an account
- // extra map: "random" (case/whitespace-insensitive) is canonicalized to
- // ProxyModeRandom, any other value is dropped so it can never persist.
- // A nil map is returned as-is. The same whitelist is enforced at the DB
- // layer by chk_accounts_extra_proxy_mode; this keeps the write path and the
- // constraint from drifting apart.
- func NormalizeProxyModeExtra(extra map[string]any) map[string]any {
- if extra == nil {
- return nil
- }
- raw, ok := extra[ProxyModeExtraKey]
- if !ok {
- return extra
- }
- if mode, ok := raw.(string); ok && strings.EqualFold(strings.TrimSpace(mode), ProxyModeRandom) {
- extra[ProxyModeExtraKey] = ProxyModeRandom
- return extra
- }
- delete(extra, ProxyModeExtraKey)
- return extra
- }
- // IsRandomProxy reports whether the account should use a randomly selected
- // active proxy for each newly selected request. The value is deliberately
- // strict: malformed user data never enables an unexpected mode.
- func (a *Account) IsRandomProxy() bool {
- if a == nil || a.Extra == nil {
- return false
- }
- mode, ok := a.Extra[ProxyModeExtraKey].(string)
- return ok && strings.EqualFold(strings.TrimSpace(mode), ProxyModeRandom)
- }
- // RandomProxySelector is implemented by repositories that can choose one
- // currently active, non-expired proxy. It is optional so narrow unit-test
- // repositories remain source-compatible.
- type RandomProxySelector interface {
- SelectRandomActiveProxy(ctx context.Context) (*Proxy, error)
- }
- type OpenAIEndpointCapability string
- const openAILongContextBillingEnabledKey = "openai_long_context_billing_enabled"
- const (
- OpenAIEndpointCapabilityChatCompletions OpenAIEndpointCapability = "chat_completions"
- OpenAIEndpointCapabilityEmbeddings OpenAIEndpointCapability = "embeddings"
- OpenAIEndpointCapabilityAlphaSearch OpenAIEndpointCapability = "alpha_search"
- OpenAIEndpointCapabilityLive OpenAIEndpointCapability = "live"
- // OpenAIEndpointCapabilityGrokMediaGeneration keeps image/video generation
- // away from Grok accounts that are explicitly disabled or whose billing
- // entitlement probe was forbidden. Video status lookups intentionally do not
- // require this capability so already-submitted requests remain queryable.
- OpenAIEndpointCapabilityGrokMediaGeneration OpenAIEndpointCapability = "grok_media_generation"
- // OpenAIEndpointCapabilityResponses 表示上游确实提供 /v1/responses 端点。
- // 与其他能力不同:支持状态来自 accounts.extra 的自动探测标记
- // (openai_responses_supported / openai_responses_mode),而非
- // credentials["openai_capabilities"] 配置集。仅用于生图意图的 /v1/responses
- // 调度,避免把请求调度到会在 forward 阶段被降级为 Chat Completions 的账号(#4417)。
- OpenAIEndpointCapabilityResponses OpenAIEndpointCapability = "responses"
- )
- const openAIEndpointCapabilitiesCredentialKey = "openai_capabilities"
- // GrokMediaEligibleExtraKey is an optional per-account override stored in
- // accounts.extra. true forces media routing on, false disables it, and an
- // absent/null value uses provider observations.
- const GrokMediaEligibleExtraKey = "grok_media_eligible"
- const (
- OpenAIAuthModePersonalAccessToken = "personalAccessToken"
- openAIAuthModeCredentialKey = "auth_mode"
- openAIAuthModeLegacyCredentialKey = "openai_auth_mode"
- )
- func isOpenAIPersonalAccessTokenAuthMode(value string) bool {
- switch strings.ToLower(strings.TrimSpace(value)) {
- case "personalaccesstoken", "personal_access_token":
- return true
- default:
- return false
- }
- }
- type TempUnschedulableRule struct {
- ErrorCode int `json:"error_code"`
- Keywords []string `json:"keywords"`
- DurationMinutes int `json:"duration_minutes"`
- Description string `json:"description"`
- }
- func (a *Account) IsActive() bool {
- return a.Status == StatusActive
- }
- // IsSyntheticUITest reports whether the account belongs to an isolated UI load-test
- // dataset. Production accounts never receive this marker. It lets the dedicated
- // test instance exercise interactive quota and connection-test controls without
- // sending fake credentials to an upstream provider.
- func (a *Account) IsSyntheticUITest() bool {
- if a == nil || a.Extra == nil {
- return false
- }
- enabled, ok := a.Extra["synthetic_ui_test"].(bool)
- return ok && enabled
- }
- // BillingRateMultiplier 返回账号计费倍率。
- // - nil 表示未配置/旧缓存缺字段,按 1.0 处理
- // - 允许 0,表示该账号计费为 0
- // - 负数属于非法数据,出于安全考虑按 1.0 处理
- func (a *Account) BillingRateMultiplier() float64 {
- if a == nil || a.RateMultiplier == nil {
- return 1.0
- }
- if *a.RateMultiplier < 0 {
- return 1.0
- }
- return *a.RateMultiplier
- }
- func (a *Account) EffectiveLoadFactor() int {
- if a == nil {
- return 1
- }
- if a.LoadFactor != nil && *a.LoadFactor > 0 {
- return *a.LoadFactor
- }
- if a.Concurrency > 0 {
- return a.Concurrency
- }
- return 1
- }
- func (a *Account) IsSchedulable() bool {
- if !a.IsActive() || !a.Schedulable {
- return false
- }
- now := time.Now()
- if a.AutoPauseOnExpired && a.ExpiresAt != nil && !now.Before(*a.ExpiresAt) {
- return false
- }
- if a.OverloadUntil != nil && now.Before(*a.OverloadUntil) {
- return false
- }
- if a.RateLimitResetAt != nil && now.Before(*a.RateLimitResetAt) {
- return false
- }
- if a.TempUnschedulableUntil != nil && now.Before(*a.TempUnschedulableUntil) {
- return false
- }
- if a.IsAPIKeyOrBedrock() && a.IsQuotaExceeded() {
- return false
- }
- return true
- }
- // IsCredentialUsableForShadow 报告本账号(作为某 spark 影子的母账号)的凭据/传输是否可被影子透传使用。
- //
- // 检查「凭据/账号/传输可用性」:
- // - 账号 active(非禁用/删除);
- // - OAuth token 未过期(AutoPauseOnExpired+ExpiresAt);
- // - 未处于 TempUnschedulableUntil 冷却期 —— 对 OpenAI 账号该字段由 401 鉴权失败 /
- // token 刷新耗尽 / transport·proxy 故障写入(ratelimit/token_refresh/upstream_transport),
- // 都代表**共享凭据或传输通道坏死**;影子共享母 token+proxy,故母处于该冷却期时影子也不可用。
- //
- // **刻意排除** global 维度的限流/过载窗口(RateLimitResetAt / OverloadUntil)与母账号自身的
- // 手动 Schedulable 开关:spark 影子拥有独立 spark 配额窗口,母账号 global 429(走 RateLimitResetAt)
- // 不应连坐 spark(否则重新耦合影子架构本应解耦的两条 429 道)。nil receiver 返回 false。
- func (a *Account) IsCredentialUsableForShadow() bool {
- if a == nil || !a.IsActive() {
- return false
- }
- now := time.Now()
- if a.AutoPauseOnExpired && a.ExpiresAt != nil && !now.Before(*a.ExpiresAt) {
- return false
- }
- if a.TempUnschedulableUntil != nil && now.Before(*a.TempUnschedulableUntil) {
- return false
- }
- return true
- }
- func (a *Account) IsRateLimited() bool {
- if a.RateLimitResetAt == nil {
- return false
- }
- return time.Now().Before(*a.RateLimitResetAt)
- }
- func (a *Account) IsOverloaded() bool {
- if a.OverloadUntil == nil {
- return false
- }
- return time.Now().Before(*a.OverloadUntil)
- }
- func (a *Account) IsOAuth() bool {
- return a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken
- }
- // IsPrivacySet 检查账号的 privacy 是否已成功设置。
- // OpenAI: privacy_mode == "training_off"
- // Antigravity: privacy_mode == "privacy_set"
- // 其他平台: 无 privacy 概念,始终返回 true
- func (a *Account) IsPrivacySet() bool {
- switch a.Platform {
- case PlatformOpenAI:
- return a.getExtraString("privacy_mode") == PrivacyModeTrainingOff
- case PlatformAntigravity:
- return a.getExtraString("privacy_mode") == AntigravityPrivacySet
- default:
- return true
- }
- }
- func (a *Account) IsGemini() bool {
- return a.Platform == PlatformGemini
- }
- func (a *Account) IsGrok() bool {
- return a.Platform == PlatformGrok
- }
- func (a *Account) IsGrokOAuth() bool {
- return a.IsGrok() && a.Type == AccountTypeOAuth
- }
- // IsKimi / IsZhipu / IsDeepseek 标识国产 OpenAI 兼容供应商账号。
- func (a *Account) IsKimi() bool {
- return a.Platform == PlatformKimi
- }
- func (a *Account) IsZhipu() bool {
- return a.Platform == PlatformZhipu
- }
- func (a *Account) IsDeepseek() bool {
- return a.Platform == PlatformDeepseek
- }
- // IsCNProvider 报告是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)。
- func (a *Account) IsCNProvider() bool {
- return a != nil && IsCNProvider(a.Platform)
- }
- // IsOpenAICompatible 报告账号是否走 OpenAI 网关(OpenAI 协议族)。
- // openai/grok 原生走 OpenAI 网关;kimi/zhipu/deepseek 同为 OpenAI Chat Completions
- // 兼容上游,也经 OpenAI 网关转发。
- func (a *Account) IsOpenAICompatible() bool {
- return a != nil && (a.Platform == PlatformOpenAI || a.Platform == PlatformGrok || a.IsCNProvider())
- }
- func (a *Account) GeminiOAuthType() string {
- if a.Platform != PlatformGemini || a.Type != AccountTypeOAuth {
- return ""
- }
- oauthType := strings.TrimSpace(a.GetCredential("oauth_type"))
- if oauthType == "" && strings.TrimSpace(a.GetCredential("project_id")) != "" {
- return "code_assist"
- }
- return oauthType
- }
- func (a *Account) GeminiTierID() string {
- tierID := strings.TrimSpace(a.GetCredential("tier_id"))
- return tierID
- }
- func (a *Account) IsGeminiCodeAssist() bool {
- if a.Platform != PlatformGemini || a.Type != AccountTypeOAuth {
- return false
- }
- oauthType := a.GeminiOAuthType()
- if oauthType == "" {
- return strings.TrimSpace(a.GetCredential("project_id")) != ""
- }
- return oauthType == "code_assist"
- }
- // IsGeminiGoogleOne reports whether this account uses the legacy consumer
- // Gemini CLI / Code Assist OAuth channel.
- func (a *Account) IsGeminiGoogleOne() bool {
- return a.Platform == PlatformGemini && a.Type == AccountTypeOAuth && a.GeminiOAuthType() == "google_one"
- }
- func (a *Account) CanGetUsage() bool {
- return a.Type == AccountTypeOAuth
- }
- func (a *Account) GetCredential(key string) string {
- if a.Credentials == nil {
- return ""
- }
- v, ok := a.Credentials[key]
- if !ok || v == nil {
- return ""
- }
- // 支持多种类型(兼容历史数据中 expires_at 等字段可能是数字或字符串)
- switch val := v.(type) {
- case string:
- return val
- case json.Number:
- // GORM datatypes.JSONMap 使用 UseNumber() 解析,数字类型为 json.Number
- return val.String()
- case float64:
- // JSON 解析后数字默认为 float64
- return strconv.FormatInt(int64(val), 10)
- case int64:
- return strconv.FormatInt(val, 10)
- case int:
- return strconv.Itoa(val)
- default:
- return ""
- }
- }
- // GetCredentialAsTime 解析凭证中的时间戳字段,支持多种格式
- // 兼容以下格式:
- // - RFC3339 字符串: "2025-01-01T00:00:00Z"
- // - Unix 时间戳字符串: "1735689600"
- // - Unix 时间戳数字: 1735689600 (float64/int64/json.Number)
- func (a *Account) GetCredentialAsTime(key string) *time.Time {
- s := a.GetCredential(key)
- if s == "" {
- return nil
- }
- // 尝试 RFC3339 格式
- if t, err := time.Parse(time.RFC3339, s); err == nil {
- return &t
- }
- // 尝试 Unix 时间戳(纯数字字符串)
- if ts, err := strconv.ParseInt(s, 10, 64); err == nil {
- t := time.Unix(ts, 0)
- return &t
- }
- return nil
- }
- // GetCredentialAsInt64 解析凭证中的 int64 字段
- // 用于读取 _token_version 等内部字段
- func (a *Account) GetCredentialAsInt64(key string) int64 {
- if a == nil || a.Credentials == nil {
- return 0
- }
- val, ok := a.Credentials[key]
- if !ok || val == nil {
- return 0
- }
- switch v := val.(type) {
- case int64:
- return v
- case float64:
- return int64(v)
- case int:
- return int64(v)
- case json.Number:
- if i, err := v.Int64(); err == nil {
- return i
- }
- case string:
- if i, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil {
- return i
- }
- }
- return 0
- }
- func (a *Account) IsTempUnschedulableEnabled() bool {
- if a.Credentials == nil {
- return false
- }
- raw, ok := a.Credentials["temp_unschedulable_enabled"]
- if !ok || raw == nil {
- return false
- }
- enabled, ok := raw.(bool)
- return ok && enabled
- }
- func (a *Account) GetTempUnschedulableRules() []TempUnschedulableRule {
- if a.Credentials == nil {
- return nil
- }
- raw, ok := a.Credentials["temp_unschedulable_rules"]
- if !ok || raw == nil {
- return nil
- }
- arr, ok := raw.([]any)
- if !ok {
- return nil
- }
- rules := make([]TempUnschedulableRule, 0, len(arr))
- for _, item := range arr {
- entry, ok := item.(map[string]any)
- if !ok || entry == nil {
- continue
- }
- rule := TempUnschedulableRule{
- ErrorCode: parseTempUnschedInt(entry["error_code"]),
- Keywords: parseTempUnschedStrings(entry["keywords"]),
- DurationMinutes: parseTempUnschedInt(entry["duration_minutes"]),
- Description: parseTempUnschedString(entry["description"]),
- }
- if rule.ErrorCode <= 0 || rule.DurationMinutes <= 0 || len(rule.Keywords) == 0 {
- continue
- }
- rules = append(rules, rule)
- }
- return rules
- }
- func parseTempUnschedString(value any) string {
- s, ok := value.(string)
- if !ok {
- return ""
- }
- return strings.TrimSpace(s)
- }
- func parseTempUnschedStrings(value any) []string {
- if value == nil {
- return nil
- }
- var raw []string
- switch v := value.(type) {
- case []string:
- raw = v
- case []any:
- raw = make([]string, 0, len(v))
- for _, item := range v {
- if s, ok := item.(string); ok {
- raw = append(raw, s)
- }
- }
- default:
- return nil
- }
- out := make([]string, 0, len(raw))
- for _, item := range raw {
- s := strings.TrimSpace(item)
- if s != "" {
- out = append(out, s)
- }
- }
- return out
- }
- func normalizeAccountNotes(value *string) *string {
- if value == nil {
- return nil
- }
- trimmed := strings.TrimSpace(*value)
- if trimmed == "" {
- return nil
- }
- return &trimmed
- }
- func parseTempUnschedInt(value any) int {
- switch v := value.(type) {
- case int:
- return v
- case int64:
- return int(v)
- case float64:
- return int(v)
- case json.Number:
- if i, err := v.Int64(); err == nil {
- return int(i)
- }
- case string:
- if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
- return i
- }
- }
- return 0
- }
- const (
- // OpenAICompactModeAuto follows compact-probe results when deciding compact eligibility.
- OpenAICompactModeAuto = "auto"
- // OpenAICompactModeForceOn always treats the account as compact-supported.
- OpenAICompactModeForceOn = "force_on"
- // OpenAICompactModeForceOff always treats the account as compact-unsupported.
- OpenAICompactModeForceOff = "force_off"
- )
- func normalizeOpenAICompactMode(mode string) string {
- switch strings.ToLower(strings.TrimSpace(mode)) {
- case OpenAICompactModeForceOn:
- return OpenAICompactModeForceOn
- case OpenAICompactModeForceOff:
- return OpenAICompactModeForceOff
- default:
- return OpenAICompactModeAuto
- }
- }
- func stringMappingFromRaw(raw any) map[string]string {
- switch mapping := raw.(type) {
- case map[string]any:
- if len(mapping) == 0 {
- return nil
- }
- result := make(map[string]string, len(mapping))
- for key, value := range mapping {
- if str, ok := value.(string); ok {
- result[key] = str
- }
- }
- if len(result) == 0 {
- return nil
- }
- return result
- case map[string]string:
- if len(mapping) == 0 {
- return nil
- }
- result := make(map[string]string, len(mapping))
- for key, value := range mapping {
- result[key] = value
- }
- return result
- default:
- return nil
- }
- }
- func (a *Account) GetModelMapping() map[string]string {
- runtimeVersion := xai.RuntimeModelMappingVersion()
- credentialsPtr := mapPtr(a.Credentials)
- rawMapping, _ := a.Credentials["model_mapping"].(map[string]any)
- rawPtr := mapPtr(rawMapping)
- rawLen := len(rawMapping)
- rawSig := uint64(0)
- rawSigReady := false
- if a.modelMappingCacheReady &&
- a.modelMappingCacheCredentialsPtr == credentialsPtr &&
- a.modelMappingCacheRawPtr == rawPtr &&
- a.modelMappingCacheRawLen == rawLen &&
- a.modelMappingCacheRuntimeVersion == runtimeVersion {
- rawSig = modelMappingSignature(rawMapping)
- rawSigReady = true
- if a.modelMappingCacheRawSig == rawSig {
- return a.modelMappingCache
- }
- }
- mapping := a.resolveModelMapping(rawMapping)
- if !rawSigReady {
- rawSig = modelMappingSignature(rawMapping)
- }
- a.modelMappingCache = mapping
- a.modelMappingCacheReady = true
- a.modelMappingCacheCredentialsPtr = credentialsPtr
- a.modelMappingCacheRawPtr = rawPtr
- a.modelMappingCacheRawLen = rawLen
- a.modelMappingCacheRawSig = rawSig
- a.modelMappingCacheRuntimeVersion = runtimeVersion
- return mapping
- }
- func (a *Account) resolveModelMapping(rawMapping map[string]any) map[string]string {
- if a.Credentials == nil {
- // Antigravity 平台使用默认映射
- if a.Platform == domain.PlatformAntigravity {
- return domain.DefaultAntigravityModelMapping
- }
- if a.Platform == domain.PlatformGrok {
- return xai.DefaultModelMapping()
- }
- // Bedrock 默认映射由 forwardBedrock 统一处理(需配合 region prefix 调整)
- return nil
- }
- if len(rawMapping) == 0 {
- if a.IsGeminiGoogleOne() {
- return geminicli.GoogleOneModelMapping()
- }
- // Antigravity 平台使用默认映射
- if a.Platform == domain.PlatformAntigravity {
- return domain.DefaultAntigravityModelMapping
- }
- if a.Platform == domain.PlatformGrok {
- return xai.DefaultModelMapping()
- }
- return nil
- }
- result := make(map[string]string)
- for k, v := range rawMapping {
- if s, ok := v.(string); ok {
- result[k] = s
- }
- }
- if len(result) > 0 {
- if a.Platform == domain.PlatformAntigravity {
- ensureAntigravityDefaultPassthroughs(result, []string{
- "gemini-3-flash",
- "gemini-3.1-pro-high",
- "gemini-3.1-pro-low",
- "gemini-3.6-flash",
- "gemini-3.6-flash-high",
- "gemini-3.6-flash-low",
- "gemini-3.6-flash-medium",
- "gemini-3.6-flash-tiered",
- })
- applyAntigravityGemini31ProAliases(result)
- }
- return result
- }
- // Antigravity 平台使用默认映射
- if a.IsGeminiGoogleOne() {
- return geminicli.GoogleOneModelMapping()
- }
- if a.Platform == domain.PlatformAntigravity {
- return domain.DefaultAntigravityModelMapping
- }
- if a.Platform == domain.PlatformGrok {
- return xai.DefaultModelMapping()
- }
- return nil
- }
- func mapPtr(m map[string]any) uintptr {
- if m == nil {
- return 0
- }
- return reflect.ValueOf(m).Pointer()
- }
- func modelMappingSignature(rawMapping map[string]any) uint64 {
- if len(rawMapping) == 0 {
- return 0
- }
- keys := make([]string, 0, len(rawMapping))
- for k := range rawMapping {
- keys = append(keys, k)
- }
- sort.Strings(keys)
- h := fnv.New64a()
- for _, k := range keys {
- _, _ = h.Write([]byte(k))
- _, _ = h.Write([]byte{0})
- if v, ok := rawMapping[k].(string); ok {
- _, _ = h.Write([]byte(v))
- } else {
- _, _ = h.Write([]byte{1})
- }
- _, _ = h.Write([]byte{0xff})
- }
- return h.Sum64()
- }
- func ensureAntigravityDefaultPassthrough(mapping map[string]string, model string) {
- if mapping == nil || model == "" {
- return
- }
- if _, exists := mapping[model]; exists {
- return
- }
- for pattern := range mapping {
- if matchWildcard(pattern, model) {
- return
- }
- }
- mapping[model] = model
- }
- func ensureAntigravityDefaultPassthroughs(mapping map[string]string, models []string) {
- for _, model := range models {
- ensureAntigravityDefaultPassthrough(mapping, model)
- }
- }
- func applyAntigravityGemini31ProAliases(mapping map[string]string) {
- target := strings.TrimSpace(mapping[domain.AntigravityGemini31ProAgentModel])
- if target == "" {
- return
- }
- aliases := []struct {
- model string
- legacyTargets map[string]struct{}
- }{
- {
- model: "gemini-3.1-pro",
- legacyTargets: map[string]struct{}{
- "gemini-3.1-pro": {},
- },
- },
- {
- model: "gemini-3.1-pro-high",
- legacyTargets: map[string]struct{}{
- "gemini-3.1-pro-high": {},
- },
- },
- {
- model: "gemini-3.1-pro-preview",
- legacyTargets: map[string]struct{}{
- "gemini-3.1-pro-preview": {},
- "gemini-3.1-pro-high": {},
- },
- },
- }
- for _, alias := range aliases {
- current, exists := mapping[alias.model]
- if exists {
- if _, legacy := alias.legacyTargets[current]; legacy {
- mapping[alias.model] = target
- }
- continue
- }
- if mappingHasWildcardForModel(mapping, alias.model) {
- continue
- }
- mapping[alias.model] = target
- }
- }
- func mappingHasWildcardForModel(mapping map[string]string, model string) bool {
- for pattern := range mapping {
- if matchWildcard(pattern, model) {
- return true
- }
- }
- return false
- }
- func normalizeRequestedModelForLookup(platform, requestedModel string) string {
- trimmed := strings.TrimSpace(requestedModel)
- if trimmed == "" {
- return ""
- }
- if platform != PlatformGemini && platform != PlatformAntigravity {
- return trimmed
- }
- if trimmed == "gemini-3.1-pro-preview-customtools" {
- return "gemini-3.1-pro-preview"
- }
- return trimmed
- }
- func mappingSupportsRequestedModel(mapping map[string]string, requestedModel string) bool {
- if requestedModel == "" {
- return false
- }
- if _, exists := mapping[requestedModel]; exists {
- return true
- }
- for pattern := range mapping {
- if matchWildcard(pattern, requestedModel) {
- return true
- }
- }
- return false
- }
- func resolveRequestedModelInMapping(mapping map[string]string, requestedModel string) (mappedModel string, matched bool) {
- if requestedModel == "" {
- return "", false
- }
- if mappedModel, exists := mapping[requestedModel]; exists {
- return mappedModel, true
- }
- return matchWildcardMappingResult(mapping, requestedModel)
- }
- // IsModelSupported 检查模型是否在 model_mapping 中(支持通配符)
- // 如果未配置 mapping,返回 true(允许所有模型)。
- //
- // 例外:OpenAI OAuth 账号(Codex 上游)的空映射会排除明确属于其他厂商
- // 家族的模型(deepseek-*/glm-* 等)——转发阶段 normalizeOpenAIModelForUpstream
- // 会把未知模型原样透传,Codex 上游对这类模型必然返回不可重试的 400,导致
- // 请求卡死在该账号上、无法 failover 到真正支持该模型的 API Key 账号(#3662)。
- // 未知/自定义别名仍保持允许(兼容渠道级映射),见 isOpenAIOAuthServableModel。
- func (a *Account) IsModelSupported(requestedModel string) bool {
- // 透传模式仅替换认证、模型语义完全交由上游决定,因此放行所有模型。
- // 该短路必须在 model_mapping 判定之前:账号从"白名单模式"切换到透传后,
- // credentials 里常残留旧的非空 model_mapping,若不在此放行,透传账号会被
- // model_mapping 白名单错误排除出候选集,导致 no available accounts / 404(issue #4936)。
- if a.IsOpenAIPassthroughEnabled() {
- return true
- }
- mapping := a.GetModelMapping()
- if len(mapping) == 0 {
- if a.IsOpenAIOAuth() {
- return isOpenAIOAuthServableModel(requestedModel)
- }
- return true // 无映射 = 允许所有
- }
- if mappingSupportsRequestedModel(mapping, requestedModel) {
- return true
- }
- normalized := normalizeRequestedModelForLookup(a.Platform, requestedModel)
- return normalized != requestedModel && mappingSupportsRequestedModel(mapping, normalized)
- }
- // GetMappedModel 获取映射后的模型名(支持通配符,最长优先匹配)
- // 如果未配置 mapping,返回原始模型名
- func (a *Account) GetMappedModel(requestedModel string) string {
- mappedModel, _ := a.ResolveMappedModel(requestedModel)
- return mappedModel
- }
- // ResolveMappedModel 获取映射后的模型名,并返回是否命中了账号级映射。
- // matched=true 表示命中了精确映射或通配符映射,即使映射结果与原模型名相同。
- func (a *Account) ResolveMappedModel(requestedModel string) (mappedModel string, matched bool) {
- mapping := a.GetModelMapping()
- if len(mapping) == 0 {
- return requestedModel, false
- }
- if mappedModel, matched := resolveRequestedModelInMapping(mapping, requestedModel); matched {
- return mappedModel, true
- }
- normalized := normalizeRequestedModelForLookup(a.Platform, requestedModel)
- if normalized != requestedModel {
- if mappedModel, matched := resolveRequestedModelInMapping(mapping, normalized); matched {
- return mappedModel, true
- }
- }
- return requestedModel, false
- }
- // GetOpenAICompactMode returns the compact routing mode for an OpenAI account.
- // Missing or invalid values fall back to "auto".
- func (a *Account) GetOpenAICompactMode() string {
- if a == nil || !a.IsOpenAI() || a.Extra == nil {
- return OpenAICompactModeAuto
- }
- mode, _ := a.Extra["openai_compact_mode"].(string)
- return normalizeOpenAICompactMode(mode)
- }
- // OpenAICompactSupportKnown reports whether compact capability is known for this
- // account and, when known, whether it is supported.
- func (a *Account) OpenAICompactSupportKnown() (supported bool, known bool) {
- if a == nil || !a.IsOpenAI() {
- return false, false
- }
- switch a.GetOpenAICompactMode() {
- case OpenAICompactModeForceOn:
- return true, true
- case OpenAICompactModeForceOff:
- return false, true
- }
- if a.Extra == nil {
- return false, false
- }
- supported, ok := a.Extra["openai_compact_supported"].(bool)
- if !ok {
- return false, false
- }
- return supported, true
- }
- // AllowsOpenAICompact reports whether the account may be considered for compact
- // requests. Unknown capability remains allowed to avoid breaking older accounts
- // before an explicit probe has been run.
- func (a *Account) AllowsOpenAICompact() bool {
- if a == nil || !a.IsOpenAI() {
- return false
- }
- supported, known := a.OpenAICompactSupportKnown()
- if !known {
- return true
- }
- return supported
- }
- // GetCompactModelMapping returns compact-only model remapping configuration.
- // This mapping is intended for /responses/compact only and does not affect
- // normal /responses traffic.
- func (a *Account) GetCompactModelMapping() map[string]string {
- if a == nil || a.Credentials == nil {
- return nil
- }
- return stringMappingFromRaw(a.Credentials["compact_model_mapping"])
- }
- // ResolveCompactMappedModel resolves compact-only model remapping and reports
- // whether a compact-specific mapping rule matched.
- func (a *Account) ResolveCompactMappedModel(requestedModel string) (mappedModel string, matched bool) {
- mapping := a.GetCompactModelMapping()
- if len(mapping) == 0 {
- return requestedModel, false
- }
- if mappedModel, matched := resolveRequestedModelInMapping(mapping, requestedModel); matched {
- return mappedModel, true
- }
- return requestedModel, false
- }
- func (a *Account) GetBaseURL() string {
- if a.Type != AccountTypeAPIKey {
- return ""
- }
- baseURL := a.GetCredential("base_url")
- if baseURL == "" {
- return "https://api.anthropic.com"
- }
- if a.Platform == PlatformAntigravity {
- return strings.TrimRight(baseURL, "/") + "/antigravity"
- }
- return baseURL
- }
- // GetGeminiBaseURL 返回 Gemini 兼容端点的 base URL。
- // Antigravity 平台的 APIKey 账号自动拼接 /antigravity。
- func (a *Account) GetGeminiBaseURL(defaultBaseURL string) string {
- baseURL := strings.TrimSpace(a.GetCredential("base_url"))
- if baseURL == "" {
- return defaultBaseURL
- }
- if a.Platform == PlatformAntigravity && a.Type == AccountTypeAPIKey {
- return strings.TrimRight(baseURL, "/") + "/antigravity"
- }
- return baseURL
- }
- func (a *Account) GetExtraString(key string) string {
- if a.Extra == nil {
- return ""
- }
- if v, ok := a.Extra[key]; ok {
- if s, ok := v.(string); ok {
- return s
- }
- }
- return ""
- }
- func (a *Account) GetClaudeUserID() string {
- if v := strings.TrimSpace(a.GetExtraString("claude_user_id")); v != "" {
- return v
- }
- if v := strings.TrimSpace(a.GetExtraString("anthropic_user_id")); v != "" {
- return v
- }
- if v := strings.TrimSpace(a.GetCredential("claude_user_id")); v != "" {
- return v
- }
- if v := strings.TrimSpace(a.GetCredential("anthropic_user_id")); v != "" {
- return v
- }
- return ""
- }
- // matchAntigravityWildcard 通配符匹配(仅支持末尾 *)
- // 用于 model_mapping 的通配符匹配
- func matchAntigravityWildcard(pattern, str string) bool {
- if strings.HasSuffix(pattern, "*") {
- prefix := pattern[:len(pattern)-1]
- return strings.HasPrefix(str, prefix)
- }
- return pattern == str
- }
- // matchWildcard 通用通配符匹配(仅支持末尾 *)
- // 复用 Antigravity 的通配符逻辑,供其他平台使用
- func matchWildcard(pattern, str string) bool {
- return matchAntigravityWildcard(pattern, str)
- }
- func matchWildcardMappingResult(mapping map[string]string, requestedModel string) (string, bool) {
- // 收集所有匹配的 pattern,按长度降序排序(最长优先)
- type patternMatch struct {
- pattern string
- target string
- }
- var matches []patternMatch
- for pattern, target := range mapping {
- if matchWildcard(pattern, requestedModel) {
- matches = append(matches, patternMatch{pattern, target})
- }
- }
- if len(matches) == 0 {
- return requestedModel, false // 无匹配,返回原始模型名
- }
- // 按 pattern 长度降序排序
- sort.Slice(matches, func(i, j int) bool {
- if len(matches[i].pattern) != len(matches[j].pattern) {
- return len(matches[i].pattern) > len(matches[j].pattern)
- }
- return matches[i].pattern < matches[j].pattern
- })
- return matches[0].target, true
- }
- func (a *Account) IsCustomErrorCodesEnabled() bool {
- if a.Type != AccountTypeAPIKey || a.Credentials == nil {
- return false
- }
- if v, ok := a.Credentials["custom_error_codes_enabled"]; ok {
- if enabled, ok := v.(bool); ok {
- return enabled
- }
- }
- return false
- }
- // IsPoolMode 检查 API Key 账号是否启用池模式。
- // 池模式下,上游错误不标记本地账号状态,而是在同一账号上重试。
- func (a *Account) IsPoolMode() bool {
- if !a.IsAPIKeyOrBedrock() || a.Credentials == nil {
- return false
- }
- if v, ok := a.Credentials["pool_mode"]; ok {
- if enabled, ok := v.(bool); ok {
- return enabled
- }
- }
- return false
- }
- const (
- defaultPoolModeRetryCount = 3
- maxPoolModeRetryCount = 10
- )
- // GetPoolModeRetryCount 返回池模式同账号重试次数。
- // 未配置或配置非法时回退为默认值 3;小于 0 按 0 处理;过大则截断到 10。
- func (a *Account) GetPoolModeRetryCount() int {
- if a == nil || !a.IsPoolMode() || a.Credentials == nil {
- return defaultPoolModeRetryCount
- }
- raw, ok := a.Credentials["pool_mode_retry_count"]
- if !ok || raw == nil {
- return defaultPoolModeRetryCount
- }
- count := parsePoolModeRetryCount(raw)
- if count < 0 {
- return 0
- }
- if count > maxPoolModeRetryCount {
- return maxPoolModeRetryCount
- }
- return count
- }
- func parsePoolModeRetryCount(value any) int {
- switch v := value.(type) {
- case int:
- return v
- case int64:
- return int(v)
- case float64:
- return int(v)
- case json.Number:
- if i, err := v.Int64(); err == nil {
- return int(i)
- }
- case string:
- if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
- return i
- }
- }
- return defaultPoolModeRetryCount
- }
- // defaultPoolModeRetryableStatusCodes 池模式下默认触发同账号重试的状态码。
- // 未在 Account.Credentials 中显式配置 pool_mode_retry_status_codes 时使用。
- var defaultPoolModeRetryableStatusCodes = []int{401, 403, 429}
- // isPoolModeRetryableStatus 池模式下应触发同账号重试的状态码(默认列表)。
- func isPoolModeRetryableStatus(statusCode int) bool {
- for _, c := range defaultPoolModeRetryableStatusCodes {
- if c == statusCode {
- return true
- }
- }
- return false
- }
- // GetPoolModeRetryStatusCodes 返回账号自定义的池模式同账号重试状态码列表。
- //
- // 返回值语义:
- // - nil:未配置 → 调用方应回退到默认值 [401, 403, 429]
- // - 长度为 0 的切片:管理员显式置空 → 关闭按状态码触发的同账号重试
- // - 非空切片:去重、过滤为合法 HTTP 状态码(100-599)后的覆盖列表
- func (a *Account) GetPoolModeRetryStatusCodes() []int {
- if a == nil || a.Credentials == nil {
- return nil
- }
- raw, ok := a.Credentials["pool_mode_retry_status_codes"]
- if !ok || raw == nil {
- return nil
- }
- arr, ok := raw.([]any)
- if !ok {
- return nil
- }
- seen := make(map[int]struct{}, len(arr))
- codes := make([]int, 0, len(arr))
- for _, v := range arr {
- var code int
- switch n := v.(type) {
- case float64:
- code = int(n)
- case int:
- code = n
- case int64:
- code = int(n)
- case json.Number:
- i, err := n.Int64()
- if err != nil {
- continue
- }
- code = int(i)
- case string:
- i, err := strconv.Atoi(strings.TrimSpace(n))
- if err != nil {
- continue
- }
- code = i
- default:
- continue
- }
- if code < 100 || code > 599 {
- continue
- }
- if _, exists := seen[code]; exists {
- continue
- }
- seen[code] = struct{}{}
- codes = append(codes, code)
- }
- sort.Ints(codes)
- return codes
- }
- // IsPoolModeRetryableStatus 在账号上下文中判断给定状态码是否应触发同账号重试。
- // 若账号未配置 pool_mode_retry_status_codes,则回退到默认列表。
- func (a *Account) IsPoolModeRetryableStatus(statusCode int) bool {
- codes := a.GetPoolModeRetryStatusCodes()
- if codes == nil {
- return isPoolModeRetryableStatus(statusCode)
- }
- for _, c := range codes {
- if c == statusCode {
- return true
- }
- }
- return false
- }
- func (a *Account) GetCustomErrorCodes() []int {
- if a.Credentials == nil {
- return nil
- }
- raw, ok := a.Credentials["custom_error_codes"]
- if !ok || raw == nil {
- return nil
- }
- if arr, ok := raw.([]any); ok {
- result := make([]int, 0, len(arr))
- for _, v := range arr {
- if f, ok := v.(float64); ok {
- result = append(result, int(f))
- }
- }
- return result
- }
- return nil
- }
- func (a *Account) ShouldHandleErrorCode(statusCode int) bool {
- if !a.IsCustomErrorCodesEnabled() {
- return true
- }
- codes := a.GetCustomErrorCodes()
- if len(codes) == 0 {
- return true
- }
- for _, code := range codes {
- if code == statusCode {
- return true
- }
- }
- return false
- }
- func (a *Account) IsInterceptWarmupEnabled() bool {
- if a.Credentials == nil {
- return false
- }
- if v, ok := a.Credentials["intercept_warmup_requests"]; ok {
- if enabled, ok := v.(bool); ok {
- return enabled
- }
- }
- return false
- }
- func (a *Account) IsBedrock() bool {
- return a.Platform == PlatformAnthropic && a.Type == AccountTypeBedrock
- }
- func (a *Account) IsBedrockAPIKey() bool {
- return a.IsBedrock() && a.GetCredential("auth_mode") == "apikey"
- }
- // IsAPIKeyOrBedrock 返回账号类型是否支持配额和池模式等特性
- func (a *Account) IsAPIKeyOrBedrock() bool {
- return a.Type == AccountTypeAPIKey || a.Type == AccountTypeBedrock
- }
- func (a *Account) IsOpenAI() bool {
- return a.Platform == PlatformOpenAI
- }
- func (a *Account) IsOpenAILongContextBillingEnabled() bool {
- if a == nil || !a.IsOpenAI() || a.Extra == nil {
- return false
- }
- enabled, ok := a.Extra[openAILongContextBillingEnabledKey].(bool)
- return ok && enabled
- }
- func (a *Account) IsAnthropic() bool {
- return a.Platform == PlatformAnthropic
- }
- func (a *Account) IsOpenAIOAuth() bool {
- return a.IsOpenAI() && a.Type == AccountTypeOAuth
- }
- // IsOpenAIOAuthLike reports OpenAI credentials that use the ChatGPT/Codex
- // inference protocol. Setup tokens share that forwarding contract but do not
- // participate in the refreshable OAuth credential lifecycle.
- func (a *Account) IsOpenAIOAuthLike() bool {
- return a != nil && a.IsOpenAI() && (a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken)
- }
- // UsesOpenAICodexProtocol preserves legacy OpenAI gateway OAuth routing for
- // accounts whose platform is implicit, while adding OpenAI SetupToken.
- func (a *Account) UsesOpenAICodexProtocol() bool {
- return a != nil && (a.Type == AccountTypeOAuth || a.IsOpenAIOAuthLike())
- }
- func (a *Account) IsOpenAIChatGPTSubscription() bool {
- if !a.IsOpenAIOAuth() {
- return false
- }
- switch strings.ToLower(strings.TrimSpace(a.GetCredential("plan_type"))) {
- case "", "free", "abnormal":
- return false
- default:
- return true
- }
- }
- func (a *Account) IsOpenAIPersonalAccessToken() bool {
- if !a.IsOpenAIOAuth() {
- return false
- }
- return isOpenAIPersonalAccessTokenAuthMode(a.GetCredential(openAIAuthModeCredentialKey)) ||
- isOpenAIPersonalAccessTokenAuthMode(a.GetCredential(openAIAuthModeLegacyCredentialKey))
- }
- func (a *Account) IsOpenAIApiKey() bool {
- return a.IsOpenAI() && a.Type == AccountTypeAPIKey
- }
- // GetOpenAIBaseURL 解析 OpenAI 协议族账号的上游 base_url。
- // 适用 openai 与国产 OpenAI 兼容供应商(kimi/zhipu/deepseek);grok 走 GetGrokBaseURL,
- // 此处对 grok 返回 "" 以保持原有行为。
- func (a *Account) GetOpenAIBaseURL() string {
- if !a.IsOpenAI() && !a.IsCNProvider() {
- return ""
- }
- if a.IsCNProvider() && a.IsAdaptiveAPIProtocol() {
- if baseURLs, ok := a.Credentials["api_base_urls"].(map[string]any); ok {
- if baseURL, ok := baseURLs[APIProtocolChatCompletions].(string); ok && strings.TrimSpace(baseURL) != "" {
- return strings.TrimSpace(baseURL)
- }
- }
- }
- if a.Type == AccountTypeAPIKey || a.Type == AccountTypeUpstream {
- if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" {
- return baseURL
- }
- }
- // 平台默认 base_url:CN 供应商按 account_mode 选择 payg / coding 默认值。
- switch a.Platform {
- case PlatformKimi:
- if a.GetAccountMode() == AccountModeCoding {
- return DefaultKimiCodingBaseURL
- }
- return DefaultKimiPayGBaseURL
- case PlatformZhipu:
- if a.GetAccountMode() == AccountModeCoding {
- return DefaultZhipuCodingBaseURL
- }
- return DefaultZhipuPayGBaseURL
- case PlatformDeepseek:
- return DefaultDeepseekBaseURL
- case PlatformMiniMax:
- return DefaultMiniMaxBaseURL
- default:
- return "https://api.openai.com"
- }
- }
- // GetAccountMode 返回国产供应商账号的接入模式(payg / coding);非国产供应商或未设置时
- // 返回空串。存储于 credentials["account_mode"]。
- func (a *Account) GetAccountMode() string {
- if a == nil {
- return ""
- }
- mode := strings.TrimSpace(a.GetCredential("account_mode"))
- if mode == AccountModePayG || mode == AccountModeCoding {
- return mode
- }
- return ""
- }
- // IsCodingPlan 报告账号是否为 Coding Plan 模式(用于滚动用量窗口冷却)。
- func (a *Account) IsCodingPlan() bool {
- return a.GetAccountMode() == AccountModeCoding
- }
- // GetAPIProtocol 返回国产供应商账号的上游 API 协议。存储于
- // credentials["api_protocol"];缺失或与平台不匹配时回退 chat_completions
- // (与既有行为完全一致)。responses 协议仅 deepseek / kimi 支持(官方原生
- // Responses 端点,适配 Codex);zhipu 无此端点。
- func (a *Account) GetAPIProtocol() string {
- if a == nil || !a.IsCNProvider() {
- return APIProtocolChatCompletions
- }
- switch strings.TrimSpace(a.GetCredential("api_protocol")) {
- case APIProtocolAdaptive:
- return APIProtocolAdaptive
- case APIProtocolAnthropic:
- return APIProtocolAnthropic
- case APIProtocolResponses:
- if a.SupportsNativeCNResponses() {
- return APIProtocolResponses
- }
- case APIProtocolChatCompletions:
- return APIProtocolChatCompletions
- }
- return APIProtocolChatCompletions
- }
- // SupportsNativeCNResponses 报告该国产供应商是否提供原生 Responses 端点。
- // DeepSeek 官方为 /responses(无 /v1);Kimi 按量付费与 Coding Plan 均为
- // /v1/responses(moonshot.cn / kimi.com/coding)。
- func (a *Account) SupportsNativeCNResponses() bool {
- if a == nil {
- return false
- }
- switch a.Platform {
- case PlatformDeepseek, PlatformKimi, PlatformMiniMax:
- return true
- default:
- return false
- }
- }
- // UsesNativeCNResponses 报告当前账号是否应按原生 Responses 协议转发
- // (显式 responses,或 adaptive 且平台具备原生端点)。
- func (a *Account) UsesNativeCNResponses() bool {
- if a == nil || !a.SupportsNativeCNResponses() {
- return false
- }
- switch a.GetAPIProtocol() {
- case APIProtocolResponses, APIProtocolAdaptive:
- return true
- default:
- return false
- }
- }
- // IsAdaptiveAPIProtocol 报告账号是否按入站协议动态选择供应商原生端点。
- func (a *Account) IsAdaptiveAPIProtocol() bool {
- return a.GetAPIProtocol() == APIProtocolAdaptive
- }
- // GetCNProtocolBaseURL 返回国产供应商指定协议的上游 base URL。
- // adaptive 账号优先使用 api_base_urls 中的分协议地址,缺失时按平台和
- // account_mode 使用官方默认端点。base_url 继续作为 Chat Completions 地址兼容旧字段。
- func (a *Account) GetCNProtocolBaseURL(protocol string) string {
- if a == nil || !a.IsCNProvider() {
- return ""
- }
- if a.IsAdaptiveAPIProtocol() {
- if baseURLs, ok := a.Credentials["api_base_urls"].(map[string]any); ok {
- if baseURL, ok := baseURLs[protocol].(string); ok && strings.TrimSpace(baseURL) != "" {
- return strings.TrimSpace(baseURL)
- }
- }
- if protocol == APIProtocolChatCompletions {
- if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" {
- return baseURL
- }
- }
- }
- return a.defaultCNProtocolBaseURL(protocol)
- }
- func (a *Account) defaultCNProtocolBaseURL(protocol string) string {
- switch protocol {
- case APIProtocolAnthropic:
- switch a.Platform {
- case PlatformKimi:
- if a.GetAccountMode() == AccountModeCoding {
- return DefaultKimiCodingAnthropicBaseURL
- }
- return DefaultKimiPayGAnthropicBaseURL
- case PlatformZhipu:
- return DefaultZhipuAnthropicBaseURL
- case PlatformDeepseek:
- return DefaultDeepseekAnthropicBaseURL
- case PlatformMiniMax:
- return DefaultMiniMaxAnthropicBaseURL
- }
- case APIProtocolChatCompletions, APIProtocolResponses:
- switch a.Platform {
- case PlatformKimi:
- if a.GetAccountMode() == AccountModeCoding {
- return DefaultKimiCodingBaseURL
- }
- return DefaultKimiPayGBaseURL
- case PlatformZhipu:
- if a.GetAccountMode() == AccountModeCoding {
- return DefaultZhipuCodingBaseURL
- }
- return DefaultZhipuPayGBaseURL
- case PlatformDeepseek:
- return DefaultDeepseekBaseURL
- case PlatformMiniMax:
- return DefaultMiniMaxBaseURL
- }
- }
- return ""
- }
- // IsAnthropicProtocol 报告账号是否以原生 Anthropic 协议接入上游
- // (/v1/messages 直通,适配 Claude Code 等客户端)。
- func (a *Account) IsAnthropicProtocol() bool {
- return a.GetAPIProtocol() == APIProtocolAnthropic
- }
- // GetAnthropicProtocolBaseURL 返回 Anthropic 协议账号的上游 base_url
- // (上游路径为 {base}/v1/messages)。优先取凭证 base_url,缺失时按
- // 供应商 × 接入模式返回默认端点。非 Anthropic 协议账号返回空串。
- func (a *Account) GetAnthropicProtocolBaseURL() string {
- if a == nil || (!a.IsAnthropicProtocol() && !a.IsAdaptiveAPIProtocol()) {
- return ""
- }
- if a.IsAdaptiveAPIProtocol() {
- return a.GetCNProtocolBaseURL(APIProtocolAnthropic)
- }
- if a.Type == AccountTypeAPIKey || a.Type == AccountTypeUpstream {
- if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" {
- return baseURL
- }
- }
- switch a.Platform {
- case PlatformKimi:
- if a.GetAccountMode() == AccountModeCoding {
- return DefaultKimiCodingAnthropicBaseURL
- }
- return DefaultKimiPayGAnthropicBaseURL
- case PlatformZhipu:
- return DefaultZhipuAnthropicBaseURL
- case PlatformDeepseek:
- return DefaultDeepseekAnthropicBaseURL
- case PlatformMiniMax:
- return DefaultMiniMaxAnthropicBaseURL
- default:
- return ""
- }
- }
- // GetOpenAIFormatBaseURL 返回供 OpenAI 格式端点(/v1/models、/v1/chat/completions
- // 等)使用的 base。chat_completions / responses 协议下与 GetOpenAIBaseURL
- // 一致(凭证 base_url 或平台默认);anthropic 协议下凭证 base_url 指向 Anthropic
- // 端点,不能拿来拼 OpenAI 路径,此时返回该供应商 × 模式的 Chat Completions
- // 默认 base(模型同步等协议族共用路径仍可用)。
- func (a *Account) GetOpenAIFormatBaseURL() string {
- if a == nil || !a.IsAnthropicProtocol() {
- return a.GetOpenAIBaseURL()
- }
- switch a.Platform {
- case PlatformKimi:
- if a.GetAccountMode() == AccountModeCoding {
- return DefaultKimiCodingBaseURL
- }
- return DefaultKimiPayGBaseURL
- case PlatformZhipu:
- if a.GetAccountMode() == AccountModeCoding {
- return DefaultZhipuCodingBaseURL
- }
- return DefaultZhipuPayGBaseURL
- case PlatformDeepseek:
- return DefaultDeepseekBaseURL
- default:
- return a.GetOpenAIBaseURL()
- }
- }
- // GetCNAPIKey 返回国产 OpenAI 兼容供应商账号的 api_key 凭据(kimi/zhipu/deepseek)。
- // 与 openai 的 GetOpenAIApiKey 区分:后者仅对 openai 平台返回。
- func (a *Account) GetCNAPIKey() string {
- if a == nil || !a.IsCNProvider() {
- return ""
- }
- return a.GetCredential("api_key")
- }
- // GetCodingPlanProvider 根据 base_url 识别 Coding Plan 供应商(kimi / zhipu),
- // 用于路由到对应的额度查询端点。非 coding 模式或无法识别时返回空串。
- // 判定规则与 cc-switch coding_plan.rs::detect_provider 保持一致。
- func (a *Account) GetCodingPlanProvider() string {
- if a == nil || a.GetAccountMode() != AccountModeCoding {
- return ""
- }
- baseURL := strings.ToLower(a.GetOpenAIBaseURL())
- if parsed, err := url.Parse(baseURL); a.Platform == PlatformMiniMax && err == nil && (parsed.Hostname() == "api.minimaxi.com" || parsed.Hostname() == "api.minimax.io") {
- return PlatformMiniMax
- }
- switch {
- case strings.Contains(baseURL, "api.kimi.com/coding"):
- return PlatformKimi
- case strings.Contains(baseURL, "bigmodel.cn"), strings.Contains(baseURL, "api.z.ai"):
- return PlatformZhipu
- default:
- return ""
- }
- }
- func (a *Account) GetOpenAIAccessToken() string {
- if !a.IsOpenAI() {
- return ""
- }
- return a.GetCredential("access_token")
- }
- func (a *Account) GetOpenAIRefreshToken() string {
- if !a.IsOpenAIOAuth() {
- return ""
- }
- return a.GetCredential("refresh_token")
- }
- // GetGrokBaseURL selects the upstream used by Grok text and Responses traffic.
- // Grok media traffic has a different transport contract and must use
- // GetGrokMediaBaseURL instead.
- //
- // The stored base_url only rewrites forwarding endpoints. Credential lifecycle
- // traffic (OAuth authorization and token refresh) always uses the official
- // auth endpoints regardless of this value.
- func (a *Account) GetGrokBaseURL() string {
- if a == nil || !a.IsGrok() {
- return ""
- }
- if a.IsGrokOAuth() {
- return a.GetGrokBaseURLOr(xai.DefaultCLIBaseURL)
- }
- return a.GetGrokBaseURLOr(xai.DefaultBaseURL)
- }
- // GetGrokBaseURLOr resolves an explicit account endpoint, falling back to the
- // supplied default. Official OAuth endpoints are normalized here; custom
- // endpoints are retained for the request builder's operator URL policy.
- func (a *Account) GetGrokBaseURLOr(defaultBaseURL string) string {
- if a == nil || !a.IsGrok() {
- return ""
- }
- defaultBaseURL = strings.TrimRight(strings.TrimSpace(defaultBaseURL), "/")
- if defaultBaseURL == "" {
- if a.IsGrokOAuth() {
- defaultBaseURL = xai.DefaultCLIBaseURL
- } else {
- defaultBaseURL = xai.DefaultBaseURL
- }
- }
- baseURL := strings.TrimSpace(a.GetCredential("base_url"))
- if baseURL == "" {
- return defaultBaseURL
- }
- if !a.IsGrokOAuth() {
- return baseURL
- }
- // Explicit regional/API or custom values remain pinned. Custom endpoints are checked by the
- // operator URL policy at the request builder, which has access to config.
- if validated, err := xai.ValidateTrustedBaseURL(baseURL); err == nil {
- return validated
- }
- if parsed, err := url.Parse(baseURL); err == nil && parsed.Scheme != "" && parsed.Host != "" &&
- parsed.User == nil && parsed.RawQuery == "" && parsed.Fragment == "" {
- return strings.TrimRight(baseURL, "/")
- }
- return defaultBaseURL
- }
- // GetGrokMediaBaseURL selects the upstream used by Grok Imagine APIs.
- // The subscription CLI gateway enforces a small request-body limit that
- // rejects large Base64 media payloads, so OAuth media leaves for api.x.ai
- // whenever text traffic resolves to the CLI gateway. Every other manually
- // selected endpoint (official/regional API hosts or custom relays) serves
- // media as-is.
- func (a *Account) GetGrokMediaBaseURL() string {
- if !a.IsGrok() {
- return ""
- }
- baseURL := a.GetGrokBaseURL()
- if a.IsGrokOAuth() && isGrokCLIProxyTarget(baseURL) {
- return xai.DefaultBaseURL
- }
- return baseURL
- }
- func (a *Account) GetGrokAccessToken() string {
- if !a.IsGrok() {
- return ""
- }
- return a.GetCredential("access_token")
- }
- func (a *Account) GetGrokRefreshToken() string {
- if !a.IsGrokOAuth() {
- return ""
- }
- return a.GetCredential("refresh_token")
- }
- func (a *Account) GetOpenAIIDToken() string {
- if !a.IsOpenAIOAuth() {
- return ""
- }
- return a.GetCredential("id_token")
- }
- func (a *Account) GetOpenAIApiKey() string {
- if !a.IsOpenAIApiKey() {
- return ""
- }
- return a.GetCredential("api_key")
- }
- // GetOpenAIProtocolAPIKey 返回 OpenAI 协议族 APIKey 账号的密钥。
- // 覆盖 openai 原生账号与国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)账号,
- // 供转发鉴权、模型列表同步等协议族共用路径使用。注意 IsOpenAIApiKey 语义上
- // 仅指 openai 平台账号,调度倍率/WS 能力门控继续以其为准,不受本方法影响。
- func (a *Account) GetOpenAIProtocolAPIKey() string {
- if a == nil {
- return ""
- }
- if a.IsCNProvider() {
- if a.Type != AccountTypeAPIKey {
- return ""
- }
- return a.GetCredential("api_key")
- }
- return a.GetOpenAIApiKey()
- }
- func (a *Account) GetOpenAIUserAgent() string {
- if !a.IsOpenAI() {
- return ""
- }
- return a.GetCredential("user_agent")
- }
- func (a *Account) GetChatGPTAccountID() string {
- if !a.IsOpenAIOAuthLike() {
- return ""
- }
- return a.GetCredential("chatgpt_account_id")
- }
- func (a *Account) IsChatGPTAccountFedRAMP() bool {
- if !a.IsOpenAIOAuthLike() || a.Credentials == nil {
- return false
- }
- v, ok := a.Credentials["chatgpt_account_is_fedramp"]
- if !ok || v == nil {
- return false
- }
- switch value := v.(type) {
- case bool:
- return value
- case string:
- parsed, err := strconv.ParseBool(strings.TrimSpace(value))
- return err == nil && parsed
- case json.Number:
- parsed, err := strconv.ParseBool(value.String())
- return err == nil && parsed
- case float64:
- return value != 0
- case int:
- return value != 0
- case int64:
- return value != 0
- default:
- return false
- }
- }
- func (a *Account) GetOpenAIDeviceID() string {
- if !a.IsOpenAIOAuth() {
- return ""
- }
- return strings.TrimSpace(a.GetExtraString("openai_device_id"))
- }
- func (a *Account) GetOpenAISessionID() string {
- if !a.IsOpenAIOAuth() {
- return ""
- }
- return strings.TrimSpace(a.GetExtraString("openai_session_id"))
- }
- func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapability) bool {
- if a == nil {
- return false
- }
- if capability == "" {
- return true
- }
- if !a.IsOpenAICompatible() {
- return false
- }
- if a.IsGrok() {
- switch capability {
- case OpenAIEndpointCapabilityChatCompletions:
- return true
- case OpenAIEndpointCapabilityGrokMediaGeneration:
- eligible, reason := a.GrokMediaGenerationEligibility()
- // Unobserved OAuth accounts remain scheduler candidates only so the
- // request path can run the billing probe before forwarding. The
- // forwarding gate itself fails closed if that probe is unavailable or
- // cannot produce positive paid-entitlement evidence.
- return eligible || reason == "billing_unobserved"
- default:
- return false
- }
- }
- switch capability {
- case OpenAIEndpointCapabilityChatCompletions:
- case OpenAIEndpointCapabilityLive:
- return a.Platform == PlatformOpenAI &&
- a.Type == AccountTypeOAuth &&
- !a.IsOpenAIPersonalAccessToken() &&
- !a.IsOpenAIAgentIdentity()
- case OpenAIEndpointCapabilityResponses:
- // Responses 支持状态由 accounts.extra 的自动探测标记决定,而非
- // credentials 能力集。已探测确认不支持 /v1/responses 的 APIKey 上游
- // 必须排除——否则会在 forward 阶段被静默降级为 Chat Completions,
- // 无法完成生图(#4417)。未探测/OAuth 账号保留旧行为(不排除)。
- if a.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(a.Extra) {
- return false
- }
- // 支持 Responses 的上游同样需具备 chat 能力:复用下方 chat_completions
- // 配置集校验。
- capability = OpenAIEndpointCapabilityChatCompletions
- case OpenAIEndpointCapabilityAlphaSearch:
- // alpha/search 的转发按账号类型分流:OAuth/PAT 走
- // chatgpt.com/backend-api/codex/alpha/search,API key 走
- // {base_url}/v1/alpha/search(见 openAIAlphaSearchURL),两类账号
- // 都可承接独立搜索请求。上游不支持该端点时由转发层 failover 兜底。
- if a.Type != AccountTypeOAuth && a.Type != AccountTypeAPIKey {
- return false
- }
- case OpenAIEndpointCapabilityEmbeddings:
- if a.Type != AccountTypeAPIKey {
- return false
- }
- default:
- return false
- }
- configured, found := a.openAIEndpointCapabilitySet()
- if !found {
- return true
- }
- if capability == OpenAIEndpointCapabilityAlphaSearch && configured[string(OpenAIEndpointCapabilityChatCompletions)] {
- return true
- }
- return configured[string(capability)]
- }
- // GrokMediaGenerationEligibility reports whether a Grok account may receive
- // new image/video generation requests. OAuth media fails closed unless billing
- // observations provide positive paid-entitlement evidence. An explicit
- // operator override takes precedence over probe data.
- func (a *Account) GrokMediaGenerationEligibility() (bool, string) {
- if a == nil || !a.IsGrok() {
- return false, "not_grok"
- }
- if override, ok := grokMediaEligibilityOverride(a.Extra); ok {
- if override {
- return true, "override_enabled"
- }
- return false, "override_disabled"
- }
- if a.Type != AccountTypeOAuth {
- return true, "non_oauth"
- }
- billing, err := grokBillingSnapshotFromExtra(a.Extra)
- if err != nil || billing == nil {
- return false, "billing_unobserved"
- }
- if billing.StatusCode == 403 || billing.WeeklyStatusCode == 403 || billing.MonthlyStatusCode == 403 {
- return false, "billing_forbidden"
- }
- if isKnownGrokFreeAccount(a) {
- return false, "billing_free_tier"
- }
- if !grokBillingHasAuthoritativeQuota(billing) {
- return false, "billing_inconclusive"
- }
- return true, "eligible"
- }
- func grokMediaEligibilityOverride(extra map[string]any) (bool, bool) {
- if extra == nil {
- return false, false
- }
- raw, exists := extra[GrokMediaEligibleExtraKey]
- if !exists || raw == nil {
- return false, false
- }
- value, ok := raw.(bool)
- return value, ok
- }
- func (a *Account) openAIEndpointCapabilitySet() (map[string]bool, bool) {
- if a == nil || a.Credentials == nil {
- return nil, false
- }
- raw, found := a.Credentials[openAIEndpointCapabilitiesCredentialKey]
- if !found || raw == nil {
- return nil, false
- }
- result := make(map[string]bool)
- add := func(value string) {
- value = strings.ToLower(strings.TrimSpace(value))
- if value == "" {
- return
- }
- result[value] = true
- }
- // 空容器({} / [])与未配置一致:不限制任何能力。
- // 避免 OAuth 账号因 API 直写/导入/历史数据遗留的空对象而被调度器静默排除(#5530)。
- // 注意:非空但全 false / 类型异常的数据仍视为「已配置且不含能力」,保持原行为。
- switch capabilities := raw.(type) {
- case []any:
- if len(capabilities) == 0 {
- return nil, false
- }
- for _, item := range capabilities {
- if value, ok := item.(string); ok {
- add(value)
- }
- }
- case []string:
- if len(capabilities) == 0 {
- return nil, false
- }
- for _, value := range capabilities {
- add(value)
- }
- case map[string]any:
- if len(capabilities) == 0 {
- return nil, false
- }
- for key, value := range capabilities {
- enabled, ok := value.(bool)
- if ok && enabled {
- add(key)
- }
- }
- case map[string]bool:
- if len(capabilities) == 0 {
- return nil, false
- }
- for key, enabled := range capabilities {
- if enabled {
- add(key)
- }
- }
- }
- return result, true
- }
- func (a *Account) SupportsOpenAIImageCapability(capability OpenAIImagesCapability) bool {
- if capability == "" {
- return true
- }
- if !a.IsOpenAI() {
- return false
- }
- switch capability {
- case OpenAIImagesCapabilityBasic, OpenAIImagesCapabilityNative:
- return a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken || a.Type == AccountTypeAPIKey
- default:
- return true
- }
- }
- func (a *Account) GetChatGPTUserID() string {
- if !a.IsOpenAIOAuth() {
- return ""
- }
- return a.GetCredential("chatgpt_user_id")
- }
- func (a *Account) GetOpenAIOrganizationID() string {
- if !a.IsOpenAIOAuth() {
- return ""
- }
- return a.GetCredential("organization_id")
- }
- func (a *Account) GetOpenAITokenExpiresAt() *time.Time {
- if !a.IsOpenAIOAuth() {
- return nil
- }
- return a.GetCredentialAsTime("expires_at")
- }
- func (a *Account) IsOpenAITokenExpired() bool {
- expiresAt := a.GetOpenAITokenExpiresAt()
- if expiresAt == nil {
- return false
- }
- return time.Now().Add(60 * time.Second).After(*expiresAt)
- }
- // IsMixedSchedulingEnabled 检查 antigravity 账户是否启用混合调度
- // 启用后可参与 anthropic/gemini 分组的账户调度
- func (a *Account) IsMixedSchedulingEnabled() bool {
- if a.Platform != PlatformAntigravity {
- return false
- }
- if a.Extra == nil {
- return false
- }
- if v, ok := a.Extra["mixed_scheduling"]; ok {
- if enabled, ok := v.(bool); ok {
- return enabled
- }
- }
- return false
- }
- // IsOveragesEnabled 检查 Antigravity 账号是否启用 AI Credits 超量请求。
- func (a *Account) IsOveragesEnabled() bool {
- if a.Platform != PlatformAntigravity {
- return false
- }
- if a.Extra == nil {
- return false
- }
- if v, ok := a.Extra["allow_overages"]; ok {
- if enabled, ok := v.(bool); ok {
- return enabled
- }
- }
- return false
- }
- // IsOpenAIPassthroughEnabled 返回 OpenAI 账号是否启用"自动透传(仅替换认证)"。
- //
- // 新字段:accounts.extra.openai_passthrough。
- // 兼容字段:accounts.extra.openai_oauth_passthrough(历史 OAuth 开关)。
- // 字段缺失或类型不正确时,按 false(关闭)处理。
- func (a *Account) IsOpenAIPassthroughEnabled() bool {
- if a == nil || !a.IsOpenAI() || a.Extra == nil {
- return false
- }
- if enabled, ok := a.Extra["openai_passthrough"].(bool); ok {
- return enabled
- }
- if enabled, ok := a.Extra["openai_oauth_passthrough"].(bool); ok {
- return enabled
- }
- return false
- }
- // IsOpenAIResponsesWebSocketV2Enabled 返回 OpenAI 账号是否开启 Responses WebSocket v2。
- //
- // 分类型新字段:
- // - OAuth 账号:accounts.extra.openai_oauth_responses_websockets_v2_enabled
- // - API Key 账号:accounts.extra.openai_apikey_responses_websockets_v2_enabled
- //
- // 兼容字段:
- // - accounts.extra.responses_websockets_v2_enabled
- // - accounts.extra.openai_ws_enabled(历史开关)
- //
- // 优先级:
- // 1. 按账号类型读取分类型字段
- // 2. 分类型字段缺失时,回退兼容字段
- func (a *Account) IsOpenAIResponsesWebSocketV2Enabled() bool {
- if a == nil || !a.IsOpenAI() || a.Extra == nil {
- return false
- }
- if a.IsOpenAIOAuthLike() {
- if enabled, ok := a.Extra["openai_oauth_responses_websockets_v2_enabled"].(bool); ok {
- return enabled
- }
- }
- if a.IsOpenAIApiKey() {
- if enabled, ok := a.Extra["openai_apikey_responses_websockets_v2_enabled"].(bool); ok {
- return enabled
- }
- }
- if enabled, ok := a.Extra["responses_websockets_v2_enabled"].(bool); ok {
- return enabled
- }
- if enabled, ok := a.Extra["openai_ws_enabled"].(bool); ok {
- return enabled
- }
- return false
- }
- const (
- OpenAIWSIngressModeOff = "off"
- OpenAIWSIngressModeShared = "shared"
- OpenAIWSIngressModeDedicated = "dedicated"
- OpenAIWSIngressModeCtxPool = "ctx_pool"
- OpenAIWSIngressModePassthrough = "passthrough"
- OpenAIWSIngressModeHTTPBridge = "http_bridge"
- )
- func normalizeOpenAIWSIngressMode(mode string) string {
- switch strings.ToLower(strings.TrimSpace(mode)) {
- case OpenAIWSIngressModeOff:
- return OpenAIWSIngressModeOff
- case OpenAIWSIngressModeCtxPool:
- return OpenAIWSIngressModeCtxPool
- case OpenAIWSIngressModePassthrough:
- return OpenAIWSIngressModePassthrough
- case OpenAIWSIngressModeHTTPBridge:
- return OpenAIWSIngressModeHTTPBridge
- case OpenAIWSIngressModeShared:
- return OpenAIWSIngressModeShared
- case OpenAIWSIngressModeDedicated:
- return OpenAIWSIngressModeDedicated
- default:
- return ""
- }
- }
- func normalizeOpenAIWSIngressDefaultMode(mode string) string {
- if normalized := normalizeOpenAIWSIngressMode(mode); normalized != "" {
- if normalized == OpenAIWSIngressModeShared || normalized == OpenAIWSIngressModeDedicated {
- return OpenAIWSIngressModeCtxPool
- }
- return normalized
- }
- return OpenAIWSIngressModeCtxPool
- }
- // ResolveOpenAIResponsesWebSocketV2Mode 返回账号在 WSv2 ingress 下的有效模式(off/ctx_pool/passthrough)。
- //
- // 优先级:
- // 1. 分类型 mode 新字段(string)
- // 2. 分类型 enabled 旧字段(bool)
- // 3. 兼容 enabled 旧字段(bool)
- // 4. defaultMode(非法时回退 ctx_pool)
- func (a *Account) ResolveOpenAIResponsesWebSocketV2Mode(defaultMode string) string {
- resolvedDefault := normalizeOpenAIWSIngressDefaultMode(defaultMode)
- if a == nil || !a.IsOpenAI() {
- return OpenAIWSIngressModeOff
- }
- if a.Extra == nil {
- return resolvedDefault
- }
- resolveModeString := func(key string) (string, bool) {
- raw, ok := a.Extra[key]
- if !ok {
- return "", false
- }
- mode, ok := raw.(string)
- if !ok {
- return "", false
- }
- normalized := normalizeOpenAIWSIngressMode(mode)
- if normalized == "" {
- return "", false
- }
- return normalized, true
- }
- resolveBoolMode := func(key string) (string, bool) {
- raw, ok := a.Extra[key]
- if !ok {
- return "", false
- }
- enabled, ok := raw.(bool)
- if !ok {
- return "", false
- }
- if enabled {
- return OpenAIWSIngressModeCtxPool, true
- }
- return OpenAIWSIngressModeOff, true
- }
- if a.IsOpenAIOAuthLike() {
- if mode, ok := resolveModeString("openai_oauth_responses_websockets_v2_mode"); ok {
- return mode
- }
- if mode, ok := resolveBoolMode("openai_oauth_responses_websockets_v2_enabled"); ok {
- return mode
- }
- }
- if a.IsOpenAIApiKey() {
- if mode, ok := resolveModeString("openai_apikey_responses_websockets_v2_mode"); ok {
- return mode
- }
- if mode, ok := resolveBoolMode("openai_apikey_responses_websockets_v2_enabled"); ok {
- return mode
- }
- }
- if mode, ok := resolveBoolMode("responses_websockets_v2_enabled"); ok {
- return mode
- }
- if mode, ok := resolveBoolMode("openai_ws_enabled"); ok {
- return mode
- }
- // 兼容旧值:shared/dedicated 语义都归并到 ctx_pool。
- if resolvedDefault == OpenAIWSIngressModeShared || resolvedDefault == OpenAIWSIngressModeDedicated {
- return OpenAIWSIngressModeCtxPool
- }
- return resolvedDefault
- }
- // IsOpenAIWSForceHTTPEnabled 返回账号级"强制 HTTP"开关。
- // 字段:accounts.extra.openai_ws_force_http。
- func (a *Account) IsOpenAIWSForceHTTPEnabled() bool {
- if a == nil || !a.IsOpenAI() || a.Extra == nil {
- return false
- }
- enabled, ok := a.Extra["openai_ws_force_http"].(bool)
- return ok && enabled
- }
- // IsOpenAIResponsesFlattenNamespacesEnabled 返回账号级"摊平 Codex namespace 工具"开关。
- // 字段:accounts.extra.openai_responses_flatten_namespaces,缺省 false(原样保留)。
- //
- // namespace 是 Codex 后端定义的私有扩展,OAuth 出口恒为 chatgpt.com/backend-api/codex
- // (buildUpstreamRequest 只对 API Key 账号取 base_url),即定义方本身,因此默认保留。
- // 该开关只为把流量转发到不认识 namespace 的兼容上游的部署保留退路:打开后恢复
- // 0.1.166 及更早版本的摊平行为。仅对 OpenAI OAuth 账号有效——API Key 走 chat
- // completions 回退桥时由桥自行摊平,Grok/Anthropic 出口有各自的适配链路。
- func (a *Account) IsOpenAIResponsesFlattenNamespacesEnabled() bool {
- if a == nil || !a.IsOpenAI() || a.Extra == nil {
- return false
- }
- enabled, ok := a.Extra["openai_responses_flatten_namespaces"].(bool)
- return ok && enabled
- }
- // IsOpenAIWSAllowStoreRecoveryEnabled 返回账号级 store 恢复开关。
- // 字段:accounts.extra.openai_ws_allow_store_recovery。
- func (a *Account) IsOpenAIWSAllowStoreRecoveryEnabled() bool {
- if a == nil || !a.IsOpenAI() || a.Extra == nil {
- return false
- }
- enabled, ok := a.Extra["openai_ws_allow_store_recovery"].(bool)
- return ok && enabled
- }
- // IsOpenAIOAuthPassthroughEnabled 兼容旧接口,等价于 OAuth 账号的 IsOpenAIPassthroughEnabled。
- func (a *Account) IsOpenAIOAuthPassthroughEnabled() bool {
- return a != nil && a.IsOpenAIOAuth() && a.IsOpenAIPassthroughEnabled()
- }
- // IsAnthropicAPIKeyPassthroughEnabled 返回 Anthropic API Key 账号是否启用"自动透传(仅替换认证)"。
- // 字段:accounts.extra.anthropic_passthrough。
- // 字段缺失或类型不正确时,按 false(关闭)处理。
- func (a *Account) IsAnthropicAPIKeyPassthroughEnabled() bool {
- if a == nil || a.Platform != PlatformAnthropic || a.Type != AccountTypeAPIKey || a.Extra == nil {
- return false
- }
- enabled, ok := a.Extra["anthropic_passthrough"].(bool)
- return ok && enabled
- }
- // WebSearch 模拟三态常量
- const (
- WebSearchModeDefault = "default" // 跟随渠道配置
- WebSearchModeEnabled = "enabled" // 强制开启
- WebSearchModeDisabled = "disabled" // 强制关闭
- )
- // GetWebSearchEmulationMode 返回账号的 WebSearch 模拟模式。
- // 三态:default(跟随渠道)/ enabled(强制开启)/ disabled(强制关闭)。
- // 兼容旧 bool 值:true→enabled, false→default(并记录 debug 日志)。
- func (a *Account) GetWebSearchEmulationMode() string {
- if a == nil || a.Platform != PlatformAnthropic || a.Type != AccountTypeAPIKey || a.Extra == nil {
- return WebSearchModeDefault
- }
- raw := a.Extra[featureKeyWebSearchEmulation]
- // Tolerant: legacy bool values (pre-migration or stale writes)
- if b, ok := raw.(bool); ok {
- slog.Debug("legacy bool web_search_emulation value", "account_id", a.ID, "value", b)
- if b {
- return WebSearchModeEnabled
- }
- return WebSearchModeDefault
- }
- mode, ok := raw.(string)
- if !ok {
- return WebSearchModeDefault
- }
- switch mode {
- case WebSearchModeEnabled, WebSearchModeDisabled:
- return mode
- default:
- return WebSearchModeDefault
- }
- }
- // IsCodexCLIOnlyEnabled 返回 OpenAI OAuth 账号是否启用"仅允许 Codex 官方客户端"。
- // 字段:accounts.extra.codex_cli_only。
- // 字段缺失或类型不正确时,按 false(关闭)处理。
- func (a *Account) IsCodexCLIOnlyEnabled() bool {
- if a == nil || !a.IsOpenAIOAuth() || a.Extra == nil {
- return false
- }
- enabled, ok := a.Extra["codex_cli_only"].(bool)
- return ok && enabled
- }
- // IsCodexCLIOnlyAppServerAllowed 返回 codex_cli_only 账号是否额外放行 Codex app-server
- // 第三方客户端(运行时与全局 app_server 开关 OR)。字段:accounts.extra.codex_cli_only_allow_app_server。
- // 仅在 codex_cli_only 已启用时有意义;字段缺失或类型不符按 false(不放行)处理。
- func (a *Account) IsCodexCLIOnlyAppServerAllowed() bool {
- if !a.IsCodexCLIOnlyEnabled() {
- return false
- }
- v, ok := a.Extra["codex_cli_only_allow_app_server"].(bool)
- return ok && v
- }
- // WindowCostSchedulability 窗口费用调度状态
- type WindowCostSchedulability int
- const (
- // WindowCostSchedulable 可正常调度
- WindowCostSchedulable WindowCostSchedulability = iota
- // WindowCostStickyOnly 仅允许粘性会话
- WindowCostStickyOnly
- // WindowCostNotSchedulable 完全不可调度
- WindowCostNotSchedulable
- )
- // IsAnthropicOAuthOrSetupToken 判断是否为 Anthropic OAuth 或 SetupToken 类型账号
- // 仅这两类账号支持 5h 窗口额度控制和会话数量控制
- func (a *Account) IsAnthropicOAuthOrSetupToken() bool {
- return a.Platform == PlatformAnthropic && (a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken)
- }
- // IsTLSFingerprintEnabled 检查是否启用 TLS 指纹伪装
- // 适用于 Anthropic 与 OpenAI OAuth/SetupToken 类型账号。
- // 启用后使用账号绑定的完整 TLS ClientHello 模板。
- func (a *Account) IsTLSFingerprintEnabled() bool {
- if a == nil || (a.Platform != PlatformAnthropic && a.Platform != PlatformOpenAI) ||
- (a.Type != AccountTypeOAuth && a.Type != AccountTypeSetupToken) {
- return false
- }
- if a.Extra == nil {
- return false
- }
- if v, ok := a.Extra["enable_tls_fingerprint"]; ok {
- if enabled, ok := v.(bool); ok {
- return enabled
- }
- }
- return false
- }
- // GetTLSFingerprintProfileID 获取账号绑定的 TLS 指纹模板 ID
- // 返回 0 表示未绑定(使用内置默认 profile)
- func (a *Account) GetTLSFingerprintProfileID() int64 {
- if a.Extra == nil {
- return 0
- }
- v, ok := a.Extra["tls_fingerprint_profile_id"]
- if !ok {
- return 0
- }
- switch id := v.(type) {
- case float64:
- return int64(id)
- case int64:
- return id
- case int:
- return int64(id)
- case json.Number:
- if i, err := id.Int64(); err == nil {
- return i
- }
- }
- return 0
- }
- // GetUserMsgQueueMode 获取用户消息队列模式
- // "serialize" = 串行队列, "throttle" = 软性限速, "" = 未设置(使用全局配置)
- func (a *Account) GetUserMsgQueueMode() string {
- if a.Extra == nil {
- return ""
- }
- // 优先读取新字段 user_msg_queue_mode(白名单校验,非法值视为未设置)
- if mode, ok := a.Extra["user_msg_queue_mode"].(string); ok && mode != "" {
- if mode == config.UMQModeSerialize || mode == config.UMQModeThrottle {
- return mode
- }
- return "" // 非法值 fallback 到全局配置
- }
- // 向后兼容: user_msg_queue_enabled: true → "serialize"
- if enabled, ok := a.Extra["user_msg_queue_enabled"].(bool); ok && enabled {
- return config.UMQModeSerialize
- }
- return ""
- }
- // IsSessionIDMaskingEnabled 检查是否启用会话ID伪装
- // 仅适用于 Anthropic OAuth/SetupToken 类型账号
- // 启用后将在一段时间内(15分钟)固定 metadata.user_id 中的 session ID,
- // 使上游认为请求来自同一个会话
- func (a *Account) IsSessionIDMaskingEnabled() bool {
- if !a.IsAnthropicOAuthOrSetupToken() {
- return false
- }
- if a.Extra == nil {
- return false
- }
- if v, ok := a.Extra["session_id_masking_enabled"]; ok {
- if enabled, ok := v.(bool); ok {
- return enabled
- }
- }
- return false
- }
- // IsCustomBaseURLEnabled 检查是否启用自定义 base URL 中继转发
- // 仅适用于 Anthropic OAuth/SetupToken 类型账号
- func (a *Account) IsCustomBaseURLEnabled() bool {
- if !a.IsAnthropicOAuthOrSetupToken() {
- return false
- }
- if a.Extra == nil {
- return false
- }
- if v, ok := a.Extra["custom_base_url_enabled"]; ok {
- if enabled, ok := v.(bool); ok {
- return enabled
- }
- }
- return false
- }
- // GetCustomBaseURL 返回自定义中继服务的 base URL
- func (a *Account) GetCustomBaseURL() string {
- return a.GetExtraString("custom_base_url")
- }
- // IsCacheTTLOverrideEnabled 检查是否启用缓存 TTL 强制替换
- // 仅适用于 Anthropic OAuth/SetupToken 类型账号
- // 启用后将所有 cache creation tokens 归入指定的 TTL 类型(5m 或 1h)
- func (a *Account) IsCacheTTLOverrideEnabled() bool {
- if !a.IsAnthropicOAuthOrSetupToken() {
- return false
- }
- if a.Extra == nil {
- return false
- }
- if v, ok := a.Extra["cache_ttl_override_enabled"]; ok {
- if enabled, ok := v.(bool); ok {
- return enabled
- }
- }
- return false
- }
- // GetCacheTTLOverrideTarget 获取缓存 TTL 强制替换的目标类型
- // 返回 "5m" 或 "1h",默认 "5m"
- func (a *Account) GetCacheTTLOverrideTarget() string {
- if a.Extra == nil {
- return "5m"
- }
- if v, ok := a.Extra["cache_ttl_override_target"]; ok {
- if target, ok := v.(string); ok && (target == "5m" || target == "1h") {
- return target
- }
- }
- return "5m"
- }
- // GetQuotaLimit 获取 API Key 账号的配额限制(美元)
- // 返回 0 表示未启用
- func (a *Account) GetQuotaLimit() float64 {
- return a.getExtraFloat64("quota_limit")
- }
- // GetQuotaUsed 获取 API Key 账号的已用配额(美元)
- func (a *Account) GetQuotaUsed() float64 {
- return a.getExtraFloat64("quota_used")
- }
- // GetQuotaDailyLimit 获取日额度限制(美元),0 表示未启用
- func (a *Account) GetQuotaDailyLimit() float64 {
- return a.getExtraFloat64("quota_daily_limit")
- }
- // GetQuotaDailyUsed 获取当日已用额度(美元)
- func (a *Account) GetQuotaDailyUsed() float64 {
- return a.getExtraFloat64("quota_daily_used")
- }
- // GetQuotaWeeklyLimit 获取周额度限制(美元),0 表示未启用
- func (a *Account) GetQuotaWeeklyLimit() float64 {
- return a.getExtraFloat64("quota_weekly_limit")
- }
- // GetQuotaWeeklyUsed 获取本周已用额度(美元)
- func (a *Account) GetQuotaWeeklyUsed() float64 {
- return a.getExtraFloat64("quota_weekly_used")
- }
- // getExtraFloat64 从 Extra 中读取指定 key 的 float64 值
- func (a *Account) getExtraFloat64(key string) float64 {
- if a.Extra == nil {
- return 0
- }
- if v, ok := a.Extra[key]; ok {
- return parseExtraFloat64(v)
- }
- return 0
- }
- // getExtraTime 从 Extra 中读取 RFC3339 时间戳
- func (a *Account) getExtraTime(key string) time.Time {
- if a.Extra == nil {
- return time.Time{}
- }
- if v, ok := a.Extra[key]; ok {
- if s, ok := v.(string); ok {
- if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
- return t
- }
- if t, err := time.Parse(time.RFC3339, s); err == nil {
- return t
- }
- }
- }
- return time.Time{}
- }
- // getExtraBool 从 Extra 中读取指定 key 的 bool 值
- func (a *Account) getExtraBool(key string) bool {
- if a.Extra == nil {
- return false
- }
- if v, ok := a.Extra[key]; ok {
- if b, ok := v.(bool); ok {
- return b
- }
- }
- return false
- }
- // getExtraString 从 Extra 中读取指定 key 的字符串值
- func (a *Account) getExtraString(key string) string {
- if a.Extra == nil {
- return ""
- }
- if v, ok := a.Extra[key]; ok {
- if s, ok := v.(string); ok {
- return s
- }
- }
- return ""
- }
- // getExtraStringDefault 从 Extra 中读取指定 key 的字符串值,不存在时返回 defaultVal
- func (a *Account) getExtraStringDefault(key, defaultVal string) string {
- if v := a.getExtraString(key); v != "" {
- return v
- }
- return defaultVal
- }
- // getExtraInt 从 Extra 中读取指定 key 的 int 值
- func (a *Account) getExtraInt(key string) int {
- if a.Extra == nil {
- return 0
- }
- if v, ok := a.Extra[key]; ok {
- return int(parseExtraFloat64(v))
- }
- return 0
- }
- // GetQuotaDailyResetMode 获取日额度重置模式:"rolling"(默认)或 "fixed"
- func (a *Account) GetQuotaDailyResetMode() string {
- if m := a.getExtraString("quota_daily_reset_mode"); m == "fixed" {
- return "fixed"
- }
- return "rolling"
- }
- // GetQuotaDailyResetHour 获取固定重置的小时(0-23),默认 0
- func (a *Account) GetQuotaDailyResetHour() int {
- return a.getExtraInt("quota_daily_reset_hour")
- }
- // GetQuotaWeeklyResetMode 获取周额度重置模式:"rolling"(默认)或 "fixed"
- func (a *Account) GetQuotaWeeklyResetMode() string {
- if m := a.getExtraString("quota_weekly_reset_mode"); m == "fixed" {
- return "fixed"
- }
- return "rolling"
- }
- // GetQuotaWeeklyResetDay 获取固定重置的星期几(0=周日, 1=周一, ..., 6=周六),默认 1(周一)
- func (a *Account) GetQuotaWeeklyResetDay() int {
- if a.Extra == nil {
- return 1
- }
- if _, ok := a.Extra["quota_weekly_reset_day"]; !ok {
- return 1
- }
- return a.getExtraInt("quota_weekly_reset_day")
- }
- // GetQuotaWeeklyResetHour 获取周配额固定重置的小时(0-23),默认 0
- func (a *Account) GetQuotaWeeklyResetHour() int {
- return a.getExtraInt("quota_weekly_reset_hour")
- }
- // GetQuotaResetTimezone 获取固定重置的时区名(IANA),默认 "UTC"
- func (a *Account) GetQuotaResetTimezone() string {
- if tz := a.getExtraString("quota_reset_timezone"); tz != "" {
- return tz
- }
- return "UTC"
- }
- // --- Quota Notification Getters ---
- // QuotaNotifyConfig returns the notify configuration for a given quota dimension.
- // dim must be one of quotaDimDaily, quotaDimWeekly, quotaDimTotal.
- func (a *Account) QuotaNotifyConfig(dim string) (enabled bool, threshold float64, thresholdType string) {
- enabled = a.getExtraBool("quota_notify_" + dim + "_enabled")
- threshold = a.getExtraFloat64("quota_notify_" + dim + "_threshold")
- thresholdType = a.getExtraStringDefault("quota_notify_"+dim+"_threshold_type", thresholdTypeFixed)
- return
- }
- func (a *Account) GetQuotaNotifyDailyEnabled() bool {
- e, _, _ := a.QuotaNotifyConfig(quotaDimDaily)
- return e
- }
- func (a *Account) GetQuotaNotifyDailyThreshold() float64 {
- _, t, _ := a.QuotaNotifyConfig(quotaDimDaily)
- return t
- }
- func (a *Account) GetQuotaNotifyDailyThresholdType() string {
- _, _, tt := a.QuotaNotifyConfig(quotaDimDaily)
- return tt
- }
- func (a *Account) GetQuotaNotifyWeeklyEnabled() bool {
- e, _, _ := a.QuotaNotifyConfig(quotaDimWeekly)
- return e
- }
- func (a *Account) GetQuotaNotifyWeeklyThreshold() float64 {
- _, t, _ := a.QuotaNotifyConfig(quotaDimWeekly)
- return t
- }
- func (a *Account) GetQuotaNotifyWeeklyThresholdType() string {
- _, _, tt := a.QuotaNotifyConfig(quotaDimWeekly)
- return tt
- }
- func (a *Account) GetQuotaNotifyTotalEnabled() bool {
- e, _, _ := a.QuotaNotifyConfig(quotaDimTotal)
- return e
- }
- func (a *Account) GetQuotaNotifyTotalThreshold() float64 {
- _, t, _ := a.QuotaNotifyConfig(quotaDimTotal)
- return t
- }
- func (a *Account) GetQuotaNotifyTotalThresholdType() string {
- _, _, tt := a.QuotaNotifyConfig(quotaDimTotal)
- return tt
- }
- // nextFixedDailyReset 计算在 after 之后的下一个每日固定重置时间点
- func nextFixedDailyReset(hour int, tz *time.Location, after time.Time) time.Time {
- t := after.In(tz)
- today := time.Date(t.Year(), t.Month(), t.Day(), hour, 0, 0, 0, tz)
- if !after.Before(today) {
- return today.AddDate(0, 0, 1)
- }
- return today
- }
- // lastFixedDailyReset 计算 now 之前最近一次的每日固定重置时间点
- func lastFixedDailyReset(hour int, tz *time.Location, now time.Time) time.Time {
- t := now.In(tz)
- today := time.Date(t.Year(), t.Month(), t.Day(), hour, 0, 0, 0, tz)
- if now.Before(today) {
- return today.AddDate(0, 0, -1)
- }
- return today
- }
- // nextFixedWeeklyReset 计算在 after 之后的下一个每周固定重置时间点
- // day: 0=Sunday, 1=Monday, ..., 6=Saturday
- func nextFixedWeeklyReset(day, hour int, tz *time.Location, after time.Time) time.Time {
- t := after.In(tz)
- todayReset := time.Date(t.Year(), t.Month(), t.Day(), hour, 0, 0, 0, tz)
- currentDay := int(todayReset.Weekday())
- daysForward := (day - currentDay + 7) % 7
- if daysForward == 0 && !after.Before(todayReset) {
- daysForward = 7
- }
- return todayReset.AddDate(0, 0, daysForward)
- }
- // lastFixedWeeklyReset 计算 now 之前最近一次的每周固定重置时间点
- func lastFixedWeeklyReset(day, hour int, tz *time.Location, now time.Time) time.Time {
- t := now.In(tz)
- todayReset := time.Date(t.Year(), t.Month(), t.Day(), hour, 0, 0, 0, tz)
- currentDay := int(todayReset.Weekday())
- daysBack := (currentDay - day + 7) % 7
- if daysBack == 0 && now.Before(todayReset) {
- daysBack = 7
- }
- return todayReset.AddDate(0, 0, -daysBack)
- }
- // isFixedDailyPeriodExpired 检查日配额是否在固定时间模式下已过期
- func (a *Account) isFixedDailyPeriodExpired(periodStart time.Time) bool {
- if periodStart.IsZero() {
- return true
- }
- tz, err := time.LoadLocation(a.GetQuotaResetTimezone())
- if err != nil {
- tz = time.UTC
- }
- lastReset := lastFixedDailyReset(a.GetQuotaDailyResetHour(), tz, time.Now())
- return periodStart.Before(lastReset)
- }
- // isFixedWeeklyPeriodExpired 检查周配额是否在固定时间模式下已过期
- func (a *Account) isFixedWeeklyPeriodExpired(periodStart time.Time) bool {
- if periodStart.IsZero() {
- return true
- }
- tz, err := time.LoadLocation(a.GetQuotaResetTimezone())
- if err != nil {
- tz = time.UTC
- }
- lastReset := lastFixedWeeklyReset(a.GetQuotaWeeklyResetDay(), a.GetQuotaWeeklyResetHour(), tz, time.Now())
- return periodStart.Before(lastReset)
- }
- // ComputeQuotaResetAt 根据当前配置计算并填充 extra 中的 quota_daily_reset_at / quota_weekly_reset_at
- // 在保存账号配置时调用
- func ComputeQuotaResetAt(extra map[string]any) {
- now := time.Now()
- tzName, _ := extra["quota_reset_timezone"].(string)
- if tzName == "" {
- tzName = "UTC"
- }
- tz, err := time.LoadLocation(tzName)
- if err != nil {
- tz = time.UTC
- }
- // 日配额固定重置时间
- if mode, _ := extra["quota_daily_reset_mode"].(string); mode == "fixed" {
- hour := int(parseExtraFloat64(extra["quota_daily_reset_hour"]))
- if hour < 0 || hour > 23 {
- hour = 0
- }
- resetAt := nextFixedDailyReset(hour, tz, now)
- extra["quota_daily_reset_at"] = resetAt.UTC().Format(time.RFC3339)
- } else {
- delete(extra, "quota_daily_reset_at")
- }
- // 周配额固定重置时间
- if mode, _ := extra["quota_weekly_reset_mode"].(string); mode == "fixed" {
- day := 1 // 默认周一
- if d, ok := extra["quota_weekly_reset_day"]; ok {
- day = int(parseExtraFloat64(d))
- }
- if day < 0 || day > 6 {
- day = 1
- }
- hour := int(parseExtraFloat64(extra["quota_weekly_reset_hour"]))
- if hour < 0 || hour > 23 {
- hour = 0
- }
- resetAt := nextFixedWeeklyReset(day, hour, tz, now)
- extra["quota_weekly_reset_at"] = resetAt.UTC().Format(time.RFC3339)
- } else {
- delete(extra, "quota_weekly_reset_at")
- }
- }
- // NormalizeFixedQuotaWindows aligns preserved quota usage with the active fixed reset window.
- //
- // Editing an existing account can switch a daily/weekly quota from rolling to fixed reset
- // while preserving quota_*_used and quota_*_start. If the preserved start belongs to the
- // old rolling window, response mapping treats the usage as expired and the dashboard shows
- // 0 until the next reset. Normalize those stale starts before persisting the edited account.
- func NormalizeFixedQuotaWindows(extra map[string]any) {
- if extra == nil {
- return
- }
- now := time.Now()
- tzName, _ := extra["quota_reset_timezone"].(string)
- if tzName == "" {
- tzName = "UTC"
- }
- tz, err := time.LoadLocation(tzName)
- if err != nil {
- tz = time.UTC
- }
- if mode, _ := extra["quota_daily_reset_mode"].(string); mode == "fixed" && parseExtraFloat64(extra["quota_daily_limit"]) > 0 {
- hour := int(parseExtraFloat64(extra["quota_daily_reset_hour"]))
- if hour < 0 || hour > 23 {
- hour = 0
- }
- lastReset := lastFixedDailyReset(hour, tz, now)
- start := parseExtraTime(extra["quota_daily_start"])
- if start.IsZero() || start.Before(lastReset) {
- extra["quota_daily_used"] = 0.0
- extra["quota_daily_start"] = lastReset.UTC().Format(time.RFC3339)
- }
- }
- if mode, _ := extra["quota_weekly_reset_mode"].(string); mode == "fixed" && parseExtraFloat64(extra["quota_weekly_limit"]) > 0 {
- day := 1
- if rawDay, ok := extra["quota_weekly_reset_day"]; ok {
- day = int(parseExtraFloat64(rawDay))
- }
- if day < 0 || day > 6 {
- day = 1
- }
- hour := int(parseExtraFloat64(extra["quota_weekly_reset_hour"]))
- if hour < 0 || hour > 23 {
- hour = 0
- }
- lastReset := lastFixedWeeklyReset(day, hour, tz, now)
- start := parseExtraTime(extra["quota_weekly_start"])
- if start.IsZero() || start.Before(lastReset) {
- extra["quota_weekly_used"] = 0.0
- extra["quota_weekly_start"] = lastReset.UTC().Format(time.RFC3339)
- }
- }
- }
- // ValidateQuotaResetConfig 校验配额固定重置时间配置的合法性
- func ValidateQuotaResetConfig(extra map[string]any) error {
- if extra == nil {
- return nil
- }
- // 校验时区
- if tz, ok := extra["quota_reset_timezone"].(string); ok && tz != "" {
- if _, err := time.LoadLocation(tz); err != nil {
- return errors.New("invalid quota_reset_timezone: must be a valid IANA timezone name")
- }
- }
- // 日配额重置模式
- if mode, ok := extra["quota_daily_reset_mode"].(string); ok {
- if mode != "rolling" && mode != "fixed" {
- return errors.New("quota_daily_reset_mode must be 'rolling' or 'fixed'")
- }
- }
- // 日配额重置小时
- if v, ok := extra["quota_daily_reset_hour"]; ok {
- hour := int(parseExtraFloat64(v))
- if hour < 0 || hour > 23 {
- return errors.New("quota_daily_reset_hour must be between 0 and 23")
- }
- }
- // 周配额重置模式
- if mode, ok := extra["quota_weekly_reset_mode"].(string); ok {
- if mode != "rolling" && mode != "fixed" {
- return errors.New("quota_weekly_reset_mode must be 'rolling' or 'fixed'")
- }
- }
- // 周配额重置星期几
- if v, ok := extra["quota_weekly_reset_day"]; ok {
- day := int(parseExtraFloat64(v))
- if day < 0 || day > 6 {
- return errors.New("quota_weekly_reset_day must be between 0 (Sunday) and 6 (Saturday)")
- }
- }
- // 周配额重置小时
- if v, ok := extra["quota_weekly_reset_hour"]; ok {
- hour := int(parseExtraFloat64(v))
- if hour < 0 || hour > 23 {
- return errors.New("quota_weekly_reset_hour must be between 0 and 23")
- }
- }
- return nil
- }
- // HasAnyQuotaLimit 检查是否配置了任一维度的配额限制
- func (a *Account) HasAnyQuotaLimit() bool {
- return a.GetQuotaLimit() > 0 || a.GetQuotaDailyLimit() > 0 || a.GetQuotaWeeklyLimit() > 0
- }
- // isPeriodExpired 检查指定周期(自 periodStart 起经过 dur)是否已过期
- func isPeriodExpired(periodStart time.Time, dur time.Duration) bool {
- if periodStart.IsZero() {
- return true // 从未使用过,视为过期(下次 increment 会初始化)
- }
- return time.Since(periodStart) >= dur
- }
- // IsDailyQuotaPeriodExpired 检查日配额周期是否已过期(用于显示层判断是否需要将 used 归零)
- func (a *Account) IsDailyQuotaPeriodExpired() bool {
- start := a.getExtraTime("quota_daily_start")
- if a.GetQuotaDailyResetMode() == "fixed" {
- return a.isFixedDailyPeriodExpired(start)
- }
- return isPeriodExpired(start, 24*time.Hour)
- }
- // IsWeeklyQuotaPeriodExpired 检查周配额周期是否已过期(用于显示层判断是否需要将 used 归零)
- func (a *Account) IsWeeklyQuotaPeriodExpired() bool {
- start := a.getExtraTime("quota_weekly_start")
- if a.GetQuotaWeeklyResetMode() == "fixed" {
- return a.isFixedWeeklyPeriodExpired(start)
- }
- return isPeriodExpired(start, 7*24*time.Hour)
- }
- // IsQuotaExceeded 检查 API Key 账号配额是否已超限(任一维度超限即返回 true)
- func (a *Account) IsQuotaExceeded() bool {
- // 总额度
- if limit := a.GetQuotaLimit(); limit > 0 && a.GetQuotaUsed() >= limit {
- return true
- }
- // 日额度(周期过期视为未超限,下次 increment 会重置)
- if limit := a.GetQuotaDailyLimit(); limit > 0 {
- start := a.getExtraTime("quota_daily_start")
- var expired bool
- if a.GetQuotaDailyResetMode() == "fixed" {
- expired = a.isFixedDailyPeriodExpired(start)
- } else {
- expired = isPeriodExpired(start, 24*time.Hour)
- }
- if !expired && a.GetQuotaDailyUsed() >= limit {
- return true
- }
- }
- // 周额度
- if limit := a.GetQuotaWeeklyLimit(); limit > 0 {
- start := a.getExtraTime("quota_weekly_start")
- var expired bool
- if a.GetQuotaWeeklyResetMode() == "fixed" {
- expired = a.isFixedWeeklyPeriodExpired(start)
- } else {
- expired = isPeriodExpired(start, 7*24*time.Hour)
- }
- if !expired && a.GetQuotaWeeklyUsed() >= limit {
- return true
- }
- }
- return false
- }
- // GetWindowCostLimit 获取 5h 窗口费用阈值(美元)
- // 返回 0 表示未启用
- func (a *Account) GetWindowCostLimit() float64 {
- if a.Extra == nil {
- return 0
- }
- if v, ok := a.Extra["window_cost_limit"]; ok {
- return parseExtraFloat64(v)
- }
- return 0
- }
- // GetWindowCostStickyReserve 获取粘性会话预留额度(美元)
- // 默认值为 10
- func (a *Account) GetWindowCostStickyReserve() float64 {
- if a.Extra == nil {
- return 10.0
- }
- if v, ok := a.Extra["window_cost_sticky_reserve"]; ok {
- val := parseExtraFloat64(v)
- if val > 0 {
- return val
- }
- }
- return 10.0
- }
- // GetMaxSessions 获取最大并发会话数
- // 返回 0 表示未启用
- func (a *Account) GetMaxSessions() int {
- if a.Extra == nil {
- return 0
- }
- if v, ok := a.Extra["max_sessions"]; ok {
- return parseExtraInt(v)
- }
- return 0
- }
- // GetSessionIdleTimeoutMinutes 获取会话空闲超时分钟数
- // 默认值为 5 分钟
- func (a *Account) GetSessionIdleTimeoutMinutes() int {
- if a.Extra == nil {
- return 5
- }
- if v, ok := a.Extra["session_idle_timeout_minutes"]; ok {
- val := parseExtraInt(v)
- if val > 0 {
- return val
- }
- }
- return 5
- }
- // GetBaseRPM 获取基础 RPM 限制
- // 返回 0 表示未启用(负数视为无效配置,按 0 处理)
- func (a *Account) GetBaseRPM() int {
- if a.Extra == nil {
- return 0
- }
- if v, ok := a.Extra["base_rpm"]; ok {
- val := parseExtraInt(v)
- if val > 0 {
- return val
- }
- }
- return 0
- }
- // GetRPMStrategy 获取 RPM 策略
- // "tiered" = 三区模型(默认), "sticky_exempt" = 粘性豁免
- func (a *Account) GetRPMStrategy() string {
- if a.Extra == nil {
- return "tiered"
- }
- if v, ok := a.Extra["rpm_strategy"]; ok {
- if s, ok := v.(string); ok && s == "sticky_exempt" {
- return "sticky_exempt"
- }
- }
- return "tiered"
- }
- // GetRPMStickyBuffer 获取 RPM 粘性缓冲数量
- // Cache-driven: buffer = concurrency + maxSessions(覆盖幽灵窗口 + 稳态会话需求)
- // floor = baseRPM / 5(向后兼容 maxSessions=0 且 concurrency=0 场景)
- func (a *Account) GetRPMStickyBuffer() int {
- if a.Extra == nil {
- return 0
- }
- // 手动 override 最高优先级
- if v, ok := a.Extra["rpm_sticky_buffer"]; ok {
- val := parseExtraInt(v)
- if val > 0 {
- return val
- }
- }
- base := a.GetBaseRPM()
- if base <= 0 {
- return 0
- }
- // Cache-driven buffer = concurrency + maxSessions
- conc := a.Concurrency
- if conc < 0 {
- conc = 0
- }
- sess := a.GetMaxSessions()
- if sess < 0 {
- sess = 0
- }
- buffer := conc + sess
- // floor: 向后兼容
- floor := base / 5
- if floor < 1 {
- floor = 1
- }
- if buffer < floor {
- buffer = floor
- }
- return buffer
- }
- // CheckRPMSchedulability 根据当前 RPM 计数检查调度状态
- // 复用 WindowCostSchedulability 三态:Schedulable / StickyOnly / NotSchedulable
- func (a *Account) CheckRPMSchedulability(currentRPM int) WindowCostSchedulability {
- baseRPM := a.GetBaseRPM()
- if baseRPM <= 0 {
- return WindowCostSchedulable
- }
- if currentRPM < baseRPM {
- return WindowCostSchedulable
- }
- strategy := a.GetRPMStrategy()
- if strategy == "sticky_exempt" {
- return WindowCostStickyOnly // 粘性豁免无红区
- }
- // tiered: 黄区 + 红区
- buffer := a.GetRPMStickyBuffer()
- if currentRPM < baseRPM+buffer {
- return WindowCostStickyOnly
- }
- return WindowCostNotSchedulable
- }
- // CheckWindowCostSchedulability 根据当前窗口费用检查调度状态
- // - 费用 < 阈值: WindowCostSchedulable(可正常调度)
- // - 费用 >= 阈值 且 < 阈值+预留: WindowCostStickyOnly(仅粘性会话)
- // - 费用 >= 阈值+预留: WindowCostNotSchedulable(不可调度)
- func (a *Account) CheckWindowCostSchedulability(currentWindowCost float64) WindowCostSchedulability {
- limit := a.GetWindowCostLimit()
- if limit <= 0 {
- return WindowCostSchedulable
- }
- if currentWindowCost < limit {
- return WindowCostSchedulable
- }
- stickyReserve := a.GetWindowCostStickyReserve()
- if currentWindowCost < limit+stickyReserve {
- return WindowCostStickyOnly
- }
- return WindowCostNotSchedulable
- }
- // GetCurrentWindowStartTime 获取当前有效的窗口开始时间
- // 逻辑:
- // 1. 如果窗口未过期(SessionWindowEnd 存在且在当前时间之后),使用记录的 SessionWindowStart
- // 2. 否则(窗口过期或未设置),使用新的预测窗口开始时间(从当前整点开始)
- func (a *Account) GetCurrentWindowStartTime() time.Time {
- now := time.Now()
- // 窗口未过期,使用记录的窗口开始时间
- if a.SessionWindowStart != nil && a.SessionWindowEnd != nil && now.Before(*a.SessionWindowEnd) {
- return *a.SessionWindowStart
- }
- // 窗口已过期或未设置,预测新的窗口开始时间(从当前整点开始)
- // 与 ratelimit_service.go 中 UpdateSessionWindow 的预测逻辑保持一致
- return time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
- }
- // parseExtraFloat64 从 extra 字段解析 float64 值
- func parseExtraFloat64(value any) float64 {
- switch v := value.(type) {
- case float64:
- return v
- case float32:
- return float64(v)
- case int:
- return float64(v)
- case int64:
- return float64(v)
- case json.Number:
- if f, err := v.Float64(); err == nil {
- return f
- }
- case string:
- if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
- return f
- }
- }
- return 0
- }
- func parseExtraTime(value any) time.Time {
- if s, ok := value.(string); ok {
- if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
- return t
- }
- if t, err := time.Parse(time.RFC3339, s); err == nil {
- return t
- }
- }
- return time.Time{}
- }
- // parseExtraInt 从 extra 字段解析 int 值
- // ParseExtraInt 从 extra 字段的 any 值解析为 int。
- // 支持 int, int64, float64, json.Number, string 类型,无法解析时返回 0。
- func ParseExtraInt(value any) int {
- return parseExtraInt(value)
- }
- func parseExtraInt(value any) int {
- switch v := value.(type) {
- case int:
- return v
- case int64:
- return int(v)
- case float64:
- return int(v)
- case json.Number:
- if i, err := v.Int64(); err == nil {
- return int(i)
- }
- case string:
- if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
- return i
- }
- }
- return 0
- }
- // IsShadow 报告账号是否为影子账号(parent_account_id 非空;当前唯一预设是 spark 维度)。
- func (a *Account) IsShadow() bool { return a != nil && a.ParentAccountID != nil }
- // IsCredentialShadow 语义别名,供「凭据消费者跳过影子」处使用(管理/后台 OAuth 路径)。
- func (a *Account) IsCredentialShadow() bool { return a.IsShadow() }
- // QuotaDimensionOrDefault 返回账号的用量维度,未设置时回退 "global"。
- func (a *Account) QuotaDimensionOrDefault() string {
- if a == nil || strings.TrimSpace(a.QuotaDimension) == "" {
- return QuotaDimensionGlobal
- }
- return a.QuotaDimension
- }
|