account.go 94 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273
  1. // Package service provides business logic and domain services for the application.
  2. package service
  3. import (
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "hash/fnv"
  8. "log/slog"
  9. "net/url"
  10. "reflect"
  11. "sort"
  12. "strconv"
  13. "strings"
  14. "time"
  15. "github.com/Wei-Shaw/sub2api/internal/config"
  16. "github.com/Wei-Shaw/sub2api/internal/domain"
  17. "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
  18. "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
  19. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  20. )
  21. type Account struct {
  22. ID int64
  23. Name string
  24. Notes *string
  25. Platform string
  26. Type string
  27. Credentials map[string]any
  28. Extra map[string]any
  29. ProxyID *int64
  30. ProxyFallbackOriginID *int64
  31. ProxyFallbackOriginName *string // 仅展示用
  32. Concurrency int
  33. Priority int
  34. // RateMultiplier 账号计费倍率(>=0,允许 0 表示该账号计费为 0)。
  35. // 使用指针用于兼容旧版本调度缓存(Redis)中缺字段的情况:nil 表示按 1.0 处理。
  36. RateMultiplier *float64
  37. LoadFactor *int // 调度负载因子;nil 表示使用 Concurrency
  38. Status string
  39. ErrorMessage string
  40. LastUsedAt *time.Time
  41. ExpiresAt *time.Time
  42. AutoPauseOnExpired bool
  43. CreatedAt time.Time
  44. UpdatedAt time.Time
  45. Schedulable bool
  46. RateLimitedAt *time.Time
  47. RateLimitResetAt *time.Time
  48. OverloadUntil *time.Time
  49. TempUnschedulableUntil *time.Time
  50. TempUnschedulableReason string
  51. SessionWindowStart *time.Time
  52. SessionWindowEnd *time.Time
  53. SessionWindowStatus string
  54. ParentAccountID *int64 // non-nil → 影子账号(不持凭据,透传母账号凭据)
  55. QuotaDimension string // 用量维度:"" / "global" / "spark"
  56. Proxy *Proxy
  57. AccountGroups []AccountGroup
  58. GroupIDs []int64
  59. Groups []*Group
  60. // model_mapping 热路径缓存(非持久化字段)
  61. modelMappingCache map[string]string
  62. modelMappingCacheReady bool
  63. modelMappingCacheCredentialsPtr uintptr
  64. modelMappingCacheRawPtr uintptr
  65. modelMappingCacheRawLen int
  66. modelMappingCacheRawSig uint64
  67. modelMappingCacheRuntimeVersion uint64
  68. // header_overrides 热路径缓存(非持久化字段,同 model_mapping 缓存先例)
  69. headerOverrideCache map[string]string
  70. headerOverrideCacheReady bool
  71. headerOverrideCacheCredentialsPtr uintptr
  72. headerOverrideCacheRawPtr uintptr
  73. headerOverrideCacheRawLen int
  74. headerOverrideCacheRawSig uint64
  75. }
  76. // ProxyModeExtraKey stores the account proxy selection mode in the existing
  77. // JSONB extra column. Keeping this in extra preserves compatibility with
  78. // existing installations without requiring an account table migration.
  79. const ProxyModeExtraKey = "proxy_mode"
  80. const ProxyModeRandom = "random"
  81. // NormalizeProxyModeExtra enforces the proxy_mode whitelist on an account
  82. // extra map: "random" (case/whitespace-insensitive) is canonicalized to
  83. // ProxyModeRandom, any other value is dropped so it can never persist.
  84. // A nil map is returned as-is. The same whitelist is enforced at the DB
  85. // layer by chk_accounts_extra_proxy_mode; this keeps the write path and the
  86. // constraint from drifting apart.
  87. func NormalizeProxyModeExtra(extra map[string]any) map[string]any {
  88. if extra == nil {
  89. return nil
  90. }
  91. raw, ok := extra[ProxyModeExtraKey]
  92. if !ok {
  93. return extra
  94. }
  95. if mode, ok := raw.(string); ok && strings.EqualFold(strings.TrimSpace(mode), ProxyModeRandom) {
  96. extra[ProxyModeExtraKey] = ProxyModeRandom
  97. return extra
  98. }
  99. delete(extra, ProxyModeExtraKey)
  100. return extra
  101. }
  102. // IsRandomProxy reports whether the account should use a randomly selected
  103. // active proxy for each newly selected request. The value is deliberately
  104. // strict: malformed user data never enables an unexpected mode.
  105. func (a *Account) IsRandomProxy() bool {
  106. if a == nil || a.Extra == nil {
  107. return false
  108. }
  109. mode, ok := a.Extra[ProxyModeExtraKey].(string)
  110. return ok && strings.EqualFold(strings.TrimSpace(mode), ProxyModeRandom)
  111. }
  112. // RandomProxySelector is implemented by repositories that can choose one
  113. // currently active, non-expired proxy. It is optional so narrow unit-test
  114. // repositories remain source-compatible.
  115. type RandomProxySelector interface {
  116. SelectRandomActiveProxy(ctx context.Context) (*Proxy, error)
  117. }
  118. type OpenAIEndpointCapability string
  119. const openAILongContextBillingEnabledKey = "openai_long_context_billing_enabled"
  120. const (
  121. OpenAIEndpointCapabilityChatCompletions OpenAIEndpointCapability = "chat_completions"
  122. OpenAIEndpointCapabilityEmbeddings OpenAIEndpointCapability = "embeddings"
  123. OpenAIEndpointCapabilityAlphaSearch OpenAIEndpointCapability = "alpha_search"
  124. OpenAIEndpointCapabilityLive OpenAIEndpointCapability = "live"
  125. // OpenAIEndpointCapabilityGrokMediaGeneration keeps image/video generation
  126. // away from Grok accounts that are explicitly disabled or whose billing
  127. // entitlement probe was forbidden. Video status lookups intentionally do not
  128. // require this capability so already-submitted requests remain queryable.
  129. OpenAIEndpointCapabilityGrokMediaGeneration OpenAIEndpointCapability = "grok_media_generation"
  130. // OpenAIEndpointCapabilityResponses 表示上游确实提供 /v1/responses 端点。
  131. // 与其他能力不同:支持状态来自 accounts.extra 的自动探测标记
  132. // (openai_responses_supported / openai_responses_mode),而非
  133. // credentials["openai_capabilities"] 配置集。仅用于生图意图的 /v1/responses
  134. // 调度,避免把请求调度到会在 forward 阶段被降级为 Chat Completions 的账号(#4417)。
  135. OpenAIEndpointCapabilityResponses OpenAIEndpointCapability = "responses"
  136. )
  137. const openAIEndpointCapabilitiesCredentialKey = "openai_capabilities"
  138. // GrokMediaEligibleExtraKey is an optional per-account override stored in
  139. // accounts.extra. true forces media routing on, false disables it, and an
  140. // absent/null value uses provider observations.
  141. const GrokMediaEligibleExtraKey = "grok_media_eligible"
  142. const (
  143. OpenAIAuthModePersonalAccessToken = "personalAccessToken"
  144. openAIAuthModeCredentialKey = "auth_mode"
  145. openAIAuthModeLegacyCredentialKey = "openai_auth_mode"
  146. )
  147. func isOpenAIPersonalAccessTokenAuthMode(value string) bool {
  148. switch strings.ToLower(strings.TrimSpace(value)) {
  149. case "personalaccesstoken", "personal_access_token":
  150. return true
  151. default:
  152. return false
  153. }
  154. }
  155. type TempUnschedulableRule struct {
  156. ErrorCode int `json:"error_code"`
  157. Keywords []string `json:"keywords"`
  158. DurationMinutes int `json:"duration_minutes"`
  159. Description string `json:"description"`
  160. }
  161. func (a *Account) IsActive() bool {
  162. return a.Status == StatusActive
  163. }
  164. // IsSyntheticUITest reports whether the account belongs to an isolated UI load-test
  165. // dataset. Production accounts never receive this marker. It lets the dedicated
  166. // test instance exercise interactive quota and connection-test controls without
  167. // sending fake credentials to an upstream provider.
  168. func (a *Account) IsSyntheticUITest() bool {
  169. if a == nil || a.Extra == nil {
  170. return false
  171. }
  172. enabled, ok := a.Extra["synthetic_ui_test"].(bool)
  173. return ok && enabled
  174. }
  175. // BillingRateMultiplier 返回账号计费倍率。
  176. // - nil 表示未配置/旧缓存缺字段,按 1.0 处理
  177. // - 允许 0,表示该账号计费为 0
  178. // - 负数属于非法数据,出于安全考虑按 1.0 处理
  179. func (a *Account) BillingRateMultiplier() float64 {
  180. if a == nil || a.RateMultiplier == nil {
  181. return 1.0
  182. }
  183. if *a.RateMultiplier < 0 {
  184. return 1.0
  185. }
  186. return *a.RateMultiplier
  187. }
  188. func (a *Account) EffectiveLoadFactor() int {
  189. if a == nil {
  190. return 1
  191. }
  192. if a.LoadFactor != nil && *a.LoadFactor > 0 {
  193. return *a.LoadFactor
  194. }
  195. if a.Concurrency > 0 {
  196. return a.Concurrency
  197. }
  198. return 1
  199. }
  200. func (a *Account) IsSchedulable() bool {
  201. if !a.IsActive() || !a.Schedulable {
  202. return false
  203. }
  204. now := time.Now()
  205. if a.AutoPauseOnExpired && a.ExpiresAt != nil && !now.Before(*a.ExpiresAt) {
  206. return false
  207. }
  208. if a.OverloadUntil != nil && now.Before(*a.OverloadUntil) {
  209. return false
  210. }
  211. if a.RateLimitResetAt != nil && now.Before(*a.RateLimitResetAt) {
  212. return false
  213. }
  214. if a.TempUnschedulableUntil != nil && now.Before(*a.TempUnschedulableUntil) {
  215. return false
  216. }
  217. if a.IsAPIKeyOrBedrock() && a.IsQuotaExceeded() {
  218. return false
  219. }
  220. return true
  221. }
  222. // IsCredentialUsableForShadow 报告本账号(作为某 spark 影子的母账号)的凭据/传输是否可被影子透传使用。
  223. //
  224. // 检查「凭据/账号/传输可用性」:
  225. // - 账号 active(非禁用/删除);
  226. // - OAuth token 未过期(AutoPauseOnExpired+ExpiresAt);
  227. // - 未处于 TempUnschedulableUntil 冷却期 —— 对 OpenAI 账号该字段由 401 鉴权失败 /
  228. // token 刷新耗尽 / transport·proxy 故障写入(ratelimit/token_refresh/upstream_transport),
  229. // 都代表**共享凭据或传输通道坏死**;影子共享母 token+proxy,故母处于该冷却期时影子也不可用。
  230. //
  231. // **刻意排除** global 维度的限流/过载窗口(RateLimitResetAt / OverloadUntil)与母账号自身的
  232. // 手动 Schedulable 开关:spark 影子拥有独立 spark 配额窗口,母账号 global 429(走 RateLimitResetAt)
  233. // 不应连坐 spark(否则重新耦合影子架构本应解耦的两条 429 道)。nil receiver 返回 false。
  234. func (a *Account) IsCredentialUsableForShadow() bool {
  235. if a == nil || !a.IsActive() {
  236. return false
  237. }
  238. now := time.Now()
  239. if a.AutoPauseOnExpired && a.ExpiresAt != nil && !now.Before(*a.ExpiresAt) {
  240. return false
  241. }
  242. if a.TempUnschedulableUntil != nil && now.Before(*a.TempUnschedulableUntil) {
  243. return false
  244. }
  245. return true
  246. }
  247. func (a *Account) IsRateLimited() bool {
  248. if a.RateLimitResetAt == nil {
  249. return false
  250. }
  251. return time.Now().Before(*a.RateLimitResetAt)
  252. }
  253. func (a *Account) IsOverloaded() bool {
  254. if a.OverloadUntil == nil {
  255. return false
  256. }
  257. return time.Now().Before(*a.OverloadUntil)
  258. }
  259. func (a *Account) IsOAuth() bool {
  260. return a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken
  261. }
  262. // IsPrivacySet 检查账号的 privacy 是否已成功设置。
  263. // OpenAI: privacy_mode == "training_off"
  264. // Antigravity: privacy_mode == "privacy_set"
  265. // 其他平台: 无 privacy 概念,始终返回 true
  266. func (a *Account) IsPrivacySet() bool {
  267. switch a.Platform {
  268. case PlatformOpenAI:
  269. return a.getExtraString("privacy_mode") == PrivacyModeTrainingOff
  270. case PlatformAntigravity:
  271. return a.getExtraString("privacy_mode") == AntigravityPrivacySet
  272. default:
  273. return true
  274. }
  275. }
  276. func (a *Account) IsGemini() bool {
  277. return a.Platform == PlatformGemini
  278. }
  279. func (a *Account) IsGrok() bool {
  280. return a.Platform == PlatformGrok
  281. }
  282. func (a *Account) IsGrokOAuth() bool {
  283. return a.IsGrok() && a.Type == AccountTypeOAuth
  284. }
  285. // IsKimi / IsZhipu / IsDeepseek 标识国产 OpenAI 兼容供应商账号。
  286. func (a *Account) IsKimi() bool {
  287. return a.Platform == PlatformKimi
  288. }
  289. func (a *Account) IsZhipu() bool {
  290. return a.Platform == PlatformZhipu
  291. }
  292. func (a *Account) IsDeepseek() bool {
  293. return a.Platform == PlatformDeepseek
  294. }
  295. // IsCNProvider 报告是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)。
  296. func (a *Account) IsCNProvider() bool {
  297. return a != nil && IsCNProvider(a.Platform)
  298. }
  299. // IsOpenAICompatible 报告账号是否走 OpenAI 网关(OpenAI 协议族)。
  300. // openai/grok 原生走 OpenAI 网关;kimi/zhipu/deepseek 同为 OpenAI Chat Completions
  301. // 兼容上游,也经 OpenAI 网关转发。
  302. func (a *Account) IsOpenAICompatible() bool {
  303. return a != nil && (a.Platform == PlatformOpenAI || a.Platform == PlatformGrok || a.IsCNProvider())
  304. }
  305. func (a *Account) GeminiOAuthType() string {
  306. if a.Platform != PlatformGemini || a.Type != AccountTypeOAuth {
  307. return ""
  308. }
  309. oauthType := strings.TrimSpace(a.GetCredential("oauth_type"))
  310. if oauthType == "" && strings.TrimSpace(a.GetCredential("project_id")) != "" {
  311. return "code_assist"
  312. }
  313. return oauthType
  314. }
  315. func (a *Account) GeminiTierID() string {
  316. tierID := strings.TrimSpace(a.GetCredential("tier_id"))
  317. return tierID
  318. }
  319. func (a *Account) IsGeminiCodeAssist() bool {
  320. if a.Platform != PlatformGemini || a.Type != AccountTypeOAuth {
  321. return false
  322. }
  323. oauthType := a.GeminiOAuthType()
  324. if oauthType == "" {
  325. return strings.TrimSpace(a.GetCredential("project_id")) != ""
  326. }
  327. return oauthType == "code_assist"
  328. }
  329. // IsGeminiGoogleOne reports whether this account uses the legacy consumer
  330. // Gemini CLI / Code Assist OAuth channel.
  331. func (a *Account) IsGeminiGoogleOne() bool {
  332. return a.Platform == PlatformGemini && a.Type == AccountTypeOAuth && a.GeminiOAuthType() == "google_one"
  333. }
  334. func (a *Account) CanGetUsage() bool {
  335. return a.Type == AccountTypeOAuth
  336. }
  337. func (a *Account) GetCredential(key string) string {
  338. if a.Credentials == nil {
  339. return ""
  340. }
  341. v, ok := a.Credentials[key]
  342. if !ok || v == nil {
  343. return ""
  344. }
  345. // 支持多种类型(兼容历史数据中 expires_at 等字段可能是数字或字符串)
  346. switch val := v.(type) {
  347. case string:
  348. return val
  349. case json.Number:
  350. // GORM datatypes.JSONMap 使用 UseNumber() 解析,数字类型为 json.Number
  351. return val.String()
  352. case float64:
  353. // JSON 解析后数字默认为 float64
  354. return strconv.FormatInt(int64(val), 10)
  355. case int64:
  356. return strconv.FormatInt(val, 10)
  357. case int:
  358. return strconv.Itoa(val)
  359. default:
  360. return ""
  361. }
  362. }
  363. // GetCredentialAsTime 解析凭证中的时间戳字段,支持多种格式
  364. // 兼容以下格式:
  365. // - RFC3339 字符串: "2025-01-01T00:00:00Z"
  366. // - Unix 时间戳字符串: "1735689600"
  367. // - Unix 时间戳数字: 1735689600 (float64/int64/json.Number)
  368. func (a *Account) GetCredentialAsTime(key string) *time.Time {
  369. s := a.GetCredential(key)
  370. if s == "" {
  371. return nil
  372. }
  373. // 尝试 RFC3339 格式
  374. if t, err := time.Parse(time.RFC3339, s); err == nil {
  375. return &t
  376. }
  377. // 尝试 Unix 时间戳(纯数字字符串)
  378. if ts, err := strconv.ParseInt(s, 10, 64); err == nil {
  379. t := time.Unix(ts, 0)
  380. return &t
  381. }
  382. return nil
  383. }
  384. // GetCredentialAsInt64 解析凭证中的 int64 字段
  385. // 用于读取 _token_version 等内部字段
  386. func (a *Account) GetCredentialAsInt64(key string) int64 {
  387. if a == nil || a.Credentials == nil {
  388. return 0
  389. }
  390. val, ok := a.Credentials[key]
  391. if !ok || val == nil {
  392. return 0
  393. }
  394. switch v := val.(type) {
  395. case int64:
  396. return v
  397. case float64:
  398. return int64(v)
  399. case int:
  400. return int64(v)
  401. case json.Number:
  402. if i, err := v.Int64(); err == nil {
  403. return i
  404. }
  405. case string:
  406. if i, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil {
  407. return i
  408. }
  409. }
  410. return 0
  411. }
  412. func (a *Account) IsTempUnschedulableEnabled() bool {
  413. if a.Credentials == nil {
  414. return false
  415. }
  416. raw, ok := a.Credentials["temp_unschedulable_enabled"]
  417. if !ok || raw == nil {
  418. return false
  419. }
  420. enabled, ok := raw.(bool)
  421. return ok && enabled
  422. }
  423. func (a *Account) GetTempUnschedulableRules() []TempUnschedulableRule {
  424. if a.Credentials == nil {
  425. return nil
  426. }
  427. raw, ok := a.Credentials["temp_unschedulable_rules"]
  428. if !ok || raw == nil {
  429. return nil
  430. }
  431. arr, ok := raw.([]any)
  432. if !ok {
  433. return nil
  434. }
  435. rules := make([]TempUnschedulableRule, 0, len(arr))
  436. for _, item := range arr {
  437. entry, ok := item.(map[string]any)
  438. if !ok || entry == nil {
  439. continue
  440. }
  441. rule := TempUnschedulableRule{
  442. ErrorCode: parseTempUnschedInt(entry["error_code"]),
  443. Keywords: parseTempUnschedStrings(entry["keywords"]),
  444. DurationMinutes: parseTempUnschedInt(entry["duration_minutes"]),
  445. Description: parseTempUnschedString(entry["description"]),
  446. }
  447. if rule.ErrorCode <= 0 || rule.DurationMinutes <= 0 || len(rule.Keywords) == 0 {
  448. continue
  449. }
  450. rules = append(rules, rule)
  451. }
  452. return rules
  453. }
  454. func parseTempUnschedString(value any) string {
  455. s, ok := value.(string)
  456. if !ok {
  457. return ""
  458. }
  459. return strings.TrimSpace(s)
  460. }
  461. func parseTempUnschedStrings(value any) []string {
  462. if value == nil {
  463. return nil
  464. }
  465. var raw []string
  466. switch v := value.(type) {
  467. case []string:
  468. raw = v
  469. case []any:
  470. raw = make([]string, 0, len(v))
  471. for _, item := range v {
  472. if s, ok := item.(string); ok {
  473. raw = append(raw, s)
  474. }
  475. }
  476. default:
  477. return nil
  478. }
  479. out := make([]string, 0, len(raw))
  480. for _, item := range raw {
  481. s := strings.TrimSpace(item)
  482. if s != "" {
  483. out = append(out, s)
  484. }
  485. }
  486. return out
  487. }
  488. func normalizeAccountNotes(value *string) *string {
  489. if value == nil {
  490. return nil
  491. }
  492. trimmed := strings.TrimSpace(*value)
  493. if trimmed == "" {
  494. return nil
  495. }
  496. return &trimmed
  497. }
  498. func parseTempUnschedInt(value any) int {
  499. switch v := value.(type) {
  500. case int:
  501. return v
  502. case int64:
  503. return int(v)
  504. case float64:
  505. return int(v)
  506. case json.Number:
  507. if i, err := v.Int64(); err == nil {
  508. return int(i)
  509. }
  510. case string:
  511. if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
  512. return i
  513. }
  514. }
  515. return 0
  516. }
  517. const (
  518. // OpenAICompactModeAuto follows compact-probe results when deciding compact eligibility.
  519. OpenAICompactModeAuto = "auto"
  520. // OpenAICompactModeForceOn always treats the account as compact-supported.
  521. OpenAICompactModeForceOn = "force_on"
  522. // OpenAICompactModeForceOff always treats the account as compact-unsupported.
  523. OpenAICompactModeForceOff = "force_off"
  524. )
  525. func normalizeOpenAICompactMode(mode string) string {
  526. switch strings.ToLower(strings.TrimSpace(mode)) {
  527. case OpenAICompactModeForceOn:
  528. return OpenAICompactModeForceOn
  529. case OpenAICompactModeForceOff:
  530. return OpenAICompactModeForceOff
  531. default:
  532. return OpenAICompactModeAuto
  533. }
  534. }
  535. func stringMappingFromRaw(raw any) map[string]string {
  536. switch mapping := raw.(type) {
  537. case map[string]any:
  538. if len(mapping) == 0 {
  539. return nil
  540. }
  541. result := make(map[string]string, len(mapping))
  542. for key, value := range mapping {
  543. if str, ok := value.(string); ok {
  544. result[key] = str
  545. }
  546. }
  547. if len(result) == 0 {
  548. return nil
  549. }
  550. return result
  551. case map[string]string:
  552. if len(mapping) == 0 {
  553. return nil
  554. }
  555. result := make(map[string]string, len(mapping))
  556. for key, value := range mapping {
  557. result[key] = value
  558. }
  559. return result
  560. default:
  561. return nil
  562. }
  563. }
  564. func (a *Account) GetModelMapping() map[string]string {
  565. runtimeVersion := xai.RuntimeModelMappingVersion()
  566. credentialsPtr := mapPtr(a.Credentials)
  567. rawMapping, _ := a.Credentials["model_mapping"].(map[string]any)
  568. rawPtr := mapPtr(rawMapping)
  569. rawLen := len(rawMapping)
  570. rawSig := uint64(0)
  571. rawSigReady := false
  572. if a.modelMappingCacheReady &&
  573. a.modelMappingCacheCredentialsPtr == credentialsPtr &&
  574. a.modelMappingCacheRawPtr == rawPtr &&
  575. a.modelMappingCacheRawLen == rawLen &&
  576. a.modelMappingCacheRuntimeVersion == runtimeVersion {
  577. rawSig = modelMappingSignature(rawMapping)
  578. rawSigReady = true
  579. if a.modelMappingCacheRawSig == rawSig {
  580. return a.modelMappingCache
  581. }
  582. }
  583. mapping := a.resolveModelMapping(rawMapping)
  584. if !rawSigReady {
  585. rawSig = modelMappingSignature(rawMapping)
  586. }
  587. a.modelMappingCache = mapping
  588. a.modelMappingCacheReady = true
  589. a.modelMappingCacheCredentialsPtr = credentialsPtr
  590. a.modelMappingCacheRawPtr = rawPtr
  591. a.modelMappingCacheRawLen = rawLen
  592. a.modelMappingCacheRawSig = rawSig
  593. a.modelMappingCacheRuntimeVersion = runtimeVersion
  594. return mapping
  595. }
  596. func (a *Account) resolveModelMapping(rawMapping map[string]any) map[string]string {
  597. if a.Credentials == nil {
  598. // Antigravity 平台使用默认映射
  599. if a.Platform == domain.PlatformAntigravity {
  600. return domain.DefaultAntigravityModelMapping
  601. }
  602. if a.Platform == domain.PlatformGrok {
  603. return xai.DefaultModelMapping()
  604. }
  605. // Bedrock 默认映射由 forwardBedrock 统一处理(需配合 region prefix 调整)
  606. return nil
  607. }
  608. if len(rawMapping) == 0 {
  609. if a.IsGeminiGoogleOne() {
  610. return geminicli.GoogleOneModelMapping()
  611. }
  612. // Antigravity 平台使用默认映射
  613. if a.Platform == domain.PlatformAntigravity {
  614. return domain.DefaultAntigravityModelMapping
  615. }
  616. if a.Platform == domain.PlatformGrok {
  617. return xai.DefaultModelMapping()
  618. }
  619. return nil
  620. }
  621. result := make(map[string]string)
  622. for k, v := range rawMapping {
  623. if s, ok := v.(string); ok {
  624. result[k] = s
  625. }
  626. }
  627. if len(result) > 0 {
  628. if a.Platform == domain.PlatformAntigravity {
  629. ensureAntigravityDefaultPassthroughs(result, []string{
  630. "gemini-3-flash",
  631. "gemini-3.1-pro-high",
  632. "gemini-3.1-pro-low",
  633. "gemini-3.6-flash",
  634. "gemini-3.6-flash-high",
  635. "gemini-3.6-flash-low",
  636. "gemini-3.6-flash-medium",
  637. "gemini-3.6-flash-tiered",
  638. })
  639. applyAntigravityGemini31ProAliases(result)
  640. }
  641. return result
  642. }
  643. // Antigravity 平台使用默认映射
  644. if a.IsGeminiGoogleOne() {
  645. return geminicli.GoogleOneModelMapping()
  646. }
  647. if a.Platform == domain.PlatformAntigravity {
  648. return domain.DefaultAntigravityModelMapping
  649. }
  650. if a.Platform == domain.PlatformGrok {
  651. return xai.DefaultModelMapping()
  652. }
  653. return nil
  654. }
  655. func mapPtr(m map[string]any) uintptr {
  656. if m == nil {
  657. return 0
  658. }
  659. return reflect.ValueOf(m).Pointer()
  660. }
  661. func modelMappingSignature(rawMapping map[string]any) uint64 {
  662. if len(rawMapping) == 0 {
  663. return 0
  664. }
  665. keys := make([]string, 0, len(rawMapping))
  666. for k := range rawMapping {
  667. keys = append(keys, k)
  668. }
  669. sort.Strings(keys)
  670. h := fnv.New64a()
  671. for _, k := range keys {
  672. _, _ = h.Write([]byte(k))
  673. _, _ = h.Write([]byte{0})
  674. if v, ok := rawMapping[k].(string); ok {
  675. _, _ = h.Write([]byte(v))
  676. } else {
  677. _, _ = h.Write([]byte{1})
  678. }
  679. _, _ = h.Write([]byte{0xff})
  680. }
  681. return h.Sum64()
  682. }
  683. func ensureAntigravityDefaultPassthrough(mapping map[string]string, model string) {
  684. if mapping == nil || model == "" {
  685. return
  686. }
  687. if _, exists := mapping[model]; exists {
  688. return
  689. }
  690. for pattern := range mapping {
  691. if matchWildcard(pattern, model) {
  692. return
  693. }
  694. }
  695. mapping[model] = model
  696. }
  697. func ensureAntigravityDefaultPassthroughs(mapping map[string]string, models []string) {
  698. for _, model := range models {
  699. ensureAntigravityDefaultPassthrough(mapping, model)
  700. }
  701. }
  702. func applyAntigravityGemini31ProAliases(mapping map[string]string) {
  703. target := strings.TrimSpace(mapping[domain.AntigravityGemini31ProAgentModel])
  704. if target == "" {
  705. return
  706. }
  707. aliases := []struct {
  708. model string
  709. legacyTargets map[string]struct{}
  710. }{
  711. {
  712. model: "gemini-3.1-pro",
  713. legacyTargets: map[string]struct{}{
  714. "gemini-3.1-pro": {},
  715. },
  716. },
  717. {
  718. model: "gemini-3.1-pro-high",
  719. legacyTargets: map[string]struct{}{
  720. "gemini-3.1-pro-high": {},
  721. },
  722. },
  723. {
  724. model: "gemini-3.1-pro-preview",
  725. legacyTargets: map[string]struct{}{
  726. "gemini-3.1-pro-preview": {},
  727. "gemini-3.1-pro-high": {},
  728. },
  729. },
  730. }
  731. for _, alias := range aliases {
  732. current, exists := mapping[alias.model]
  733. if exists {
  734. if _, legacy := alias.legacyTargets[current]; legacy {
  735. mapping[alias.model] = target
  736. }
  737. continue
  738. }
  739. if mappingHasWildcardForModel(mapping, alias.model) {
  740. continue
  741. }
  742. mapping[alias.model] = target
  743. }
  744. }
  745. func mappingHasWildcardForModel(mapping map[string]string, model string) bool {
  746. for pattern := range mapping {
  747. if matchWildcard(pattern, model) {
  748. return true
  749. }
  750. }
  751. return false
  752. }
  753. func normalizeRequestedModelForLookup(platform, requestedModel string) string {
  754. trimmed := strings.TrimSpace(requestedModel)
  755. if trimmed == "" {
  756. return ""
  757. }
  758. if platform != PlatformGemini && platform != PlatformAntigravity {
  759. return trimmed
  760. }
  761. if trimmed == "gemini-3.1-pro-preview-customtools" {
  762. return "gemini-3.1-pro-preview"
  763. }
  764. return trimmed
  765. }
  766. func mappingSupportsRequestedModel(mapping map[string]string, requestedModel string) bool {
  767. if requestedModel == "" {
  768. return false
  769. }
  770. if _, exists := mapping[requestedModel]; exists {
  771. return true
  772. }
  773. for pattern := range mapping {
  774. if matchWildcard(pattern, requestedModel) {
  775. return true
  776. }
  777. }
  778. return false
  779. }
  780. func resolveRequestedModelInMapping(mapping map[string]string, requestedModel string) (mappedModel string, matched bool) {
  781. if requestedModel == "" {
  782. return "", false
  783. }
  784. if mappedModel, exists := mapping[requestedModel]; exists {
  785. return mappedModel, true
  786. }
  787. return matchWildcardMappingResult(mapping, requestedModel)
  788. }
  789. // IsModelSupported 检查模型是否在 model_mapping 中(支持通配符)
  790. // 如果未配置 mapping,返回 true(允许所有模型)。
  791. //
  792. // 例外:OpenAI OAuth 账号(Codex 上游)的空映射会排除明确属于其他厂商
  793. // 家族的模型(deepseek-*/glm-* 等)——转发阶段 normalizeOpenAIModelForUpstream
  794. // 会把未知模型原样透传,Codex 上游对这类模型必然返回不可重试的 400,导致
  795. // 请求卡死在该账号上、无法 failover 到真正支持该模型的 API Key 账号(#3662)。
  796. // 未知/自定义别名仍保持允许(兼容渠道级映射),见 isOpenAIOAuthServableModel。
  797. func (a *Account) IsModelSupported(requestedModel string) bool {
  798. // 透传模式仅替换认证、模型语义完全交由上游决定,因此放行所有模型。
  799. // 该短路必须在 model_mapping 判定之前:账号从"白名单模式"切换到透传后,
  800. // credentials 里常残留旧的非空 model_mapping,若不在此放行,透传账号会被
  801. // model_mapping 白名单错误排除出候选集,导致 no available accounts / 404(issue #4936)。
  802. if a.IsOpenAIPassthroughEnabled() {
  803. return true
  804. }
  805. mapping := a.GetModelMapping()
  806. if len(mapping) == 0 {
  807. if a.IsOpenAIOAuth() {
  808. return isOpenAIOAuthServableModel(requestedModel)
  809. }
  810. return true // 无映射 = 允许所有
  811. }
  812. if mappingSupportsRequestedModel(mapping, requestedModel) {
  813. return true
  814. }
  815. normalized := normalizeRequestedModelForLookup(a.Platform, requestedModel)
  816. return normalized != requestedModel && mappingSupportsRequestedModel(mapping, normalized)
  817. }
  818. // GetMappedModel 获取映射后的模型名(支持通配符,最长优先匹配)
  819. // 如果未配置 mapping,返回原始模型名
  820. func (a *Account) GetMappedModel(requestedModel string) string {
  821. mappedModel, _ := a.ResolveMappedModel(requestedModel)
  822. return mappedModel
  823. }
  824. // ResolveMappedModel 获取映射后的模型名,并返回是否命中了账号级映射。
  825. // matched=true 表示命中了精确映射或通配符映射,即使映射结果与原模型名相同。
  826. func (a *Account) ResolveMappedModel(requestedModel string) (mappedModel string, matched bool) {
  827. mapping := a.GetModelMapping()
  828. if len(mapping) == 0 {
  829. return requestedModel, false
  830. }
  831. if mappedModel, matched := resolveRequestedModelInMapping(mapping, requestedModel); matched {
  832. return mappedModel, true
  833. }
  834. normalized := normalizeRequestedModelForLookup(a.Platform, requestedModel)
  835. if normalized != requestedModel {
  836. if mappedModel, matched := resolveRequestedModelInMapping(mapping, normalized); matched {
  837. return mappedModel, true
  838. }
  839. }
  840. return requestedModel, false
  841. }
  842. // GetOpenAICompactMode returns the compact routing mode for an OpenAI account.
  843. // Missing or invalid values fall back to "auto".
  844. func (a *Account) GetOpenAICompactMode() string {
  845. if a == nil || !a.IsOpenAI() || a.Extra == nil {
  846. return OpenAICompactModeAuto
  847. }
  848. mode, _ := a.Extra["openai_compact_mode"].(string)
  849. return normalizeOpenAICompactMode(mode)
  850. }
  851. // OpenAICompactSupportKnown reports whether compact capability is known for this
  852. // account and, when known, whether it is supported.
  853. func (a *Account) OpenAICompactSupportKnown() (supported bool, known bool) {
  854. if a == nil || !a.IsOpenAI() {
  855. return false, false
  856. }
  857. switch a.GetOpenAICompactMode() {
  858. case OpenAICompactModeForceOn:
  859. return true, true
  860. case OpenAICompactModeForceOff:
  861. return false, true
  862. }
  863. if a.Extra == nil {
  864. return false, false
  865. }
  866. supported, ok := a.Extra["openai_compact_supported"].(bool)
  867. if !ok {
  868. return false, false
  869. }
  870. return supported, true
  871. }
  872. // AllowsOpenAICompact reports whether the account may be considered for compact
  873. // requests. Unknown capability remains allowed to avoid breaking older accounts
  874. // before an explicit probe has been run.
  875. func (a *Account) AllowsOpenAICompact() bool {
  876. if a == nil || !a.IsOpenAI() {
  877. return false
  878. }
  879. supported, known := a.OpenAICompactSupportKnown()
  880. if !known {
  881. return true
  882. }
  883. return supported
  884. }
  885. // GetCompactModelMapping returns compact-only model remapping configuration.
  886. // This mapping is intended for /responses/compact only and does not affect
  887. // normal /responses traffic.
  888. func (a *Account) GetCompactModelMapping() map[string]string {
  889. if a == nil || a.Credentials == nil {
  890. return nil
  891. }
  892. return stringMappingFromRaw(a.Credentials["compact_model_mapping"])
  893. }
  894. // ResolveCompactMappedModel resolves compact-only model remapping and reports
  895. // whether a compact-specific mapping rule matched.
  896. func (a *Account) ResolveCompactMappedModel(requestedModel string) (mappedModel string, matched bool) {
  897. mapping := a.GetCompactModelMapping()
  898. if len(mapping) == 0 {
  899. return requestedModel, false
  900. }
  901. if mappedModel, matched := resolveRequestedModelInMapping(mapping, requestedModel); matched {
  902. return mappedModel, true
  903. }
  904. return requestedModel, false
  905. }
  906. func (a *Account) GetBaseURL() string {
  907. if a.Type != AccountTypeAPIKey {
  908. return ""
  909. }
  910. baseURL := a.GetCredential("base_url")
  911. if baseURL == "" {
  912. return "https://api.anthropic.com"
  913. }
  914. if a.Platform == PlatformAntigravity {
  915. return strings.TrimRight(baseURL, "/") + "/antigravity"
  916. }
  917. return baseURL
  918. }
  919. // GetGeminiBaseURL 返回 Gemini 兼容端点的 base URL。
  920. // Antigravity 平台的 APIKey 账号自动拼接 /antigravity。
  921. func (a *Account) GetGeminiBaseURL(defaultBaseURL string) string {
  922. baseURL := strings.TrimSpace(a.GetCredential("base_url"))
  923. if baseURL == "" {
  924. return defaultBaseURL
  925. }
  926. if a.Platform == PlatformAntigravity && a.Type == AccountTypeAPIKey {
  927. return strings.TrimRight(baseURL, "/") + "/antigravity"
  928. }
  929. return baseURL
  930. }
  931. func (a *Account) GetExtraString(key string) string {
  932. if a.Extra == nil {
  933. return ""
  934. }
  935. if v, ok := a.Extra[key]; ok {
  936. if s, ok := v.(string); ok {
  937. return s
  938. }
  939. }
  940. return ""
  941. }
  942. func (a *Account) GetClaudeUserID() string {
  943. if v := strings.TrimSpace(a.GetExtraString("claude_user_id")); v != "" {
  944. return v
  945. }
  946. if v := strings.TrimSpace(a.GetExtraString("anthropic_user_id")); v != "" {
  947. return v
  948. }
  949. if v := strings.TrimSpace(a.GetCredential("claude_user_id")); v != "" {
  950. return v
  951. }
  952. if v := strings.TrimSpace(a.GetCredential("anthropic_user_id")); v != "" {
  953. return v
  954. }
  955. return ""
  956. }
  957. // matchAntigravityWildcard 通配符匹配(仅支持末尾 *)
  958. // 用于 model_mapping 的通配符匹配
  959. func matchAntigravityWildcard(pattern, str string) bool {
  960. if strings.HasSuffix(pattern, "*") {
  961. prefix := pattern[:len(pattern)-1]
  962. return strings.HasPrefix(str, prefix)
  963. }
  964. return pattern == str
  965. }
  966. // matchWildcard 通用通配符匹配(仅支持末尾 *)
  967. // 复用 Antigravity 的通配符逻辑,供其他平台使用
  968. func matchWildcard(pattern, str string) bool {
  969. return matchAntigravityWildcard(pattern, str)
  970. }
  971. func matchWildcardMappingResult(mapping map[string]string, requestedModel string) (string, bool) {
  972. // 收集所有匹配的 pattern,按长度降序排序(最长优先)
  973. type patternMatch struct {
  974. pattern string
  975. target string
  976. }
  977. var matches []patternMatch
  978. for pattern, target := range mapping {
  979. if matchWildcard(pattern, requestedModel) {
  980. matches = append(matches, patternMatch{pattern, target})
  981. }
  982. }
  983. if len(matches) == 0 {
  984. return requestedModel, false // 无匹配,返回原始模型名
  985. }
  986. // 按 pattern 长度降序排序
  987. sort.Slice(matches, func(i, j int) bool {
  988. if len(matches[i].pattern) != len(matches[j].pattern) {
  989. return len(matches[i].pattern) > len(matches[j].pattern)
  990. }
  991. return matches[i].pattern < matches[j].pattern
  992. })
  993. return matches[0].target, true
  994. }
  995. func (a *Account) IsCustomErrorCodesEnabled() bool {
  996. if a.Type != AccountTypeAPIKey || a.Credentials == nil {
  997. return false
  998. }
  999. if v, ok := a.Credentials["custom_error_codes_enabled"]; ok {
  1000. if enabled, ok := v.(bool); ok {
  1001. return enabled
  1002. }
  1003. }
  1004. return false
  1005. }
  1006. // IsPoolMode 检查 API Key 账号是否启用池模式。
  1007. // 池模式下,上游错误不标记本地账号状态,而是在同一账号上重试。
  1008. func (a *Account) IsPoolMode() bool {
  1009. if !a.IsAPIKeyOrBedrock() || a.Credentials == nil {
  1010. return false
  1011. }
  1012. if v, ok := a.Credentials["pool_mode"]; ok {
  1013. if enabled, ok := v.(bool); ok {
  1014. return enabled
  1015. }
  1016. }
  1017. return false
  1018. }
  1019. const (
  1020. defaultPoolModeRetryCount = 3
  1021. maxPoolModeRetryCount = 10
  1022. )
  1023. // GetPoolModeRetryCount 返回池模式同账号重试次数。
  1024. // 未配置或配置非法时回退为默认值 3;小于 0 按 0 处理;过大则截断到 10。
  1025. func (a *Account) GetPoolModeRetryCount() int {
  1026. if a == nil || !a.IsPoolMode() || a.Credentials == nil {
  1027. return defaultPoolModeRetryCount
  1028. }
  1029. raw, ok := a.Credentials["pool_mode_retry_count"]
  1030. if !ok || raw == nil {
  1031. return defaultPoolModeRetryCount
  1032. }
  1033. count := parsePoolModeRetryCount(raw)
  1034. if count < 0 {
  1035. return 0
  1036. }
  1037. if count > maxPoolModeRetryCount {
  1038. return maxPoolModeRetryCount
  1039. }
  1040. return count
  1041. }
  1042. func parsePoolModeRetryCount(value any) int {
  1043. switch v := value.(type) {
  1044. case int:
  1045. return v
  1046. case int64:
  1047. return int(v)
  1048. case float64:
  1049. return int(v)
  1050. case json.Number:
  1051. if i, err := v.Int64(); err == nil {
  1052. return int(i)
  1053. }
  1054. case string:
  1055. if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
  1056. return i
  1057. }
  1058. }
  1059. return defaultPoolModeRetryCount
  1060. }
  1061. // defaultPoolModeRetryableStatusCodes 池模式下默认触发同账号重试的状态码。
  1062. // 未在 Account.Credentials 中显式配置 pool_mode_retry_status_codes 时使用。
  1063. var defaultPoolModeRetryableStatusCodes = []int{401, 403, 429}
  1064. // isPoolModeRetryableStatus 池模式下应触发同账号重试的状态码(默认列表)。
  1065. func isPoolModeRetryableStatus(statusCode int) bool {
  1066. for _, c := range defaultPoolModeRetryableStatusCodes {
  1067. if c == statusCode {
  1068. return true
  1069. }
  1070. }
  1071. return false
  1072. }
  1073. // GetPoolModeRetryStatusCodes 返回账号自定义的池模式同账号重试状态码列表。
  1074. //
  1075. // 返回值语义:
  1076. // - nil:未配置 → 调用方应回退到默认值 [401, 403, 429]
  1077. // - 长度为 0 的切片:管理员显式置空 → 关闭按状态码触发的同账号重试
  1078. // - 非空切片:去重、过滤为合法 HTTP 状态码(100-599)后的覆盖列表
  1079. func (a *Account) GetPoolModeRetryStatusCodes() []int {
  1080. if a == nil || a.Credentials == nil {
  1081. return nil
  1082. }
  1083. raw, ok := a.Credentials["pool_mode_retry_status_codes"]
  1084. if !ok || raw == nil {
  1085. return nil
  1086. }
  1087. arr, ok := raw.([]any)
  1088. if !ok {
  1089. return nil
  1090. }
  1091. seen := make(map[int]struct{}, len(arr))
  1092. codes := make([]int, 0, len(arr))
  1093. for _, v := range arr {
  1094. var code int
  1095. switch n := v.(type) {
  1096. case float64:
  1097. code = int(n)
  1098. case int:
  1099. code = n
  1100. case int64:
  1101. code = int(n)
  1102. case json.Number:
  1103. i, err := n.Int64()
  1104. if err != nil {
  1105. continue
  1106. }
  1107. code = int(i)
  1108. case string:
  1109. i, err := strconv.Atoi(strings.TrimSpace(n))
  1110. if err != nil {
  1111. continue
  1112. }
  1113. code = i
  1114. default:
  1115. continue
  1116. }
  1117. if code < 100 || code > 599 {
  1118. continue
  1119. }
  1120. if _, exists := seen[code]; exists {
  1121. continue
  1122. }
  1123. seen[code] = struct{}{}
  1124. codes = append(codes, code)
  1125. }
  1126. sort.Ints(codes)
  1127. return codes
  1128. }
  1129. // IsPoolModeRetryableStatus 在账号上下文中判断给定状态码是否应触发同账号重试。
  1130. // 若账号未配置 pool_mode_retry_status_codes,则回退到默认列表。
  1131. func (a *Account) IsPoolModeRetryableStatus(statusCode int) bool {
  1132. codes := a.GetPoolModeRetryStatusCodes()
  1133. if codes == nil {
  1134. return isPoolModeRetryableStatus(statusCode)
  1135. }
  1136. for _, c := range codes {
  1137. if c == statusCode {
  1138. return true
  1139. }
  1140. }
  1141. return false
  1142. }
  1143. func (a *Account) GetCustomErrorCodes() []int {
  1144. if a.Credentials == nil {
  1145. return nil
  1146. }
  1147. raw, ok := a.Credentials["custom_error_codes"]
  1148. if !ok || raw == nil {
  1149. return nil
  1150. }
  1151. if arr, ok := raw.([]any); ok {
  1152. result := make([]int, 0, len(arr))
  1153. for _, v := range arr {
  1154. if f, ok := v.(float64); ok {
  1155. result = append(result, int(f))
  1156. }
  1157. }
  1158. return result
  1159. }
  1160. return nil
  1161. }
  1162. func (a *Account) ShouldHandleErrorCode(statusCode int) bool {
  1163. if !a.IsCustomErrorCodesEnabled() {
  1164. return true
  1165. }
  1166. codes := a.GetCustomErrorCodes()
  1167. if len(codes) == 0 {
  1168. return true
  1169. }
  1170. for _, code := range codes {
  1171. if code == statusCode {
  1172. return true
  1173. }
  1174. }
  1175. return false
  1176. }
  1177. func (a *Account) IsInterceptWarmupEnabled() bool {
  1178. if a.Credentials == nil {
  1179. return false
  1180. }
  1181. if v, ok := a.Credentials["intercept_warmup_requests"]; ok {
  1182. if enabled, ok := v.(bool); ok {
  1183. return enabled
  1184. }
  1185. }
  1186. return false
  1187. }
  1188. func (a *Account) IsBedrock() bool {
  1189. return a.Platform == PlatformAnthropic && a.Type == AccountTypeBedrock
  1190. }
  1191. func (a *Account) IsBedrockAPIKey() bool {
  1192. return a.IsBedrock() && a.GetCredential("auth_mode") == "apikey"
  1193. }
  1194. // IsAPIKeyOrBedrock 返回账号类型是否支持配额和池模式等特性
  1195. func (a *Account) IsAPIKeyOrBedrock() bool {
  1196. return a.Type == AccountTypeAPIKey || a.Type == AccountTypeBedrock
  1197. }
  1198. func (a *Account) IsOpenAI() bool {
  1199. return a.Platform == PlatformOpenAI
  1200. }
  1201. func (a *Account) IsOpenAILongContextBillingEnabled() bool {
  1202. if a == nil || !a.IsOpenAI() || a.Extra == nil {
  1203. return false
  1204. }
  1205. enabled, ok := a.Extra[openAILongContextBillingEnabledKey].(bool)
  1206. return ok && enabled
  1207. }
  1208. func (a *Account) IsAnthropic() bool {
  1209. return a.Platform == PlatformAnthropic
  1210. }
  1211. func (a *Account) IsOpenAIOAuth() bool {
  1212. return a.IsOpenAI() && a.Type == AccountTypeOAuth
  1213. }
  1214. // IsOpenAIOAuthLike reports OpenAI credentials that use the ChatGPT/Codex
  1215. // inference protocol. Setup tokens share that forwarding contract but do not
  1216. // participate in the refreshable OAuth credential lifecycle.
  1217. func (a *Account) IsOpenAIOAuthLike() bool {
  1218. return a != nil && a.IsOpenAI() && (a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken)
  1219. }
  1220. // UsesOpenAICodexProtocol preserves legacy OpenAI gateway OAuth routing for
  1221. // accounts whose platform is implicit, while adding OpenAI SetupToken.
  1222. func (a *Account) UsesOpenAICodexProtocol() bool {
  1223. return a != nil && (a.Type == AccountTypeOAuth || a.IsOpenAIOAuthLike())
  1224. }
  1225. func (a *Account) IsOpenAIChatGPTSubscription() bool {
  1226. if !a.IsOpenAIOAuth() {
  1227. return false
  1228. }
  1229. switch strings.ToLower(strings.TrimSpace(a.GetCredential("plan_type"))) {
  1230. case "", "free", "abnormal":
  1231. return false
  1232. default:
  1233. return true
  1234. }
  1235. }
  1236. func (a *Account) IsOpenAIPersonalAccessToken() bool {
  1237. if !a.IsOpenAIOAuth() {
  1238. return false
  1239. }
  1240. return isOpenAIPersonalAccessTokenAuthMode(a.GetCredential(openAIAuthModeCredentialKey)) ||
  1241. isOpenAIPersonalAccessTokenAuthMode(a.GetCredential(openAIAuthModeLegacyCredentialKey))
  1242. }
  1243. func (a *Account) IsOpenAIApiKey() bool {
  1244. return a.IsOpenAI() && a.Type == AccountTypeAPIKey
  1245. }
  1246. // GetOpenAIBaseURL 解析 OpenAI 协议族账号的上游 base_url。
  1247. // 适用 openai 与国产 OpenAI 兼容供应商(kimi/zhipu/deepseek);grok 走 GetGrokBaseURL,
  1248. // 此处对 grok 返回 "" 以保持原有行为。
  1249. func (a *Account) GetOpenAIBaseURL() string {
  1250. if !a.IsOpenAI() && !a.IsCNProvider() {
  1251. return ""
  1252. }
  1253. if a.IsCNProvider() && a.IsAdaptiveAPIProtocol() {
  1254. if baseURLs, ok := a.Credentials["api_base_urls"].(map[string]any); ok {
  1255. if baseURL, ok := baseURLs[APIProtocolChatCompletions].(string); ok && strings.TrimSpace(baseURL) != "" {
  1256. return strings.TrimSpace(baseURL)
  1257. }
  1258. }
  1259. }
  1260. if a.Type == AccountTypeAPIKey || a.Type == AccountTypeUpstream {
  1261. if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" {
  1262. return baseURL
  1263. }
  1264. }
  1265. // 平台默认 base_url:CN 供应商按 account_mode 选择 payg / coding 默认值。
  1266. switch a.Platform {
  1267. case PlatformKimi:
  1268. if a.GetAccountMode() == AccountModeCoding {
  1269. return DefaultKimiCodingBaseURL
  1270. }
  1271. return DefaultKimiPayGBaseURL
  1272. case PlatformZhipu:
  1273. if a.GetAccountMode() == AccountModeCoding {
  1274. return DefaultZhipuCodingBaseURL
  1275. }
  1276. return DefaultZhipuPayGBaseURL
  1277. case PlatformDeepseek:
  1278. return DefaultDeepseekBaseURL
  1279. case PlatformMiniMax:
  1280. return DefaultMiniMaxBaseURL
  1281. default:
  1282. return "https://api.openai.com"
  1283. }
  1284. }
  1285. // GetAccountMode 返回国产供应商账号的接入模式(payg / coding);非国产供应商或未设置时
  1286. // 返回空串。存储于 credentials["account_mode"]。
  1287. func (a *Account) GetAccountMode() string {
  1288. if a == nil {
  1289. return ""
  1290. }
  1291. mode := strings.TrimSpace(a.GetCredential("account_mode"))
  1292. if mode == AccountModePayG || mode == AccountModeCoding {
  1293. return mode
  1294. }
  1295. return ""
  1296. }
  1297. // IsCodingPlan 报告账号是否为 Coding Plan 模式(用于滚动用量窗口冷却)。
  1298. func (a *Account) IsCodingPlan() bool {
  1299. return a.GetAccountMode() == AccountModeCoding
  1300. }
  1301. // GetAPIProtocol 返回国产供应商账号的上游 API 协议。存储于
  1302. // credentials["api_protocol"];缺失或与平台不匹配时回退 chat_completions
  1303. // (与既有行为完全一致)。responses 协议仅 deepseek / kimi 支持(官方原生
  1304. // Responses 端点,适配 Codex);zhipu 无此端点。
  1305. func (a *Account) GetAPIProtocol() string {
  1306. if a == nil || !a.IsCNProvider() {
  1307. return APIProtocolChatCompletions
  1308. }
  1309. switch strings.TrimSpace(a.GetCredential("api_protocol")) {
  1310. case APIProtocolAdaptive:
  1311. return APIProtocolAdaptive
  1312. case APIProtocolAnthropic:
  1313. return APIProtocolAnthropic
  1314. case APIProtocolResponses:
  1315. if a.SupportsNativeCNResponses() {
  1316. return APIProtocolResponses
  1317. }
  1318. case APIProtocolChatCompletions:
  1319. return APIProtocolChatCompletions
  1320. }
  1321. return APIProtocolChatCompletions
  1322. }
  1323. // SupportsNativeCNResponses 报告该国产供应商是否提供原生 Responses 端点。
  1324. // DeepSeek 官方为 /responses(无 /v1);Kimi 按量付费与 Coding Plan 均为
  1325. // /v1/responses(moonshot.cn / kimi.com/coding)。
  1326. func (a *Account) SupportsNativeCNResponses() bool {
  1327. if a == nil {
  1328. return false
  1329. }
  1330. switch a.Platform {
  1331. case PlatformDeepseek, PlatformKimi, PlatformMiniMax:
  1332. return true
  1333. default:
  1334. return false
  1335. }
  1336. }
  1337. // UsesNativeCNResponses 报告当前账号是否应按原生 Responses 协议转发
  1338. // (显式 responses,或 adaptive 且平台具备原生端点)。
  1339. func (a *Account) UsesNativeCNResponses() bool {
  1340. if a == nil || !a.SupportsNativeCNResponses() {
  1341. return false
  1342. }
  1343. switch a.GetAPIProtocol() {
  1344. case APIProtocolResponses, APIProtocolAdaptive:
  1345. return true
  1346. default:
  1347. return false
  1348. }
  1349. }
  1350. // IsAdaptiveAPIProtocol 报告账号是否按入站协议动态选择供应商原生端点。
  1351. func (a *Account) IsAdaptiveAPIProtocol() bool {
  1352. return a.GetAPIProtocol() == APIProtocolAdaptive
  1353. }
  1354. // GetCNProtocolBaseURL 返回国产供应商指定协议的上游 base URL。
  1355. // adaptive 账号优先使用 api_base_urls 中的分协议地址,缺失时按平台和
  1356. // account_mode 使用官方默认端点。base_url 继续作为 Chat Completions 地址兼容旧字段。
  1357. func (a *Account) GetCNProtocolBaseURL(protocol string) string {
  1358. if a == nil || !a.IsCNProvider() {
  1359. return ""
  1360. }
  1361. if a.IsAdaptiveAPIProtocol() {
  1362. if baseURLs, ok := a.Credentials["api_base_urls"].(map[string]any); ok {
  1363. if baseURL, ok := baseURLs[protocol].(string); ok && strings.TrimSpace(baseURL) != "" {
  1364. return strings.TrimSpace(baseURL)
  1365. }
  1366. }
  1367. if protocol == APIProtocolChatCompletions {
  1368. if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" {
  1369. return baseURL
  1370. }
  1371. }
  1372. }
  1373. return a.defaultCNProtocolBaseURL(protocol)
  1374. }
  1375. func (a *Account) defaultCNProtocolBaseURL(protocol string) string {
  1376. switch protocol {
  1377. case APIProtocolAnthropic:
  1378. switch a.Platform {
  1379. case PlatformKimi:
  1380. if a.GetAccountMode() == AccountModeCoding {
  1381. return DefaultKimiCodingAnthropicBaseURL
  1382. }
  1383. return DefaultKimiPayGAnthropicBaseURL
  1384. case PlatformZhipu:
  1385. return DefaultZhipuAnthropicBaseURL
  1386. case PlatformDeepseek:
  1387. return DefaultDeepseekAnthropicBaseURL
  1388. case PlatformMiniMax:
  1389. return DefaultMiniMaxAnthropicBaseURL
  1390. }
  1391. case APIProtocolChatCompletions, APIProtocolResponses:
  1392. switch a.Platform {
  1393. case PlatformKimi:
  1394. if a.GetAccountMode() == AccountModeCoding {
  1395. return DefaultKimiCodingBaseURL
  1396. }
  1397. return DefaultKimiPayGBaseURL
  1398. case PlatformZhipu:
  1399. if a.GetAccountMode() == AccountModeCoding {
  1400. return DefaultZhipuCodingBaseURL
  1401. }
  1402. return DefaultZhipuPayGBaseURL
  1403. case PlatformDeepseek:
  1404. return DefaultDeepseekBaseURL
  1405. case PlatformMiniMax:
  1406. return DefaultMiniMaxBaseURL
  1407. }
  1408. }
  1409. return ""
  1410. }
  1411. // IsAnthropicProtocol 报告账号是否以原生 Anthropic 协议接入上游
  1412. // (/v1/messages 直通,适配 Claude Code 等客户端)。
  1413. func (a *Account) IsAnthropicProtocol() bool {
  1414. return a.GetAPIProtocol() == APIProtocolAnthropic
  1415. }
  1416. // GetAnthropicProtocolBaseURL 返回 Anthropic 协议账号的上游 base_url
  1417. // (上游路径为 {base}/v1/messages)。优先取凭证 base_url,缺失时按
  1418. // 供应商 × 接入模式返回默认端点。非 Anthropic 协议账号返回空串。
  1419. func (a *Account) GetAnthropicProtocolBaseURL() string {
  1420. if a == nil || (!a.IsAnthropicProtocol() && !a.IsAdaptiveAPIProtocol()) {
  1421. return ""
  1422. }
  1423. if a.IsAdaptiveAPIProtocol() {
  1424. return a.GetCNProtocolBaseURL(APIProtocolAnthropic)
  1425. }
  1426. if a.Type == AccountTypeAPIKey || a.Type == AccountTypeUpstream {
  1427. if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" {
  1428. return baseURL
  1429. }
  1430. }
  1431. switch a.Platform {
  1432. case PlatformKimi:
  1433. if a.GetAccountMode() == AccountModeCoding {
  1434. return DefaultKimiCodingAnthropicBaseURL
  1435. }
  1436. return DefaultKimiPayGAnthropicBaseURL
  1437. case PlatformZhipu:
  1438. return DefaultZhipuAnthropicBaseURL
  1439. case PlatformDeepseek:
  1440. return DefaultDeepseekAnthropicBaseURL
  1441. case PlatformMiniMax:
  1442. return DefaultMiniMaxAnthropicBaseURL
  1443. default:
  1444. return ""
  1445. }
  1446. }
  1447. // GetOpenAIFormatBaseURL 返回供 OpenAI 格式端点(/v1/models、/v1/chat/completions
  1448. // 等)使用的 base。chat_completions / responses 协议下与 GetOpenAIBaseURL
  1449. // 一致(凭证 base_url 或平台默认);anthropic 协议下凭证 base_url 指向 Anthropic
  1450. // 端点,不能拿来拼 OpenAI 路径,此时返回该供应商 × 模式的 Chat Completions
  1451. // 默认 base(模型同步等协议族共用路径仍可用)。
  1452. func (a *Account) GetOpenAIFormatBaseURL() string {
  1453. if a == nil || !a.IsAnthropicProtocol() {
  1454. return a.GetOpenAIBaseURL()
  1455. }
  1456. switch a.Platform {
  1457. case PlatformKimi:
  1458. if a.GetAccountMode() == AccountModeCoding {
  1459. return DefaultKimiCodingBaseURL
  1460. }
  1461. return DefaultKimiPayGBaseURL
  1462. case PlatformZhipu:
  1463. if a.GetAccountMode() == AccountModeCoding {
  1464. return DefaultZhipuCodingBaseURL
  1465. }
  1466. return DefaultZhipuPayGBaseURL
  1467. case PlatformDeepseek:
  1468. return DefaultDeepseekBaseURL
  1469. default:
  1470. return a.GetOpenAIBaseURL()
  1471. }
  1472. }
  1473. // GetCNAPIKey 返回国产 OpenAI 兼容供应商账号的 api_key 凭据(kimi/zhipu/deepseek)。
  1474. // 与 openai 的 GetOpenAIApiKey 区分:后者仅对 openai 平台返回。
  1475. func (a *Account) GetCNAPIKey() string {
  1476. if a == nil || !a.IsCNProvider() {
  1477. return ""
  1478. }
  1479. return a.GetCredential("api_key")
  1480. }
  1481. // GetCodingPlanProvider 根据 base_url 识别 Coding Plan 供应商(kimi / zhipu),
  1482. // 用于路由到对应的额度查询端点。非 coding 模式或无法识别时返回空串。
  1483. // 判定规则与 cc-switch coding_plan.rs::detect_provider 保持一致。
  1484. func (a *Account) GetCodingPlanProvider() string {
  1485. if a == nil || a.GetAccountMode() != AccountModeCoding {
  1486. return ""
  1487. }
  1488. baseURL := strings.ToLower(a.GetOpenAIBaseURL())
  1489. if parsed, err := url.Parse(baseURL); a.Platform == PlatformMiniMax && err == nil && (parsed.Hostname() == "api.minimaxi.com" || parsed.Hostname() == "api.minimax.io") {
  1490. return PlatformMiniMax
  1491. }
  1492. switch {
  1493. case strings.Contains(baseURL, "api.kimi.com/coding"):
  1494. return PlatformKimi
  1495. case strings.Contains(baseURL, "bigmodel.cn"), strings.Contains(baseURL, "api.z.ai"):
  1496. return PlatformZhipu
  1497. default:
  1498. return ""
  1499. }
  1500. }
  1501. func (a *Account) GetOpenAIAccessToken() string {
  1502. if !a.IsOpenAI() {
  1503. return ""
  1504. }
  1505. return a.GetCredential("access_token")
  1506. }
  1507. func (a *Account) GetOpenAIRefreshToken() string {
  1508. if !a.IsOpenAIOAuth() {
  1509. return ""
  1510. }
  1511. return a.GetCredential("refresh_token")
  1512. }
  1513. // GetGrokBaseURL selects the upstream used by Grok text and Responses traffic.
  1514. // Grok media traffic has a different transport contract and must use
  1515. // GetGrokMediaBaseURL instead.
  1516. //
  1517. // The stored base_url only rewrites forwarding endpoints. Credential lifecycle
  1518. // traffic (OAuth authorization and token refresh) always uses the official
  1519. // auth endpoints regardless of this value.
  1520. func (a *Account) GetGrokBaseURL() string {
  1521. if a == nil || !a.IsGrok() {
  1522. return ""
  1523. }
  1524. if a.IsGrokOAuth() {
  1525. return a.GetGrokBaseURLOr(xai.DefaultCLIBaseURL)
  1526. }
  1527. return a.GetGrokBaseURLOr(xai.DefaultBaseURL)
  1528. }
  1529. // GetGrokBaseURLOr resolves an explicit account endpoint, falling back to the
  1530. // supplied default. Official OAuth endpoints are normalized here; custom
  1531. // endpoints are retained for the request builder's operator URL policy.
  1532. func (a *Account) GetGrokBaseURLOr(defaultBaseURL string) string {
  1533. if a == nil || !a.IsGrok() {
  1534. return ""
  1535. }
  1536. defaultBaseURL = strings.TrimRight(strings.TrimSpace(defaultBaseURL), "/")
  1537. if defaultBaseURL == "" {
  1538. if a.IsGrokOAuth() {
  1539. defaultBaseURL = xai.DefaultCLIBaseURL
  1540. } else {
  1541. defaultBaseURL = xai.DefaultBaseURL
  1542. }
  1543. }
  1544. baseURL := strings.TrimSpace(a.GetCredential("base_url"))
  1545. if baseURL == "" {
  1546. return defaultBaseURL
  1547. }
  1548. if !a.IsGrokOAuth() {
  1549. return baseURL
  1550. }
  1551. // Explicit regional/API or custom values remain pinned. Custom endpoints are checked by the
  1552. // operator URL policy at the request builder, which has access to config.
  1553. if validated, err := xai.ValidateTrustedBaseURL(baseURL); err == nil {
  1554. return validated
  1555. }
  1556. if parsed, err := url.Parse(baseURL); err == nil && parsed.Scheme != "" && parsed.Host != "" &&
  1557. parsed.User == nil && parsed.RawQuery == "" && parsed.Fragment == "" {
  1558. return strings.TrimRight(baseURL, "/")
  1559. }
  1560. return defaultBaseURL
  1561. }
  1562. // GetGrokMediaBaseURL selects the upstream used by Grok Imagine APIs.
  1563. // The subscription CLI gateway enforces a small request-body limit that
  1564. // rejects large Base64 media payloads, so OAuth media leaves for api.x.ai
  1565. // whenever text traffic resolves to the CLI gateway. Every other manually
  1566. // selected endpoint (official/regional API hosts or custom relays) serves
  1567. // media as-is.
  1568. func (a *Account) GetGrokMediaBaseURL() string {
  1569. if !a.IsGrok() {
  1570. return ""
  1571. }
  1572. baseURL := a.GetGrokBaseURL()
  1573. if a.IsGrokOAuth() && isGrokCLIProxyTarget(baseURL) {
  1574. return xai.DefaultBaseURL
  1575. }
  1576. return baseURL
  1577. }
  1578. func (a *Account) GetGrokAccessToken() string {
  1579. if !a.IsGrok() {
  1580. return ""
  1581. }
  1582. return a.GetCredential("access_token")
  1583. }
  1584. func (a *Account) GetGrokRefreshToken() string {
  1585. if !a.IsGrokOAuth() {
  1586. return ""
  1587. }
  1588. return a.GetCredential("refresh_token")
  1589. }
  1590. func (a *Account) GetOpenAIIDToken() string {
  1591. if !a.IsOpenAIOAuth() {
  1592. return ""
  1593. }
  1594. return a.GetCredential("id_token")
  1595. }
  1596. func (a *Account) GetOpenAIApiKey() string {
  1597. if !a.IsOpenAIApiKey() {
  1598. return ""
  1599. }
  1600. return a.GetCredential("api_key")
  1601. }
  1602. // GetOpenAIProtocolAPIKey 返回 OpenAI 协议族 APIKey 账号的密钥。
  1603. // 覆盖 openai 原生账号与国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)账号,
  1604. // 供转发鉴权、模型列表同步等协议族共用路径使用。注意 IsOpenAIApiKey 语义上
  1605. // 仅指 openai 平台账号,调度倍率/WS 能力门控继续以其为准,不受本方法影响。
  1606. func (a *Account) GetOpenAIProtocolAPIKey() string {
  1607. if a == nil {
  1608. return ""
  1609. }
  1610. if a.IsCNProvider() {
  1611. if a.Type != AccountTypeAPIKey {
  1612. return ""
  1613. }
  1614. return a.GetCredential("api_key")
  1615. }
  1616. return a.GetOpenAIApiKey()
  1617. }
  1618. func (a *Account) GetOpenAIUserAgent() string {
  1619. if !a.IsOpenAI() {
  1620. return ""
  1621. }
  1622. return a.GetCredential("user_agent")
  1623. }
  1624. func (a *Account) GetChatGPTAccountID() string {
  1625. if !a.IsOpenAIOAuthLike() {
  1626. return ""
  1627. }
  1628. return a.GetCredential("chatgpt_account_id")
  1629. }
  1630. func (a *Account) IsChatGPTAccountFedRAMP() bool {
  1631. if !a.IsOpenAIOAuthLike() || a.Credentials == nil {
  1632. return false
  1633. }
  1634. v, ok := a.Credentials["chatgpt_account_is_fedramp"]
  1635. if !ok || v == nil {
  1636. return false
  1637. }
  1638. switch value := v.(type) {
  1639. case bool:
  1640. return value
  1641. case string:
  1642. parsed, err := strconv.ParseBool(strings.TrimSpace(value))
  1643. return err == nil && parsed
  1644. case json.Number:
  1645. parsed, err := strconv.ParseBool(value.String())
  1646. return err == nil && parsed
  1647. case float64:
  1648. return value != 0
  1649. case int:
  1650. return value != 0
  1651. case int64:
  1652. return value != 0
  1653. default:
  1654. return false
  1655. }
  1656. }
  1657. func (a *Account) GetOpenAIDeviceID() string {
  1658. if !a.IsOpenAIOAuth() {
  1659. return ""
  1660. }
  1661. return strings.TrimSpace(a.GetExtraString("openai_device_id"))
  1662. }
  1663. func (a *Account) GetOpenAISessionID() string {
  1664. if !a.IsOpenAIOAuth() {
  1665. return ""
  1666. }
  1667. return strings.TrimSpace(a.GetExtraString("openai_session_id"))
  1668. }
  1669. func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapability) bool {
  1670. if a == nil {
  1671. return false
  1672. }
  1673. if capability == "" {
  1674. return true
  1675. }
  1676. if !a.IsOpenAICompatible() {
  1677. return false
  1678. }
  1679. if a.IsGrok() {
  1680. switch capability {
  1681. case OpenAIEndpointCapabilityChatCompletions:
  1682. return true
  1683. case OpenAIEndpointCapabilityGrokMediaGeneration:
  1684. eligible, reason := a.GrokMediaGenerationEligibility()
  1685. // Unobserved OAuth accounts remain scheduler candidates only so the
  1686. // request path can run the billing probe before forwarding. The
  1687. // forwarding gate itself fails closed if that probe is unavailable or
  1688. // cannot produce positive paid-entitlement evidence.
  1689. return eligible || reason == "billing_unobserved"
  1690. default:
  1691. return false
  1692. }
  1693. }
  1694. switch capability {
  1695. case OpenAIEndpointCapabilityChatCompletions:
  1696. case OpenAIEndpointCapabilityLive:
  1697. return a.Platform == PlatformOpenAI &&
  1698. a.Type == AccountTypeOAuth &&
  1699. !a.IsOpenAIPersonalAccessToken() &&
  1700. !a.IsOpenAIAgentIdentity()
  1701. case OpenAIEndpointCapabilityResponses:
  1702. // Responses 支持状态由 accounts.extra 的自动探测标记决定,而非
  1703. // credentials 能力集。已探测确认不支持 /v1/responses 的 APIKey 上游
  1704. // 必须排除——否则会在 forward 阶段被静默降级为 Chat Completions,
  1705. // 无法完成生图(#4417)。未探测/OAuth 账号保留旧行为(不排除)。
  1706. if a.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(a.Extra) {
  1707. return false
  1708. }
  1709. // 支持 Responses 的上游同样需具备 chat 能力:复用下方 chat_completions
  1710. // 配置集校验。
  1711. capability = OpenAIEndpointCapabilityChatCompletions
  1712. case OpenAIEndpointCapabilityAlphaSearch:
  1713. // alpha/search 的转发按账号类型分流:OAuth/PAT 走
  1714. // chatgpt.com/backend-api/codex/alpha/search,API key 走
  1715. // {base_url}/v1/alpha/search(见 openAIAlphaSearchURL),两类账号
  1716. // 都可承接独立搜索请求。上游不支持该端点时由转发层 failover 兜底。
  1717. if a.Type != AccountTypeOAuth && a.Type != AccountTypeAPIKey {
  1718. return false
  1719. }
  1720. case OpenAIEndpointCapabilityEmbeddings:
  1721. if a.Type != AccountTypeAPIKey {
  1722. return false
  1723. }
  1724. default:
  1725. return false
  1726. }
  1727. configured, found := a.openAIEndpointCapabilitySet()
  1728. if !found {
  1729. return true
  1730. }
  1731. if capability == OpenAIEndpointCapabilityAlphaSearch && configured[string(OpenAIEndpointCapabilityChatCompletions)] {
  1732. return true
  1733. }
  1734. return configured[string(capability)]
  1735. }
  1736. // GrokMediaGenerationEligibility reports whether a Grok account may receive
  1737. // new image/video generation requests. OAuth media fails closed unless billing
  1738. // observations provide positive paid-entitlement evidence. An explicit
  1739. // operator override takes precedence over probe data.
  1740. func (a *Account) GrokMediaGenerationEligibility() (bool, string) {
  1741. if a == nil || !a.IsGrok() {
  1742. return false, "not_grok"
  1743. }
  1744. if override, ok := grokMediaEligibilityOverride(a.Extra); ok {
  1745. if override {
  1746. return true, "override_enabled"
  1747. }
  1748. return false, "override_disabled"
  1749. }
  1750. if a.Type != AccountTypeOAuth {
  1751. return true, "non_oauth"
  1752. }
  1753. billing, err := grokBillingSnapshotFromExtra(a.Extra)
  1754. if err != nil || billing == nil {
  1755. return false, "billing_unobserved"
  1756. }
  1757. if billing.StatusCode == 403 || billing.WeeklyStatusCode == 403 || billing.MonthlyStatusCode == 403 {
  1758. return false, "billing_forbidden"
  1759. }
  1760. if isKnownGrokFreeAccount(a) {
  1761. return false, "billing_free_tier"
  1762. }
  1763. if !grokBillingHasAuthoritativeQuota(billing) {
  1764. return false, "billing_inconclusive"
  1765. }
  1766. return true, "eligible"
  1767. }
  1768. func grokMediaEligibilityOverride(extra map[string]any) (bool, bool) {
  1769. if extra == nil {
  1770. return false, false
  1771. }
  1772. raw, exists := extra[GrokMediaEligibleExtraKey]
  1773. if !exists || raw == nil {
  1774. return false, false
  1775. }
  1776. value, ok := raw.(bool)
  1777. return value, ok
  1778. }
  1779. func (a *Account) openAIEndpointCapabilitySet() (map[string]bool, bool) {
  1780. if a == nil || a.Credentials == nil {
  1781. return nil, false
  1782. }
  1783. raw, found := a.Credentials[openAIEndpointCapabilitiesCredentialKey]
  1784. if !found || raw == nil {
  1785. return nil, false
  1786. }
  1787. result := make(map[string]bool)
  1788. add := func(value string) {
  1789. value = strings.ToLower(strings.TrimSpace(value))
  1790. if value == "" {
  1791. return
  1792. }
  1793. result[value] = true
  1794. }
  1795. // 空容器({} / [])与未配置一致:不限制任何能力。
  1796. // 避免 OAuth 账号因 API 直写/导入/历史数据遗留的空对象而被调度器静默排除(#5530)。
  1797. // 注意:非空但全 false / 类型异常的数据仍视为「已配置且不含能力」,保持原行为。
  1798. switch capabilities := raw.(type) {
  1799. case []any:
  1800. if len(capabilities) == 0 {
  1801. return nil, false
  1802. }
  1803. for _, item := range capabilities {
  1804. if value, ok := item.(string); ok {
  1805. add(value)
  1806. }
  1807. }
  1808. case []string:
  1809. if len(capabilities) == 0 {
  1810. return nil, false
  1811. }
  1812. for _, value := range capabilities {
  1813. add(value)
  1814. }
  1815. case map[string]any:
  1816. if len(capabilities) == 0 {
  1817. return nil, false
  1818. }
  1819. for key, value := range capabilities {
  1820. enabled, ok := value.(bool)
  1821. if ok && enabled {
  1822. add(key)
  1823. }
  1824. }
  1825. case map[string]bool:
  1826. if len(capabilities) == 0 {
  1827. return nil, false
  1828. }
  1829. for key, enabled := range capabilities {
  1830. if enabled {
  1831. add(key)
  1832. }
  1833. }
  1834. }
  1835. return result, true
  1836. }
  1837. func (a *Account) SupportsOpenAIImageCapability(capability OpenAIImagesCapability) bool {
  1838. if capability == "" {
  1839. return true
  1840. }
  1841. if !a.IsOpenAI() {
  1842. return false
  1843. }
  1844. switch capability {
  1845. case OpenAIImagesCapabilityBasic, OpenAIImagesCapabilityNative:
  1846. return a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken || a.Type == AccountTypeAPIKey
  1847. default:
  1848. return true
  1849. }
  1850. }
  1851. func (a *Account) GetChatGPTUserID() string {
  1852. if !a.IsOpenAIOAuth() {
  1853. return ""
  1854. }
  1855. return a.GetCredential("chatgpt_user_id")
  1856. }
  1857. func (a *Account) GetOpenAIOrganizationID() string {
  1858. if !a.IsOpenAIOAuth() {
  1859. return ""
  1860. }
  1861. return a.GetCredential("organization_id")
  1862. }
  1863. func (a *Account) GetOpenAITokenExpiresAt() *time.Time {
  1864. if !a.IsOpenAIOAuth() {
  1865. return nil
  1866. }
  1867. return a.GetCredentialAsTime("expires_at")
  1868. }
  1869. func (a *Account) IsOpenAITokenExpired() bool {
  1870. expiresAt := a.GetOpenAITokenExpiresAt()
  1871. if expiresAt == nil {
  1872. return false
  1873. }
  1874. return time.Now().Add(60 * time.Second).After(*expiresAt)
  1875. }
  1876. // IsMixedSchedulingEnabled 检查 antigravity 账户是否启用混合调度
  1877. // 启用后可参与 anthropic/gemini 分组的账户调度
  1878. func (a *Account) IsMixedSchedulingEnabled() bool {
  1879. if a.Platform != PlatformAntigravity {
  1880. return false
  1881. }
  1882. if a.Extra == nil {
  1883. return false
  1884. }
  1885. if v, ok := a.Extra["mixed_scheduling"]; ok {
  1886. if enabled, ok := v.(bool); ok {
  1887. return enabled
  1888. }
  1889. }
  1890. return false
  1891. }
  1892. // IsOveragesEnabled 检查 Antigravity 账号是否启用 AI Credits 超量请求。
  1893. func (a *Account) IsOveragesEnabled() bool {
  1894. if a.Platform != PlatformAntigravity {
  1895. return false
  1896. }
  1897. if a.Extra == nil {
  1898. return false
  1899. }
  1900. if v, ok := a.Extra["allow_overages"]; ok {
  1901. if enabled, ok := v.(bool); ok {
  1902. return enabled
  1903. }
  1904. }
  1905. return false
  1906. }
  1907. // IsOpenAIPassthroughEnabled 返回 OpenAI 账号是否启用"自动透传(仅替换认证)"。
  1908. //
  1909. // 新字段:accounts.extra.openai_passthrough。
  1910. // 兼容字段:accounts.extra.openai_oauth_passthrough(历史 OAuth 开关)。
  1911. // 字段缺失或类型不正确时,按 false(关闭)处理。
  1912. func (a *Account) IsOpenAIPassthroughEnabled() bool {
  1913. if a == nil || !a.IsOpenAI() || a.Extra == nil {
  1914. return false
  1915. }
  1916. if enabled, ok := a.Extra["openai_passthrough"].(bool); ok {
  1917. return enabled
  1918. }
  1919. if enabled, ok := a.Extra["openai_oauth_passthrough"].(bool); ok {
  1920. return enabled
  1921. }
  1922. return false
  1923. }
  1924. // IsOpenAIResponsesWebSocketV2Enabled 返回 OpenAI 账号是否开启 Responses WebSocket v2。
  1925. //
  1926. // 分类型新字段:
  1927. // - OAuth 账号:accounts.extra.openai_oauth_responses_websockets_v2_enabled
  1928. // - API Key 账号:accounts.extra.openai_apikey_responses_websockets_v2_enabled
  1929. //
  1930. // 兼容字段:
  1931. // - accounts.extra.responses_websockets_v2_enabled
  1932. // - accounts.extra.openai_ws_enabled(历史开关)
  1933. //
  1934. // 优先级:
  1935. // 1. 按账号类型读取分类型字段
  1936. // 2. 分类型字段缺失时,回退兼容字段
  1937. func (a *Account) IsOpenAIResponsesWebSocketV2Enabled() bool {
  1938. if a == nil || !a.IsOpenAI() || a.Extra == nil {
  1939. return false
  1940. }
  1941. if a.IsOpenAIOAuthLike() {
  1942. if enabled, ok := a.Extra["openai_oauth_responses_websockets_v2_enabled"].(bool); ok {
  1943. return enabled
  1944. }
  1945. }
  1946. if a.IsOpenAIApiKey() {
  1947. if enabled, ok := a.Extra["openai_apikey_responses_websockets_v2_enabled"].(bool); ok {
  1948. return enabled
  1949. }
  1950. }
  1951. if enabled, ok := a.Extra["responses_websockets_v2_enabled"].(bool); ok {
  1952. return enabled
  1953. }
  1954. if enabled, ok := a.Extra["openai_ws_enabled"].(bool); ok {
  1955. return enabled
  1956. }
  1957. return false
  1958. }
  1959. const (
  1960. OpenAIWSIngressModeOff = "off"
  1961. OpenAIWSIngressModeShared = "shared"
  1962. OpenAIWSIngressModeDedicated = "dedicated"
  1963. OpenAIWSIngressModeCtxPool = "ctx_pool"
  1964. OpenAIWSIngressModePassthrough = "passthrough"
  1965. OpenAIWSIngressModeHTTPBridge = "http_bridge"
  1966. )
  1967. func normalizeOpenAIWSIngressMode(mode string) string {
  1968. switch strings.ToLower(strings.TrimSpace(mode)) {
  1969. case OpenAIWSIngressModeOff:
  1970. return OpenAIWSIngressModeOff
  1971. case OpenAIWSIngressModeCtxPool:
  1972. return OpenAIWSIngressModeCtxPool
  1973. case OpenAIWSIngressModePassthrough:
  1974. return OpenAIWSIngressModePassthrough
  1975. case OpenAIWSIngressModeHTTPBridge:
  1976. return OpenAIWSIngressModeHTTPBridge
  1977. case OpenAIWSIngressModeShared:
  1978. return OpenAIWSIngressModeShared
  1979. case OpenAIWSIngressModeDedicated:
  1980. return OpenAIWSIngressModeDedicated
  1981. default:
  1982. return ""
  1983. }
  1984. }
  1985. func normalizeOpenAIWSIngressDefaultMode(mode string) string {
  1986. if normalized := normalizeOpenAIWSIngressMode(mode); normalized != "" {
  1987. if normalized == OpenAIWSIngressModeShared || normalized == OpenAIWSIngressModeDedicated {
  1988. return OpenAIWSIngressModeCtxPool
  1989. }
  1990. return normalized
  1991. }
  1992. return OpenAIWSIngressModeCtxPool
  1993. }
  1994. // ResolveOpenAIResponsesWebSocketV2Mode 返回账号在 WSv2 ingress 下的有效模式(off/ctx_pool/passthrough)。
  1995. //
  1996. // 优先级:
  1997. // 1. 分类型 mode 新字段(string)
  1998. // 2. 分类型 enabled 旧字段(bool)
  1999. // 3. 兼容 enabled 旧字段(bool)
  2000. // 4. defaultMode(非法时回退 ctx_pool)
  2001. func (a *Account) ResolveOpenAIResponsesWebSocketV2Mode(defaultMode string) string {
  2002. resolvedDefault := normalizeOpenAIWSIngressDefaultMode(defaultMode)
  2003. if a == nil || !a.IsOpenAI() {
  2004. return OpenAIWSIngressModeOff
  2005. }
  2006. if a.Extra == nil {
  2007. return resolvedDefault
  2008. }
  2009. resolveModeString := func(key string) (string, bool) {
  2010. raw, ok := a.Extra[key]
  2011. if !ok {
  2012. return "", false
  2013. }
  2014. mode, ok := raw.(string)
  2015. if !ok {
  2016. return "", false
  2017. }
  2018. normalized := normalizeOpenAIWSIngressMode(mode)
  2019. if normalized == "" {
  2020. return "", false
  2021. }
  2022. return normalized, true
  2023. }
  2024. resolveBoolMode := func(key string) (string, bool) {
  2025. raw, ok := a.Extra[key]
  2026. if !ok {
  2027. return "", false
  2028. }
  2029. enabled, ok := raw.(bool)
  2030. if !ok {
  2031. return "", false
  2032. }
  2033. if enabled {
  2034. return OpenAIWSIngressModeCtxPool, true
  2035. }
  2036. return OpenAIWSIngressModeOff, true
  2037. }
  2038. if a.IsOpenAIOAuthLike() {
  2039. if mode, ok := resolveModeString("openai_oauth_responses_websockets_v2_mode"); ok {
  2040. return mode
  2041. }
  2042. if mode, ok := resolveBoolMode("openai_oauth_responses_websockets_v2_enabled"); ok {
  2043. return mode
  2044. }
  2045. }
  2046. if a.IsOpenAIApiKey() {
  2047. if mode, ok := resolveModeString("openai_apikey_responses_websockets_v2_mode"); ok {
  2048. return mode
  2049. }
  2050. if mode, ok := resolveBoolMode("openai_apikey_responses_websockets_v2_enabled"); ok {
  2051. return mode
  2052. }
  2053. }
  2054. if mode, ok := resolveBoolMode("responses_websockets_v2_enabled"); ok {
  2055. return mode
  2056. }
  2057. if mode, ok := resolveBoolMode("openai_ws_enabled"); ok {
  2058. return mode
  2059. }
  2060. // 兼容旧值:shared/dedicated 语义都归并到 ctx_pool。
  2061. if resolvedDefault == OpenAIWSIngressModeShared || resolvedDefault == OpenAIWSIngressModeDedicated {
  2062. return OpenAIWSIngressModeCtxPool
  2063. }
  2064. return resolvedDefault
  2065. }
  2066. // IsOpenAIWSForceHTTPEnabled 返回账号级"强制 HTTP"开关。
  2067. // 字段:accounts.extra.openai_ws_force_http。
  2068. func (a *Account) IsOpenAIWSForceHTTPEnabled() bool {
  2069. if a == nil || !a.IsOpenAI() || a.Extra == nil {
  2070. return false
  2071. }
  2072. enabled, ok := a.Extra["openai_ws_force_http"].(bool)
  2073. return ok && enabled
  2074. }
  2075. // IsOpenAIResponsesFlattenNamespacesEnabled 返回账号级"摊平 Codex namespace 工具"开关。
  2076. // 字段:accounts.extra.openai_responses_flatten_namespaces,缺省 false(原样保留)。
  2077. //
  2078. // namespace 是 Codex 后端定义的私有扩展,OAuth 出口恒为 chatgpt.com/backend-api/codex
  2079. // (buildUpstreamRequest 只对 API Key 账号取 base_url),即定义方本身,因此默认保留。
  2080. // 该开关只为把流量转发到不认识 namespace 的兼容上游的部署保留退路:打开后恢复
  2081. // 0.1.166 及更早版本的摊平行为。仅对 OpenAI OAuth 账号有效——API Key 走 chat
  2082. // completions 回退桥时由桥自行摊平,Grok/Anthropic 出口有各自的适配链路。
  2083. func (a *Account) IsOpenAIResponsesFlattenNamespacesEnabled() bool {
  2084. if a == nil || !a.IsOpenAI() || a.Extra == nil {
  2085. return false
  2086. }
  2087. enabled, ok := a.Extra["openai_responses_flatten_namespaces"].(bool)
  2088. return ok && enabled
  2089. }
  2090. // IsOpenAIWSAllowStoreRecoveryEnabled 返回账号级 store 恢复开关。
  2091. // 字段:accounts.extra.openai_ws_allow_store_recovery。
  2092. func (a *Account) IsOpenAIWSAllowStoreRecoveryEnabled() bool {
  2093. if a == nil || !a.IsOpenAI() || a.Extra == nil {
  2094. return false
  2095. }
  2096. enabled, ok := a.Extra["openai_ws_allow_store_recovery"].(bool)
  2097. return ok && enabled
  2098. }
  2099. // IsOpenAIOAuthPassthroughEnabled 兼容旧接口,等价于 OAuth 账号的 IsOpenAIPassthroughEnabled。
  2100. func (a *Account) IsOpenAIOAuthPassthroughEnabled() bool {
  2101. return a != nil && a.IsOpenAIOAuth() && a.IsOpenAIPassthroughEnabled()
  2102. }
  2103. // IsAnthropicAPIKeyPassthroughEnabled 返回 Anthropic API Key 账号是否启用"自动透传(仅替换认证)"。
  2104. // 字段:accounts.extra.anthropic_passthrough。
  2105. // 字段缺失或类型不正确时,按 false(关闭)处理。
  2106. func (a *Account) IsAnthropicAPIKeyPassthroughEnabled() bool {
  2107. if a == nil || a.Platform != PlatformAnthropic || a.Type != AccountTypeAPIKey || a.Extra == nil {
  2108. return false
  2109. }
  2110. enabled, ok := a.Extra["anthropic_passthrough"].(bool)
  2111. return ok && enabled
  2112. }
  2113. // WebSearch 模拟三态常量
  2114. const (
  2115. WebSearchModeDefault = "default" // 跟随渠道配置
  2116. WebSearchModeEnabled = "enabled" // 强制开启
  2117. WebSearchModeDisabled = "disabled" // 强制关闭
  2118. )
  2119. // GetWebSearchEmulationMode 返回账号的 WebSearch 模拟模式。
  2120. // 三态:default(跟随渠道)/ enabled(强制开启)/ disabled(强制关闭)。
  2121. // 兼容旧 bool 值:true→enabled, false→default(并记录 debug 日志)。
  2122. func (a *Account) GetWebSearchEmulationMode() string {
  2123. if a == nil || a.Platform != PlatformAnthropic || a.Type != AccountTypeAPIKey || a.Extra == nil {
  2124. return WebSearchModeDefault
  2125. }
  2126. raw := a.Extra[featureKeyWebSearchEmulation]
  2127. // Tolerant: legacy bool values (pre-migration or stale writes)
  2128. if b, ok := raw.(bool); ok {
  2129. slog.Debug("legacy bool web_search_emulation value", "account_id", a.ID, "value", b)
  2130. if b {
  2131. return WebSearchModeEnabled
  2132. }
  2133. return WebSearchModeDefault
  2134. }
  2135. mode, ok := raw.(string)
  2136. if !ok {
  2137. return WebSearchModeDefault
  2138. }
  2139. switch mode {
  2140. case WebSearchModeEnabled, WebSearchModeDisabled:
  2141. return mode
  2142. default:
  2143. return WebSearchModeDefault
  2144. }
  2145. }
  2146. // IsCodexCLIOnlyEnabled 返回 OpenAI OAuth 账号是否启用"仅允许 Codex 官方客户端"。
  2147. // 字段:accounts.extra.codex_cli_only。
  2148. // 字段缺失或类型不正确时,按 false(关闭)处理。
  2149. func (a *Account) IsCodexCLIOnlyEnabled() bool {
  2150. if a == nil || !a.IsOpenAIOAuth() || a.Extra == nil {
  2151. return false
  2152. }
  2153. enabled, ok := a.Extra["codex_cli_only"].(bool)
  2154. return ok && enabled
  2155. }
  2156. // IsCodexCLIOnlyAppServerAllowed 返回 codex_cli_only 账号是否额外放行 Codex app-server
  2157. // 第三方客户端(运行时与全局 app_server 开关 OR)。字段:accounts.extra.codex_cli_only_allow_app_server。
  2158. // 仅在 codex_cli_only 已启用时有意义;字段缺失或类型不符按 false(不放行)处理。
  2159. func (a *Account) IsCodexCLIOnlyAppServerAllowed() bool {
  2160. if !a.IsCodexCLIOnlyEnabled() {
  2161. return false
  2162. }
  2163. v, ok := a.Extra["codex_cli_only_allow_app_server"].(bool)
  2164. return ok && v
  2165. }
  2166. // WindowCostSchedulability 窗口费用调度状态
  2167. type WindowCostSchedulability int
  2168. const (
  2169. // WindowCostSchedulable 可正常调度
  2170. WindowCostSchedulable WindowCostSchedulability = iota
  2171. // WindowCostStickyOnly 仅允许粘性会话
  2172. WindowCostStickyOnly
  2173. // WindowCostNotSchedulable 完全不可调度
  2174. WindowCostNotSchedulable
  2175. )
  2176. // IsAnthropicOAuthOrSetupToken 判断是否为 Anthropic OAuth 或 SetupToken 类型账号
  2177. // 仅这两类账号支持 5h 窗口额度控制和会话数量控制
  2178. func (a *Account) IsAnthropicOAuthOrSetupToken() bool {
  2179. return a.Platform == PlatformAnthropic && (a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken)
  2180. }
  2181. // IsTLSFingerprintEnabled 检查是否启用 TLS 指纹伪装
  2182. // 适用于 Anthropic 与 OpenAI OAuth/SetupToken 类型账号。
  2183. // 启用后使用账号绑定的完整 TLS ClientHello 模板。
  2184. func (a *Account) IsTLSFingerprintEnabled() bool {
  2185. if a == nil || (a.Platform != PlatformAnthropic && a.Platform != PlatformOpenAI) ||
  2186. (a.Type != AccountTypeOAuth && a.Type != AccountTypeSetupToken) {
  2187. return false
  2188. }
  2189. if a.Extra == nil {
  2190. return false
  2191. }
  2192. if v, ok := a.Extra["enable_tls_fingerprint"]; ok {
  2193. if enabled, ok := v.(bool); ok {
  2194. return enabled
  2195. }
  2196. }
  2197. return false
  2198. }
  2199. // GetTLSFingerprintProfileID 获取账号绑定的 TLS 指纹模板 ID
  2200. // 返回 0 表示未绑定(使用内置默认 profile)
  2201. func (a *Account) GetTLSFingerprintProfileID() int64 {
  2202. if a.Extra == nil {
  2203. return 0
  2204. }
  2205. v, ok := a.Extra["tls_fingerprint_profile_id"]
  2206. if !ok {
  2207. return 0
  2208. }
  2209. switch id := v.(type) {
  2210. case float64:
  2211. return int64(id)
  2212. case int64:
  2213. return id
  2214. case int:
  2215. return int64(id)
  2216. case json.Number:
  2217. if i, err := id.Int64(); err == nil {
  2218. return i
  2219. }
  2220. }
  2221. return 0
  2222. }
  2223. // GetUserMsgQueueMode 获取用户消息队列模式
  2224. // "serialize" = 串行队列, "throttle" = 软性限速, "" = 未设置(使用全局配置)
  2225. func (a *Account) GetUserMsgQueueMode() string {
  2226. if a.Extra == nil {
  2227. return ""
  2228. }
  2229. // 优先读取新字段 user_msg_queue_mode(白名单校验,非法值视为未设置)
  2230. if mode, ok := a.Extra["user_msg_queue_mode"].(string); ok && mode != "" {
  2231. if mode == config.UMQModeSerialize || mode == config.UMQModeThrottle {
  2232. return mode
  2233. }
  2234. return "" // 非法值 fallback 到全局配置
  2235. }
  2236. // 向后兼容: user_msg_queue_enabled: true → "serialize"
  2237. if enabled, ok := a.Extra["user_msg_queue_enabled"].(bool); ok && enabled {
  2238. return config.UMQModeSerialize
  2239. }
  2240. return ""
  2241. }
  2242. // IsSessionIDMaskingEnabled 检查是否启用会话ID伪装
  2243. // 仅适用于 Anthropic OAuth/SetupToken 类型账号
  2244. // 启用后将在一段时间内(15分钟)固定 metadata.user_id 中的 session ID,
  2245. // 使上游认为请求来自同一个会话
  2246. func (a *Account) IsSessionIDMaskingEnabled() bool {
  2247. if !a.IsAnthropicOAuthOrSetupToken() {
  2248. return false
  2249. }
  2250. if a.Extra == nil {
  2251. return false
  2252. }
  2253. if v, ok := a.Extra["session_id_masking_enabled"]; ok {
  2254. if enabled, ok := v.(bool); ok {
  2255. return enabled
  2256. }
  2257. }
  2258. return false
  2259. }
  2260. // IsCustomBaseURLEnabled 检查是否启用自定义 base URL 中继转发
  2261. // 仅适用于 Anthropic OAuth/SetupToken 类型账号
  2262. func (a *Account) IsCustomBaseURLEnabled() bool {
  2263. if !a.IsAnthropicOAuthOrSetupToken() {
  2264. return false
  2265. }
  2266. if a.Extra == nil {
  2267. return false
  2268. }
  2269. if v, ok := a.Extra["custom_base_url_enabled"]; ok {
  2270. if enabled, ok := v.(bool); ok {
  2271. return enabled
  2272. }
  2273. }
  2274. return false
  2275. }
  2276. // GetCustomBaseURL 返回自定义中继服务的 base URL
  2277. func (a *Account) GetCustomBaseURL() string {
  2278. return a.GetExtraString("custom_base_url")
  2279. }
  2280. // IsCacheTTLOverrideEnabled 检查是否启用缓存 TTL 强制替换
  2281. // 仅适用于 Anthropic OAuth/SetupToken 类型账号
  2282. // 启用后将所有 cache creation tokens 归入指定的 TTL 类型(5m 或 1h)
  2283. func (a *Account) IsCacheTTLOverrideEnabled() bool {
  2284. if !a.IsAnthropicOAuthOrSetupToken() {
  2285. return false
  2286. }
  2287. if a.Extra == nil {
  2288. return false
  2289. }
  2290. if v, ok := a.Extra["cache_ttl_override_enabled"]; ok {
  2291. if enabled, ok := v.(bool); ok {
  2292. return enabled
  2293. }
  2294. }
  2295. return false
  2296. }
  2297. // GetCacheTTLOverrideTarget 获取缓存 TTL 强制替换的目标类型
  2298. // 返回 "5m" 或 "1h",默认 "5m"
  2299. func (a *Account) GetCacheTTLOverrideTarget() string {
  2300. if a.Extra == nil {
  2301. return "5m"
  2302. }
  2303. if v, ok := a.Extra["cache_ttl_override_target"]; ok {
  2304. if target, ok := v.(string); ok && (target == "5m" || target == "1h") {
  2305. return target
  2306. }
  2307. }
  2308. return "5m"
  2309. }
  2310. // GetQuotaLimit 获取 API Key 账号的配额限制(美元)
  2311. // 返回 0 表示未启用
  2312. func (a *Account) GetQuotaLimit() float64 {
  2313. return a.getExtraFloat64("quota_limit")
  2314. }
  2315. // GetQuotaUsed 获取 API Key 账号的已用配额(美元)
  2316. func (a *Account) GetQuotaUsed() float64 {
  2317. return a.getExtraFloat64("quota_used")
  2318. }
  2319. // GetQuotaDailyLimit 获取日额度限制(美元),0 表示未启用
  2320. func (a *Account) GetQuotaDailyLimit() float64 {
  2321. return a.getExtraFloat64("quota_daily_limit")
  2322. }
  2323. // GetQuotaDailyUsed 获取当日已用额度(美元)
  2324. func (a *Account) GetQuotaDailyUsed() float64 {
  2325. return a.getExtraFloat64("quota_daily_used")
  2326. }
  2327. // GetQuotaWeeklyLimit 获取周额度限制(美元),0 表示未启用
  2328. func (a *Account) GetQuotaWeeklyLimit() float64 {
  2329. return a.getExtraFloat64("quota_weekly_limit")
  2330. }
  2331. // GetQuotaWeeklyUsed 获取本周已用额度(美元)
  2332. func (a *Account) GetQuotaWeeklyUsed() float64 {
  2333. return a.getExtraFloat64("quota_weekly_used")
  2334. }
  2335. // getExtraFloat64 从 Extra 中读取指定 key 的 float64 值
  2336. func (a *Account) getExtraFloat64(key string) float64 {
  2337. if a.Extra == nil {
  2338. return 0
  2339. }
  2340. if v, ok := a.Extra[key]; ok {
  2341. return parseExtraFloat64(v)
  2342. }
  2343. return 0
  2344. }
  2345. // getExtraTime 从 Extra 中读取 RFC3339 时间戳
  2346. func (a *Account) getExtraTime(key string) time.Time {
  2347. if a.Extra == nil {
  2348. return time.Time{}
  2349. }
  2350. if v, ok := a.Extra[key]; ok {
  2351. if s, ok := v.(string); ok {
  2352. if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
  2353. return t
  2354. }
  2355. if t, err := time.Parse(time.RFC3339, s); err == nil {
  2356. return t
  2357. }
  2358. }
  2359. }
  2360. return time.Time{}
  2361. }
  2362. // getExtraBool 从 Extra 中读取指定 key 的 bool 值
  2363. func (a *Account) getExtraBool(key string) bool {
  2364. if a.Extra == nil {
  2365. return false
  2366. }
  2367. if v, ok := a.Extra[key]; ok {
  2368. if b, ok := v.(bool); ok {
  2369. return b
  2370. }
  2371. }
  2372. return false
  2373. }
  2374. // getExtraString 从 Extra 中读取指定 key 的字符串值
  2375. func (a *Account) getExtraString(key string) string {
  2376. if a.Extra == nil {
  2377. return ""
  2378. }
  2379. if v, ok := a.Extra[key]; ok {
  2380. if s, ok := v.(string); ok {
  2381. return s
  2382. }
  2383. }
  2384. return ""
  2385. }
  2386. // getExtraStringDefault 从 Extra 中读取指定 key 的字符串值,不存在时返回 defaultVal
  2387. func (a *Account) getExtraStringDefault(key, defaultVal string) string {
  2388. if v := a.getExtraString(key); v != "" {
  2389. return v
  2390. }
  2391. return defaultVal
  2392. }
  2393. // getExtraInt 从 Extra 中读取指定 key 的 int 值
  2394. func (a *Account) getExtraInt(key string) int {
  2395. if a.Extra == nil {
  2396. return 0
  2397. }
  2398. if v, ok := a.Extra[key]; ok {
  2399. return int(parseExtraFloat64(v))
  2400. }
  2401. return 0
  2402. }
  2403. // GetQuotaDailyResetMode 获取日额度重置模式:"rolling"(默认)或 "fixed"
  2404. func (a *Account) GetQuotaDailyResetMode() string {
  2405. if m := a.getExtraString("quota_daily_reset_mode"); m == "fixed" {
  2406. return "fixed"
  2407. }
  2408. return "rolling"
  2409. }
  2410. // GetQuotaDailyResetHour 获取固定重置的小时(0-23),默认 0
  2411. func (a *Account) GetQuotaDailyResetHour() int {
  2412. return a.getExtraInt("quota_daily_reset_hour")
  2413. }
  2414. // GetQuotaWeeklyResetMode 获取周额度重置模式:"rolling"(默认)或 "fixed"
  2415. func (a *Account) GetQuotaWeeklyResetMode() string {
  2416. if m := a.getExtraString("quota_weekly_reset_mode"); m == "fixed" {
  2417. return "fixed"
  2418. }
  2419. return "rolling"
  2420. }
  2421. // GetQuotaWeeklyResetDay 获取固定重置的星期几(0=周日, 1=周一, ..., 6=周六),默认 1(周一)
  2422. func (a *Account) GetQuotaWeeklyResetDay() int {
  2423. if a.Extra == nil {
  2424. return 1
  2425. }
  2426. if _, ok := a.Extra["quota_weekly_reset_day"]; !ok {
  2427. return 1
  2428. }
  2429. return a.getExtraInt("quota_weekly_reset_day")
  2430. }
  2431. // GetQuotaWeeklyResetHour 获取周配额固定重置的小时(0-23),默认 0
  2432. func (a *Account) GetQuotaWeeklyResetHour() int {
  2433. return a.getExtraInt("quota_weekly_reset_hour")
  2434. }
  2435. // GetQuotaResetTimezone 获取固定重置的时区名(IANA),默认 "UTC"
  2436. func (a *Account) GetQuotaResetTimezone() string {
  2437. if tz := a.getExtraString("quota_reset_timezone"); tz != "" {
  2438. return tz
  2439. }
  2440. return "UTC"
  2441. }
  2442. // --- Quota Notification Getters ---
  2443. // QuotaNotifyConfig returns the notify configuration for a given quota dimension.
  2444. // dim must be one of quotaDimDaily, quotaDimWeekly, quotaDimTotal.
  2445. func (a *Account) QuotaNotifyConfig(dim string) (enabled bool, threshold float64, thresholdType string) {
  2446. enabled = a.getExtraBool("quota_notify_" + dim + "_enabled")
  2447. threshold = a.getExtraFloat64("quota_notify_" + dim + "_threshold")
  2448. thresholdType = a.getExtraStringDefault("quota_notify_"+dim+"_threshold_type", thresholdTypeFixed)
  2449. return
  2450. }
  2451. func (a *Account) GetQuotaNotifyDailyEnabled() bool {
  2452. e, _, _ := a.QuotaNotifyConfig(quotaDimDaily)
  2453. return e
  2454. }
  2455. func (a *Account) GetQuotaNotifyDailyThreshold() float64 {
  2456. _, t, _ := a.QuotaNotifyConfig(quotaDimDaily)
  2457. return t
  2458. }
  2459. func (a *Account) GetQuotaNotifyDailyThresholdType() string {
  2460. _, _, tt := a.QuotaNotifyConfig(quotaDimDaily)
  2461. return tt
  2462. }
  2463. func (a *Account) GetQuotaNotifyWeeklyEnabled() bool {
  2464. e, _, _ := a.QuotaNotifyConfig(quotaDimWeekly)
  2465. return e
  2466. }
  2467. func (a *Account) GetQuotaNotifyWeeklyThreshold() float64 {
  2468. _, t, _ := a.QuotaNotifyConfig(quotaDimWeekly)
  2469. return t
  2470. }
  2471. func (a *Account) GetQuotaNotifyWeeklyThresholdType() string {
  2472. _, _, tt := a.QuotaNotifyConfig(quotaDimWeekly)
  2473. return tt
  2474. }
  2475. func (a *Account) GetQuotaNotifyTotalEnabled() bool {
  2476. e, _, _ := a.QuotaNotifyConfig(quotaDimTotal)
  2477. return e
  2478. }
  2479. func (a *Account) GetQuotaNotifyTotalThreshold() float64 {
  2480. _, t, _ := a.QuotaNotifyConfig(quotaDimTotal)
  2481. return t
  2482. }
  2483. func (a *Account) GetQuotaNotifyTotalThresholdType() string {
  2484. _, _, tt := a.QuotaNotifyConfig(quotaDimTotal)
  2485. return tt
  2486. }
  2487. // nextFixedDailyReset 计算在 after 之后的下一个每日固定重置时间点
  2488. func nextFixedDailyReset(hour int, tz *time.Location, after time.Time) time.Time {
  2489. t := after.In(tz)
  2490. today := time.Date(t.Year(), t.Month(), t.Day(), hour, 0, 0, 0, tz)
  2491. if !after.Before(today) {
  2492. return today.AddDate(0, 0, 1)
  2493. }
  2494. return today
  2495. }
  2496. // lastFixedDailyReset 计算 now 之前最近一次的每日固定重置时间点
  2497. func lastFixedDailyReset(hour int, tz *time.Location, now time.Time) time.Time {
  2498. t := now.In(tz)
  2499. today := time.Date(t.Year(), t.Month(), t.Day(), hour, 0, 0, 0, tz)
  2500. if now.Before(today) {
  2501. return today.AddDate(0, 0, -1)
  2502. }
  2503. return today
  2504. }
  2505. // nextFixedWeeklyReset 计算在 after 之后的下一个每周固定重置时间点
  2506. // day: 0=Sunday, 1=Monday, ..., 6=Saturday
  2507. func nextFixedWeeklyReset(day, hour int, tz *time.Location, after time.Time) time.Time {
  2508. t := after.In(tz)
  2509. todayReset := time.Date(t.Year(), t.Month(), t.Day(), hour, 0, 0, 0, tz)
  2510. currentDay := int(todayReset.Weekday())
  2511. daysForward := (day - currentDay + 7) % 7
  2512. if daysForward == 0 && !after.Before(todayReset) {
  2513. daysForward = 7
  2514. }
  2515. return todayReset.AddDate(0, 0, daysForward)
  2516. }
  2517. // lastFixedWeeklyReset 计算 now 之前最近一次的每周固定重置时间点
  2518. func lastFixedWeeklyReset(day, hour int, tz *time.Location, now time.Time) time.Time {
  2519. t := now.In(tz)
  2520. todayReset := time.Date(t.Year(), t.Month(), t.Day(), hour, 0, 0, 0, tz)
  2521. currentDay := int(todayReset.Weekday())
  2522. daysBack := (currentDay - day + 7) % 7
  2523. if daysBack == 0 && now.Before(todayReset) {
  2524. daysBack = 7
  2525. }
  2526. return todayReset.AddDate(0, 0, -daysBack)
  2527. }
  2528. // isFixedDailyPeriodExpired 检查日配额是否在固定时间模式下已过期
  2529. func (a *Account) isFixedDailyPeriodExpired(periodStart time.Time) bool {
  2530. if periodStart.IsZero() {
  2531. return true
  2532. }
  2533. tz, err := time.LoadLocation(a.GetQuotaResetTimezone())
  2534. if err != nil {
  2535. tz = time.UTC
  2536. }
  2537. lastReset := lastFixedDailyReset(a.GetQuotaDailyResetHour(), tz, time.Now())
  2538. return periodStart.Before(lastReset)
  2539. }
  2540. // isFixedWeeklyPeriodExpired 检查周配额是否在固定时间模式下已过期
  2541. func (a *Account) isFixedWeeklyPeriodExpired(periodStart time.Time) bool {
  2542. if periodStart.IsZero() {
  2543. return true
  2544. }
  2545. tz, err := time.LoadLocation(a.GetQuotaResetTimezone())
  2546. if err != nil {
  2547. tz = time.UTC
  2548. }
  2549. lastReset := lastFixedWeeklyReset(a.GetQuotaWeeklyResetDay(), a.GetQuotaWeeklyResetHour(), tz, time.Now())
  2550. return periodStart.Before(lastReset)
  2551. }
  2552. // ComputeQuotaResetAt 根据当前配置计算并填充 extra 中的 quota_daily_reset_at / quota_weekly_reset_at
  2553. // 在保存账号配置时调用
  2554. func ComputeQuotaResetAt(extra map[string]any) {
  2555. now := time.Now()
  2556. tzName, _ := extra["quota_reset_timezone"].(string)
  2557. if tzName == "" {
  2558. tzName = "UTC"
  2559. }
  2560. tz, err := time.LoadLocation(tzName)
  2561. if err != nil {
  2562. tz = time.UTC
  2563. }
  2564. // 日配额固定重置时间
  2565. if mode, _ := extra["quota_daily_reset_mode"].(string); mode == "fixed" {
  2566. hour := int(parseExtraFloat64(extra["quota_daily_reset_hour"]))
  2567. if hour < 0 || hour > 23 {
  2568. hour = 0
  2569. }
  2570. resetAt := nextFixedDailyReset(hour, tz, now)
  2571. extra["quota_daily_reset_at"] = resetAt.UTC().Format(time.RFC3339)
  2572. } else {
  2573. delete(extra, "quota_daily_reset_at")
  2574. }
  2575. // 周配额固定重置时间
  2576. if mode, _ := extra["quota_weekly_reset_mode"].(string); mode == "fixed" {
  2577. day := 1 // 默认周一
  2578. if d, ok := extra["quota_weekly_reset_day"]; ok {
  2579. day = int(parseExtraFloat64(d))
  2580. }
  2581. if day < 0 || day > 6 {
  2582. day = 1
  2583. }
  2584. hour := int(parseExtraFloat64(extra["quota_weekly_reset_hour"]))
  2585. if hour < 0 || hour > 23 {
  2586. hour = 0
  2587. }
  2588. resetAt := nextFixedWeeklyReset(day, hour, tz, now)
  2589. extra["quota_weekly_reset_at"] = resetAt.UTC().Format(time.RFC3339)
  2590. } else {
  2591. delete(extra, "quota_weekly_reset_at")
  2592. }
  2593. }
  2594. // NormalizeFixedQuotaWindows aligns preserved quota usage with the active fixed reset window.
  2595. //
  2596. // Editing an existing account can switch a daily/weekly quota from rolling to fixed reset
  2597. // while preserving quota_*_used and quota_*_start. If the preserved start belongs to the
  2598. // old rolling window, response mapping treats the usage as expired and the dashboard shows
  2599. // 0 until the next reset. Normalize those stale starts before persisting the edited account.
  2600. func NormalizeFixedQuotaWindows(extra map[string]any) {
  2601. if extra == nil {
  2602. return
  2603. }
  2604. now := time.Now()
  2605. tzName, _ := extra["quota_reset_timezone"].(string)
  2606. if tzName == "" {
  2607. tzName = "UTC"
  2608. }
  2609. tz, err := time.LoadLocation(tzName)
  2610. if err != nil {
  2611. tz = time.UTC
  2612. }
  2613. if mode, _ := extra["quota_daily_reset_mode"].(string); mode == "fixed" && parseExtraFloat64(extra["quota_daily_limit"]) > 0 {
  2614. hour := int(parseExtraFloat64(extra["quota_daily_reset_hour"]))
  2615. if hour < 0 || hour > 23 {
  2616. hour = 0
  2617. }
  2618. lastReset := lastFixedDailyReset(hour, tz, now)
  2619. start := parseExtraTime(extra["quota_daily_start"])
  2620. if start.IsZero() || start.Before(lastReset) {
  2621. extra["quota_daily_used"] = 0.0
  2622. extra["quota_daily_start"] = lastReset.UTC().Format(time.RFC3339)
  2623. }
  2624. }
  2625. if mode, _ := extra["quota_weekly_reset_mode"].(string); mode == "fixed" && parseExtraFloat64(extra["quota_weekly_limit"]) > 0 {
  2626. day := 1
  2627. if rawDay, ok := extra["quota_weekly_reset_day"]; ok {
  2628. day = int(parseExtraFloat64(rawDay))
  2629. }
  2630. if day < 0 || day > 6 {
  2631. day = 1
  2632. }
  2633. hour := int(parseExtraFloat64(extra["quota_weekly_reset_hour"]))
  2634. if hour < 0 || hour > 23 {
  2635. hour = 0
  2636. }
  2637. lastReset := lastFixedWeeklyReset(day, hour, tz, now)
  2638. start := parseExtraTime(extra["quota_weekly_start"])
  2639. if start.IsZero() || start.Before(lastReset) {
  2640. extra["quota_weekly_used"] = 0.0
  2641. extra["quota_weekly_start"] = lastReset.UTC().Format(time.RFC3339)
  2642. }
  2643. }
  2644. }
  2645. // ValidateQuotaResetConfig 校验配额固定重置时间配置的合法性
  2646. func ValidateQuotaResetConfig(extra map[string]any) error {
  2647. if extra == nil {
  2648. return nil
  2649. }
  2650. // 校验时区
  2651. if tz, ok := extra["quota_reset_timezone"].(string); ok && tz != "" {
  2652. if _, err := time.LoadLocation(tz); err != nil {
  2653. return errors.New("invalid quota_reset_timezone: must be a valid IANA timezone name")
  2654. }
  2655. }
  2656. // 日配额重置模式
  2657. if mode, ok := extra["quota_daily_reset_mode"].(string); ok {
  2658. if mode != "rolling" && mode != "fixed" {
  2659. return errors.New("quota_daily_reset_mode must be 'rolling' or 'fixed'")
  2660. }
  2661. }
  2662. // 日配额重置小时
  2663. if v, ok := extra["quota_daily_reset_hour"]; ok {
  2664. hour := int(parseExtraFloat64(v))
  2665. if hour < 0 || hour > 23 {
  2666. return errors.New("quota_daily_reset_hour must be between 0 and 23")
  2667. }
  2668. }
  2669. // 周配额重置模式
  2670. if mode, ok := extra["quota_weekly_reset_mode"].(string); ok {
  2671. if mode != "rolling" && mode != "fixed" {
  2672. return errors.New("quota_weekly_reset_mode must be 'rolling' or 'fixed'")
  2673. }
  2674. }
  2675. // 周配额重置星期几
  2676. if v, ok := extra["quota_weekly_reset_day"]; ok {
  2677. day := int(parseExtraFloat64(v))
  2678. if day < 0 || day > 6 {
  2679. return errors.New("quota_weekly_reset_day must be between 0 (Sunday) and 6 (Saturday)")
  2680. }
  2681. }
  2682. // 周配额重置小时
  2683. if v, ok := extra["quota_weekly_reset_hour"]; ok {
  2684. hour := int(parseExtraFloat64(v))
  2685. if hour < 0 || hour > 23 {
  2686. return errors.New("quota_weekly_reset_hour must be between 0 and 23")
  2687. }
  2688. }
  2689. return nil
  2690. }
  2691. // HasAnyQuotaLimit 检查是否配置了任一维度的配额限制
  2692. func (a *Account) HasAnyQuotaLimit() bool {
  2693. return a.GetQuotaLimit() > 0 || a.GetQuotaDailyLimit() > 0 || a.GetQuotaWeeklyLimit() > 0
  2694. }
  2695. // isPeriodExpired 检查指定周期(自 periodStart 起经过 dur)是否已过期
  2696. func isPeriodExpired(periodStart time.Time, dur time.Duration) bool {
  2697. if periodStart.IsZero() {
  2698. return true // 从未使用过,视为过期(下次 increment 会初始化)
  2699. }
  2700. return time.Since(periodStart) >= dur
  2701. }
  2702. // IsDailyQuotaPeriodExpired 检查日配额周期是否已过期(用于显示层判断是否需要将 used 归零)
  2703. func (a *Account) IsDailyQuotaPeriodExpired() bool {
  2704. start := a.getExtraTime("quota_daily_start")
  2705. if a.GetQuotaDailyResetMode() == "fixed" {
  2706. return a.isFixedDailyPeriodExpired(start)
  2707. }
  2708. return isPeriodExpired(start, 24*time.Hour)
  2709. }
  2710. // IsWeeklyQuotaPeriodExpired 检查周配额周期是否已过期(用于显示层判断是否需要将 used 归零)
  2711. func (a *Account) IsWeeklyQuotaPeriodExpired() bool {
  2712. start := a.getExtraTime("quota_weekly_start")
  2713. if a.GetQuotaWeeklyResetMode() == "fixed" {
  2714. return a.isFixedWeeklyPeriodExpired(start)
  2715. }
  2716. return isPeriodExpired(start, 7*24*time.Hour)
  2717. }
  2718. // IsQuotaExceeded 检查 API Key 账号配额是否已超限(任一维度超限即返回 true)
  2719. func (a *Account) IsQuotaExceeded() bool {
  2720. // 总额度
  2721. if limit := a.GetQuotaLimit(); limit > 0 && a.GetQuotaUsed() >= limit {
  2722. return true
  2723. }
  2724. // 日额度(周期过期视为未超限,下次 increment 会重置)
  2725. if limit := a.GetQuotaDailyLimit(); limit > 0 {
  2726. start := a.getExtraTime("quota_daily_start")
  2727. var expired bool
  2728. if a.GetQuotaDailyResetMode() == "fixed" {
  2729. expired = a.isFixedDailyPeriodExpired(start)
  2730. } else {
  2731. expired = isPeriodExpired(start, 24*time.Hour)
  2732. }
  2733. if !expired && a.GetQuotaDailyUsed() >= limit {
  2734. return true
  2735. }
  2736. }
  2737. // 周额度
  2738. if limit := a.GetQuotaWeeklyLimit(); limit > 0 {
  2739. start := a.getExtraTime("quota_weekly_start")
  2740. var expired bool
  2741. if a.GetQuotaWeeklyResetMode() == "fixed" {
  2742. expired = a.isFixedWeeklyPeriodExpired(start)
  2743. } else {
  2744. expired = isPeriodExpired(start, 7*24*time.Hour)
  2745. }
  2746. if !expired && a.GetQuotaWeeklyUsed() >= limit {
  2747. return true
  2748. }
  2749. }
  2750. return false
  2751. }
  2752. // GetWindowCostLimit 获取 5h 窗口费用阈值(美元)
  2753. // 返回 0 表示未启用
  2754. func (a *Account) GetWindowCostLimit() float64 {
  2755. if a.Extra == nil {
  2756. return 0
  2757. }
  2758. if v, ok := a.Extra["window_cost_limit"]; ok {
  2759. return parseExtraFloat64(v)
  2760. }
  2761. return 0
  2762. }
  2763. // GetWindowCostStickyReserve 获取粘性会话预留额度(美元)
  2764. // 默认值为 10
  2765. func (a *Account) GetWindowCostStickyReserve() float64 {
  2766. if a.Extra == nil {
  2767. return 10.0
  2768. }
  2769. if v, ok := a.Extra["window_cost_sticky_reserve"]; ok {
  2770. val := parseExtraFloat64(v)
  2771. if val > 0 {
  2772. return val
  2773. }
  2774. }
  2775. return 10.0
  2776. }
  2777. // GetMaxSessions 获取最大并发会话数
  2778. // 返回 0 表示未启用
  2779. func (a *Account) GetMaxSessions() int {
  2780. if a.Extra == nil {
  2781. return 0
  2782. }
  2783. if v, ok := a.Extra["max_sessions"]; ok {
  2784. return parseExtraInt(v)
  2785. }
  2786. return 0
  2787. }
  2788. // GetSessionIdleTimeoutMinutes 获取会话空闲超时分钟数
  2789. // 默认值为 5 分钟
  2790. func (a *Account) GetSessionIdleTimeoutMinutes() int {
  2791. if a.Extra == nil {
  2792. return 5
  2793. }
  2794. if v, ok := a.Extra["session_idle_timeout_minutes"]; ok {
  2795. val := parseExtraInt(v)
  2796. if val > 0 {
  2797. return val
  2798. }
  2799. }
  2800. return 5
  2801. }
  2802. // GetBaseRPM 获取基础 RPM 限制
  2803. // 返回 0 表示未启用(负数视为无效配置,按 0 处理)
  2804. func (a *Account) GetBaseRPM() int {
  2805. if a.Extra == nil {
  2806. return 0
  2807. }
  2808. if v, ok := a.Extra["base_rpm"]; ok {
  2809. val := parseExtraInt(v)
  2810. if val > 0 {
  2811. return val
  2812. }
  2813. }
  2814. return 0
  2815. }
  2816. // GetRPMStrategy 获取 RPM 策略
  2817. // "tiered" = 三区模型(默认), "sticky_exempt" = 粘性豁免
  2818. func (a *Account) GetRPMStrategy() string {
  2819. if a.Extra == nil {
  2820. return "tiered"
  2821. }
  2822. if v, ok := a.Extra["rpm_strategy"]; ok {
  2823. if s, ok := v.(string); ok && s == "sticky_exempt" {
  2824. return "sticky_exempt"
  2825. }
  2826. }
  2827. return "tiered"
  2828. }
  2829. // GetRPMStickyBuffer 获取 RPM 粘性缓冲数量
  2830. // Cache-driven: buffer = concurrency + maxSessions(覆盖幽灵窗口 + 稳态会话需求)
  2831. // floor = baseRPM / 5(向后兼容 maxSessions=0 且 concurrency=0 场景)
  2832. func (a *Account) GetRPMStickyBuffer() int {
  2833. if a.Extra == nil {
  2834. return 0
  2835. }
  2836. // 手动 override 最高优先级
  2837. if v, ok := a.Extra["rpm_sticky_buffer"]; ok {
  2838. val := parseExtraInt(v)
  2839. if val > 0 {
  2840. return val
  2841. }
  2842. }
  2843. base := a.GetBaseRPM()
  2844. if base <= 0 {
  2845. return 0
  2846. }
  2847. // Cache-driven buffer = concurrency + maxSessions
  2848. conc := a.Concurrency
  2849. if conc < 0 {
  2850. conc = 0
  2851. }
  2852. sess := a.GetMaxSessions()
  2853. if sess < 0 {
  2854. sess = 0
  2855. }
  2856. buffer := conc + sess
  2857. // floor: 向后兼容
  2858. floor := base / 5
  2859. if floor < 1 {
  2860. floor = 1
  2861. }
  2862. if buffer < floor {
  2863. buffer = floor
  2864. }
  2865. return buffer
  2866. }
  2867. // CheckRPMSchedulability 根据当前 RPM 计数检查调度状态
  2868. // 复用 WindowCostSchedulability 三态:Schedulable / StickyOnly / NotSchedulable
  2869. func (a *Account) CheckRPMSchedulability(currentRPM int) WindowCostSchedulability {
  2870. baseRPM := a.GetBaseRPM()
  2871. if baseRPM <= 0 {
  2872. return WindowCostSchedulable
  2873. }
  2874. if currentRPM < baseRPM {
  2875. return WindowCostSchedulable
  2876. }
  2877. strategy := a.GetRPMStrategy()
  2878. if strategy == "sticky_exempt" {
  2879. return WindowCostStickyOnly // 粘性豁免无红区
  2880. }
  2881. // tiered: 黄区 + 红区
  2882. buffer := a.GetRPMStickyBuffer()
  2883. if currentRPM < baseRPM+buffer {
  2884. return WindowCostStickyOnly
  2885. }
  2886. return WindowCostNotSchedulable
  2887. }
  2888. // CheckWindowCostSchedulability 根据当前窗口费用检查调度状态
  2889. // - 费用 < 阈值: WindowCostSchedulable(可正常调度)
  2890. // - 费用 >= 阈值 且 < 阈值+预留: WindowCostStickyOnly(仅粘性会话)
  2891. // - 费用 >= 阈值+预留: WindowCostNotSchedulable(不可调度)
  2892. func (a *Account) CheckWindowCostSchedulability(currentWindowCost float64) WindowCostSchedulability {
  2893. limit := a.GetWindowCostLimit()
  2894. if limit <= 0 {
  2895. return WindowCostSchedulable
  2896. }
  2897. if currentWindowCost < limit {
  2898. return WindowCostSchedulable
  2899. }
  2900. stickyReserve := a.GetWindowCostStickyReserve()
  2901. if currentWindowCost < limit+stickyReserve {
  2902. return WindowCostStickyOnly
  2903. }
  2904. return WindowCostNotSchedulable
  2905. }
  2906. // GetCurrentWindowStartTime 获取当前有效的窗口开始时间
  2907. // 逻辑:
  2908. // 1. 如果窗口未过期(SessionWindowEnd 存在且在当前时间之后),使用记录的 SessionWindowStart
  2909. // 2. 否则(窗口过期或未设置),使用新的预测窗口开始时间(从当前整点开始)
  2910. func (a *Account) GetCurrentWindowStartTime() time.Time {
  2911. now := time.Now()
  2912. // 窗口未过期,使用记录的窗口开始时间
  2913. if a.SessionWindowStart != nil && a.SessionWindowEnd != nil && now.Before(*a.SessionWindowEnd) {
  2914. return *a.SessionWindowStart
  2915. }
  2916. // 窗口已过期或未设置,预测新的窗口开始时间(从当前整点开始)
  2917. // 与 ratelimit_service.go 中 UpdateSessionWindow 的预测逻辑保持一致
  2918. return time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
  2919. }
  2920. // parseExtraFloat64 从 extra 字段解析 float64 值
  2921. func parseExtraFloat64(value any) float64 {
  2922. switch v := value.(type) {
  2923. case float64:
  2924. return v
  2925. case float32:
  2926. return float64(v)
  2927. case int:
  2928. return float64(v)
  2929. case int64:
  2930. return float64(v)
  2931. case json.Number:
  2932. if f, err := v.Float64(); err == nil {
  2933. return f
  2934. }
  2935. case string:
  2936. if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
  2937. return f
  2938. }
  2939. }
  2940. return 0
  2941. }
  2942. func parseExtraTime(value any) time.Time {
  2943. if s, ok := value.(string); ok {
  2944. if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
  2945. return t
  2946. }
  2947. if t, err := time.Parse(time.RFC3339, s); err == nil {
  2948. return t
  2949. }
  2950. }
  2951. return time.Time{}
  2952. }
  2953. // parseExtraInt 从 extra 字段解析 int 值
  2954. // ParseExtraInt 从 extra 字段的 any 值解析为 int。
  2955. // 支持 int, int64, float64, json.Number, string 类型,无法解析时返回 0。
  2956. func ParseExtraInt(value any) int {
  2957. return parseExtraInt(value)
  2958. }
  2959. func parseExtraInt(value any) int {
  2960. switch v := value.(type) {
  2961. case int:
  2962. return v
  2963. case int64:
  2964. return int(v)
  2965. case float64:
  2966. return int(v)
  2967. case json.Number:
  2968. if i, err := v.Int64(); err == nil {
  2969. return int(i)
  2970. }
  2971. case string:
  2972. if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
  2973. return i
  2974. }
  2975. }
  2976. return 0
  2977. }
  2978. // IsShadow 报告账号是否为影子账号(parent_account_id 非空;当前唯一预设是 spark 维度)。
  2979. func (a *Account) IsShadow() bool { return a != nil && a.ParentAccountID != nil }
  2980. // IsCredentialShadow 语义别名,供「凭据消费者跳过影子」处使用(管理/后台 OAuth 路径)。
  2981. func (a *Account) IsCredentialShadow() bool { return a.IsShadow() }
  2982. // QuotaDimensionOrDefault 返回账号的用量维度,未设置时回退 "global"。
  2983. func (a *Account) QuotaDimensionOrDefault() string {
  2984. if a == nil || strings.TrimSpace(a.QuotaDimension) == "" {
  2985. return QuotaDimensionGlobal
  2986. }
  2987. return a.QuotaDimension
  2988. }