openai_gateway_service.go 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254
  1. package service
  2. import (
  3. "context"
  4. "crypto/sha256"
  5. "encoding/hex"
  6. "encoding/json"
  7. "errors"
  8. "fmt"
  9. "log/slog"
  10. "math/rand"
  11. "net/http"
  12. "strings"
  13. "sync"
  14. "sync/atomic"
  15. "time"
  16. "github.com/Wei-Shaw/sub2api/internal/config"
  17. "github.com/Wei-Shaw/sub2api/internal/pkg/ip"
  18. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  19. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  20. "github.com/Wei-Shaw/sub2api/internal/platform/liveattestation"
  21. "github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
  22. "github.com/cespare/xxhash/v2"
  23. "github.com/gin-gonic/gin"
  24. "go.uber.org/zap"
  25. )
  26. const (
  27. // ChatGPT internal API for OAuth accounts
  28. chatgptCodexURL = "https://chatgpt.com/backend-api/codex/responses"
  29. // OpenAI Platform API for API Key accounts (fallback)
  30. openaiPlatformAPIURL = "https://api.openai.com/v1/responses"
  31. openaiPlatformAPIInputTokensURL = "https://api.openai.com/v1/responses/input_tokens"
  32. openaiStickySessionTTL = time.Hour // 粘性会话TTL
  33. // 与真实 Codex TUI 的 User-Agent 结构对齐:
  34. // {originator}/{version} ({OS} {OS_version}; {arch}) {terminal}
  35. // 缺少 OS/架构/终端后缀的形态易被上游指纹识别为非官方客户端。
  36. // 该后缀是 UA 形态的唯一定义处,buildCodexCLIUserAgent 按运行时版本号复用它。
  37. codexCLIUserAgentSuffix = " (Ubuntu 22.4.0; x86_64) xterm-256color"
  38. // codexCLIUserAgent 是编译期兜底 UA;运行时优先使用由后台版本号拼出的规范 UA。
  39. // 版本段必须来自 codexCLIVersion:UA 与 version 头是同一个版本声明的两个出口,
  40. // 各自硬编码会漂移成互相矛盾的身份。
  41. codexCLIUserAgent = openai.CodexDefaultOriginator + "/" + codexCLIVersion + codexCLIUserAgentSuffix
  42. // codex_cli_only 拒绝时单个请求头日志长度上限(字符)
  43. codexCLIOnlyHeaderValueMaxBytes = 256
  44. // OpenAI WS Mode 失败后的重连次数上限(不含首次尝试)。
  45. // 与 Codex 客户端保持一致:失败后最多重连 5 次。
  46. openAIWSReconnectRetryLimit = 5
  47. // 上游错误体只需要提取错误 JSON/日志摘要,默认 512KiB 避免错误风暴叠加大请求体。
  48. openAIUpstreamErrorBodyReadLimit int64 = 512 << 10
  49. // OpenAI WS Mode 重连退避默认值(可由配置覆盖)。
  50. openAIWSRetryBackoffInitialDefault = 120 * time.Millisecond
  51. openAIWSRetryBackoffMaxDefault = 2 * time.Second
  52. openAIWSRetryJitterRatioDefault = 0.2
  53. openAICompactSessionSeedKey = "openai_compact_session_seed"
  54. openAIUpstreamEndpointContextKey = "openai_actual_upstream_endpoint"
  55. // codexCLIVersion 是网关对上游声明的 Codex 客户端版本,同时供 codexCLIUserAgent
  56. // 与 version 头使用。上游 /backend-api/codex 在容量紧张时按客户端身份分优先级降载,
  57. // 陈旧版本会被优先丢弃(HTTP 200 + 流内 server_is_overloaded);非官方客户端配不出
  58. // 官方身份时整体回退到本常量,因此它必须跟随官方 CLI 的当前发布版本,
  59. // 落后多个版本会让这些请求稳定落在被优先丢弃的一侧。
  60. codexCLIVersion = "0.146.0"
  61. // Codex 限额快照仅用于后台展示/诊断,不需要每个成功请求都立即落库。
  62. openAICodexSnapshotPersistMinInterval = 30 * time.Second
  63. // 配额自动暂停时,超过该时长仍未刷新的 used% 快照视为陈旧,不再据此暂停账号。
  64. // 被暂停的账号收不到流量,其快照永远不会从上游响应头刷新;该兜底让账号在快照
  65. // 陈旧时放行一次请求,从而通过正常响应头自愈,而无需等待整个窗口(5h/7d)重置。
  66. openAICodexAutoPauseStaleAfter = 2 * time.Hour
  67. )
  68. // OpenAI allowed headers whitelist (for non-passthrough).
  69. var openaiAllowedHeaders = map[string]bool{
  70. "accept-language": true,
  71. "content-type": true,
  72. "conversation_id": true,
  73. "user-agent": true,
  74. "originator": true,
  75. "session_id": true,
  76. "x-codex-beta-features": true,
  77. "x-codex-installation-id": true,
  78. "x-codex-turn-state": true,
  79. "x-codex-turn-metadata": true,
  80. "x-codex-window-id": true,
  81. responsesLiteHeaderKey: true,
  82. }
  83. // OpenAI passthrough allowed headers whitelist.
  84. // 透传模式下仅放行这些低风险请求头,避免将非标准/环境噪声头传给上游触发风控。
  85. var openaiPassthroughAllowedHeaders = map[string]bool{
  86. "accept": true,
  87. "accept-language": true,
  88. "content-type": true,
  89. "conversation_id": true,
  90. "openai-beta": true,
  91. "user-agent": true,
  92. "originator": true,
  93. "session_id": true,
  94. "x-codex-beta-features": true,
  95. "x-codex-installation-id": true,
  96. "x-codex-turn-state": true,
  97. "x-codex-turn-metadata": true,
  98. "x-codex-window-id": true,
  99. responsesLiteHeaderKey: true,
  100. }
  101. // codex_cli_only 拒绝时记录的请求头白名单(仅用于诊断日志,不参与上游透传)
  102. var codexCLIOnlyDebugHeaderWhitelist = []string{
  103. "User-Agent",
  104. "Content-Type",
  105. "Accept",
  106. "Accept-Language",
  107. "OpenAI-Beta",
  108. "Originator",
  109. "Session_ID",
  110. "Conversation_ID",
  111. "X-Request-ID",
  112. "X-Client-Request-ID",
  113. "X-Forwarded-For",
  114. "X-Real-IP",
  115. }
  116. // OpenAICodexUsageSnapshot represents Codex API usage limits from response headers
  117. type OpenAICodexUsageSnapshot struct {
  118. PrimaryUsedPercent *float64 `json:"primary_used_percent,omitempty"`
  119. PrimaryResetAfterSeconds *int `json:"primary_reset_after_seconds,omitempty"`
  120. PrimaryWindowMinutes *int `json:"primary_window_minutes,omitempty"`
  121. SecondaryUsedPercent *float64 `json:"secondary_used_percent,omitempty"`
  122. SecondaryResetAfterSeconds *int `json:"secondary_reset_after_seconds,omitempty"`
  123. SecondaryWindowMinutes *int `json:"secondary_window_minutes,omitempty"`
  124. PrimaryOverSecondaryPercent *float64 `json:"primary_over_secondary_percent,omitempty"`
  125. UpdatedAt string `json:"updated_at,omitempty"`
  126. }
  127. // NormalizedCodexLimits contains normalized 5h/7d rate limit data
  128. type NormalizedCodexLimits struct {
  129. Used5hPercent *float64
  130. Reset5hSeconds *int
  131. Window5hMinutes *int
  132. Used7dPercent *float64
  133. Reset7dSeconds *int
  134. Window7dMinutes *int
  135. }
  136. // Normalize converts primary/secondary fields to canonical 5h/7d fields.
  137. // Strategy: Compare window_minutes to determine which is 5h vs 7d.
  138. // Returns nil if snapshot is nil or has no useful data.
  139. func (s *OpenAICodexUsageSnapshot) Normalize() *NormalizedCodexLimits {
  140. if s == nil {
  141. return nil
  142. }
  143. result := &NormalizedCodexLimits{}
  144. primaryMins := 0
  145. secondaryMins := 0
  146. hasPrimaryWindow := false
  147. hasSecondaryWindow := false
  148. if s.PrimaryWindowMinutes != nil {
  149. primaryMins = *s.PrimaryWindowMinutes
  150. hasPrimaryWindow = true
  151. }
  152. if s.SecondaryWindowMinutes != nil {
  153. secondaryMins = *s.SecondaryWindowMinutes
  154. hasSecondaryWindow = true
  155. }
  156. // Determine mapping based on window_minutes
  157. use5hFromPrimary := false
  158. use7dFromPrimary := false
  159. if hasPrimaryWindow && hasSecondaryWindow {
  160. // Both known: smaller window is 5h, larger is 7d
  161. if primaryMins < secondaryMins {
  162. use5hFromPrimary = true
  163. } else {
  164. use7dFromPrimary = true
  165. }
  166. } else if hasPrimaryWindow {
  167. // Only primary known: classify by threshold (<=360 min = 6h -> 5h window)
  168. if primaryMins <= 360 {
  169. use5hFromPrimary = true
  170. } else {
  171. use7dFromPrimary = true
  172. }
  173. } else if hasSecondaryWindow {
  174. // Only secondary known: classify by threshold
  175. if secondaryMins <= 360 {
  176. // 5h from secondary, so primary (if any data) is 7d
  177. use7dFromPrimary = true
  178. } else {
  179. // 7d from secondary, so primary (if any data) is 5h
  180. use5hFromPrimary = true
  181. }
  182. } else {
  183. // No window_minutes: fall back to legacy assumption (primary=7d, secondary=5h)
  184. use7dFromPrimary = true
  185. }
  186. // Assign values
  187. if use5hFromPrimary {
  188. result.Used5hPercent = s.PrimaryUsedPercent
  189. result.Reset5hSeconds = s.PrimaryResetAfterSeconds
  190. result.Window5hMinutes = s.PrimaryWindowMinutes
  191. result.Used7dPercent = s.SecondaryUsedPercent
  192. result.Reset7dSeconds = s.SecondaryResetAfterSeconds
  193. result.Window7dMinutes = s.SecondaryWindowMinutes
  194. } else if use7dFromPrimary {
  195. result.Used7dPercent = s.PrimaryUsedPercent
  196. result.Reset7dSeconds = s.PrimaryResetAfterSeconds
  197. result.Window7dMinutes = s.PrimaryWindowMinutes
  198. result.Used5hPercent = s.SecondaryUsedPercent
  199. result.Reset5hSeconds = s.SecondaryResetAfterSeconds
  200. result.Window5hMinutes = s.SecondaryWindowMinutes
  201. }
  202. return result
  203. }
  204. // OpenAIUsage represents OpenAI API response usage
  205. type OpenAIUsage struct {
  206. InputTokens int `json:"input_tokens"`
  207. ImageInputTokens int `json:"image_input_tokens,omitempty"`
  208. OutputTokens int `json:"output_tokens"`
  209. CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"`
  210. CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"`
  211. ImageOutputTokens int `json:"image_output_tokens,omitempty"`
  212. }
  213. // OpenAIForwardResult represents the result of forwarding
  214. type OpenAIForwardResult struct {
  215. RequestID string
  216. ResponseID string
  217. // UpstreamHeaders 是直接上游的响应头,用于按账户配置解析上游请求标识。
  218. UpstreamHeaders http.Header
  219. Usage OpenAIUsage
  220. Model string // 原始模型(用于响应和日志显示)
  221. // BillingModel is the model used for cost calculation.
  222. // When non-empty, CalculateCost uses this instead of Model.
  223. // This is set by the Anthropic Messages conversion path where
  224. // the mapped upstream model differs from the client-facing model.
  225. BillingModel string
  226. // UpstreamModel is the actual model sent to the upstream provider after mapping.
  227. // Empty when no mapping was applied (requested model was used as-is).
  228. UpstreamModel string
  229. // UpstreamResponseModel is captured from the raw successful upstream
  230. // response before any client-facing rewrite or protocol conversion.
  231. UpstreamResponseModel string
  232. UpstreamResponseModelConflict bool
  233. // UpstreamResponseServiceTier is the tier the upstream reports having used
  234. // (response service_tier: "priority" / "default" / "flex" / ...); "" when not declared.
  235. UpstreamResponseServiceTier string
  236. // UpstreamEndpoint is the actual upstream API path used for this request.
  237. // It avoids guessing when one downstream protocol can use multiple upstream endpoints.
  238. UpstreamEndpoint string
  239. // ServiceTier is the final tier sent upstream after policy rewriting.
  240. // The upstream response declaration remains separate above and is reconciled
  241. // at usage-recording time, where the credential protocol is available.
  242. ServiceTier *string
  243. // ReasoningEffort is extracted from request body (reasoning.effort) or derived from model suffix
  244. // after group policy rewriting and model-family remapping.
  245. // Stored for usage records display; nil means not provided / not applicable.
  246. ReasoningEffort *string
  247. // RequestedReasoningEffort is the client-requested effort before mapping.
  248. // Empty/nil means it should fall back to ReasoningEffort at persistence.
  249. RequestedReasoningEffort *string
  250. Stream bool
  251. OpenAIWSMode bool
  252. // UpstreamTerminalEvent is the normalized terminal event observed on an
  253. // upstream Responses WebSocket turn. Empty preserves legacy/non-WS success.
  254. UpstreamTerminalEvent string
  255. ResponseHeaders http.Header
  256. Duration time.Duration
  257. FirstTokenMs *int
  258. ClientDisconnect bool
  259. ImageCount int
  260. ImageSize string
  261. ImageInputSize string
  262. ImageOutputSize string
  263. ImageOutputSizes []string
  264. ImageSizeSource string
  265. ImageSizeBreakdown map[string]int
  266. VideoCount int
  267. VideoResolution string
  268. // VideoDurationSeconds 是提交时请求的生成时长(xAI 按输出秒数计费),已归一化到 1-15 秒。
  269. VideoDurationSeconds int
  270. // WebSearchCalls 是 Codex alpha/search 网页搜索调用次数(每次成功请求为 1)。
  271. // 上游不返回 usage 字段,>0 时走按次计费(分组单价 × 次数 × 倍率)。
  272. WebSearchCalls int
  273. // SearchCount is Grok-native web_search / tool search call count (per 1k pricing).
  274. SearchCount int
  275. // AudioUsage carries Voice billing units when present.
  276. AudioUsage *AudioUsage
  277. wsReplayInput []json.RawMessage
  278. wsReplayInputExists bool
  279. wsAccountFailoverReplayInput []json.RawMessage
  280. }
  281. // SucceededForScheduling reports whether this result is an upstream success
  282. // that may clear model-scoped transient state. The zero value remains a success
  283. // for existing non-WS callers.
  284. func (r *OpenAIForwardResult) SucceededForScheduling() bool {
  285. if r == nil || !r.OpenAIWSMode || r.UpstreamTerminalEvent == "" {
  286. return true
  287. }
  288. switch r.UpstreamTerminalEvent {
  289. case "response.completed", "response.done":
  290. return true
  291. default:
  292. return false
  293. }
  294. }
  295. // SetActualOpenAIUpstreamEndpoint records the endpoint selected by the current
  296. // forwarding attempt. It covers error paths where no OpenAIForwardResult is
  297. // available for usage and operations logging.
  298. func SetActualOpenAIUpstreamEndpoint(c *gin.Context, endpoint string) {
  299. if c == nil {
  300. return
  301. }
  302. if endpoint = strings.TrimSpace(endpoint); endpoint != "" {
  303. c.Set(openAIUpstreamEndpointContextKey, endpoint)
  304. }
  305. }
  306. // ClearActualOpenAIUpstreamEndpoint 清理当前转发尝试记录的端点。
  307. // Handler 会在账号 failover 尝试间复用同一个 Gin context,因此每次尝试
  308. // 都必须从无残留状态开始。
  309. func ClearActualOpenAIUpstreamEndpoint(c *gin.Context) {
  310. if c == nil {
  311. return
  312. }
  313. c.Set(openAIUpstreamEndpointContextKey, "")
  314. }
  315. // GetActualOpenAIUpstreamEndpoint returns the endpoint recorded by the latest
  316. // forwarding attempt in this request.
  317. func GetActualOpenAIUpstreamEndpoint(c *gin.Context) string {
  318. if c == nil {
  319. return ""
  320. }
  321. value, exists := c.Get(openAIUpstreamEndpointContextKey)
  322. if !exists {
  323. return ""
  324. }
  325. endpoint, _ := value.(string)
  326. return strings.TrimSpace(endpoint)
  327. }
  328. type OpenAIWSRetryMetricsSnapshot struct {
  329. RetryAttemptsTotal int64 `json:"retry_attempts_total"`
  330. RetryBackoffMsTotal int64 `json:"retry_backoff_ms_total"`
  331. RetryExhaustedTotal int64 `json:"retry_exhausted_total"`
  332. NonRetryableFastFallbackTotal int64 `json:"non_retryable_fast_fallback_total"`
  333. }
  334. type OpenAICompatibilityFallbackMetricsSnapshot struct {
  335. SessionHashLegacyReadFallbackTotal int64 `json:"session_hash_legacy_read_fallback_total"`
  336. SessionHashLegacyReadFallbackHit int64 `json:"session_hash_legacy_read_fallback_hit"`
  337. SessionHashLegacyDualWriteTotal int64 `json:"session_hash_legacy_dual_write_total"`
  338. SessionHashLegacyReadHitRate float64 `json:"session_hash_legacy_read_hit_rate"`
  339. MetadataLegacyFallbackIsMaxTokensOneHaikuTotal int64 `json:"metadata_legacy_fallback_is_max_tokens_one_haiku_total"`
  340. MetadataLegacyFallbackThinkingEnabledTotal int64 `json:"metadata_legacy_fallback_thinking_enabled_total"`
  341. MetadataLegacyFallbackPrefetchedStickyAccount int64 `json:"metadata_legacy_fallback_prefetched_sticky_account_total"`
  342. MetadataLegacyFallbackPrefetchedStickyGroup int64 `json:"metadata_legacy_fallback_prefetched_sticky_group_total"`
  343. MetadataLegacyFallbackSingleAccountRetryTotal int64 `json:"metadata_legacy_fallback_single_account_retry_total"`
  344. MetadataLegacyFallbackAccountSwitchCountTotal int64 `json:"metadata_legacy_fallback_account_switch_count_total"`
  345. MetadataLegacyFallbackTotal int64 `json:"metadata_legacy_fallback_total"`
  346. }
  347. type openAIWSRetryMetrics struct {
  348. retryAttempts atomic.Int64
  349. retryBackoffMs atomic.Int64
  350. retryExhausted atomic.Int64
  351. nonRetryableFastFallback atomic.Int64
  352. }
  353. type accountWriteThrottle struct {
  354. minInterval time.Duration
  355. mu sync.Mutex
  356. lastByID map[int64]time.Time
  357. }
  358. func newAccountWriteThrottle(minInterval time.Duration) *accountWriteThrottle {
  359. return &accountWriteThrottle{
  360. minInterval: minInterval,
  361. lastByID: make(map[int64]time.Time),
  362. }
  363. }
  364. func (t *accountWriteThrottle) Allow(id int64, now time.Time) bool {
  365. if t == nil || id <= 0 || t.minInterval <= 0 {
  366. return true
  367. }
  368. t.mu.Lock()
  369. defer t.mu.Unlock()
  370. if last, ok := t.lastByID[id]; ok && now.Sub(last) < t.minInterval {
  371. return false
  372. }
  373. t.lastByID[id] = now
  374. if len(t.lastByID) > 4096 {
  375. cutoff := now.Add(-4 * t.minInterval)
  376. for accountID, writtenAt := range t.lastByID {
  377. if writtenAt.Before(cutoff) {
  378. delete(t.lastByID, accountID)
  379. }
  380. }
  381. }
  382. return true
  383. }
  384. var defaultOpenAICodexSnapshotPersistThrottle = newAccountWriteThrottle(openAICodexSnapshotPersistMinInterval)
  385. // ErrNoAvailableCompactAccounts indicates a legacy /responses/compact request
  386. // needs compact support but no compatible account is available.
  387. var ErrNoAvailableCompactAccounts = errors.New("no available accounts support /responses/compact")
  388. // OpenAIGatewayService handles OpenAI API gateway operations
  389. type OpenAIGatewayService struct {
  390. accountRepo AccountRepository
  391. usageLogRepo UsageLogRepository
  392. usageBillingRepo UsageBillingRepository
  393. userRepo UserRepository
  394. userSubRepo UserSubscriptionRepository
  395. cache GatewayCache
  396. cfg *config.Config
  397. codexDetector CodexClientRestrictionDetector
  398. schedulerSnapshot *SchedulerSnapshotService
  399. concurrencyService *ConcurrencyService
  400. billingService *BillingService
  401. rateLimitService *RateLimitService
  402. billingCacheService *BillingCacheService
  403. userGroupRateResolver *userGroupRateResolver
  404. httpUpstream HTTPUpstream
  405. pluginManager *PluginManager
  406. deferredService *DeferredService
  407. openAITokenProvider *OpenAITokenProvider
  408. grokTokenProvider *GrokTokenProvider
  409. toolCorrector *CodexToolCorrector
  410. openaiWSResolver OpenAIWSProtocolResolver
  411. resolver *ModelPricingResolver
  412. channelService *ChannelService
  413. balanceNotifyService *BalanceNotifyService
  414. settingService *SettingService
  415. userPlatformQuotaRepo UserPlatformQuotaRepository
  416. liveAttestation liveattestation.Provider
  417. liveAttestationCipher SecretEncryptor
  418. openaiWSPoolOnce sync.Once
  419. openaiWSStateStoreOnce sync.Once
  420. openaiSchedulerOnce sync.Once
  421. openaiProxyStreamCircuitOnce sync.Once
  422. openaiWSPassthroughDialerOnce sync.Once
  423. openaiModelTransientOnce sync.Once
  424. agentIdentityTaskMu sync.Mutex
  425. openaiWSPool *openAIWSConnPool
  426. openaiWSStateStore OpenAIWSStateStore
  427. openaiScheduler OpenAIAccountScheduler
  428. openaiWSPassthroughDialer openAIWSClientDialer
  429. openaiWSSessionPreemptions openAIWSSessionPreemptRegistry
  430. openaiAccountStats *openAIAccountRuntimeStats
  431. openaiModelTransient *openAIAccountModelTransientState
  432. openaiProxyStreamCircuit *openAIProxyStreamCircuit
  433. openaiProxyStreamFailOpenLogAt atomic.Int64
  434. openaiWSFallbackUntil sync.Map // key: int64(accountID), value: time.Time
  435. openaiAccountRuntimeBlockUntil sync.Map // key: int64(accountID), value: time.Time
  436. openaiAccountRuntimeBlockLocks sync.Map // key: int64(accountID), value: *sync.Mutex
  437. openaiAccountRuntimeBlockGeneration sync.Map // key: int64(accountID), value: uint64
  438. openaiAccountRuntimeBlockSequence atomic.Uint64
  439. openaiOAuth429RetryStartedAt sync.Map // key: int64(accountID), value: time.Time
  440. grokCredentialMutationLocks sync.Map // key: int64(accountID), value: *sync.Mutex
  441. openaiOAuth429WindowStartUnixNano atomic.Int64
  442. openaiOAuth429WindowCount atomic.Int64
  443. openaiWSRetryMetrics openAIWSRetryMetrics
  444. responseHeaderFilter *responseheaders.CompiledHeaderFilter
  445. codexSnapshotThrottle *accountWriteThrottle
  446. openAIModelsCache openAIModelsCache
  447. openaiCompatSessionResponses sync.Map
  448. openaiCompatAnthropicDigestSessions sync.Map
  449. // openaiCodexTurnStateOrigins: 下游会话 seed → openAICodexTurnStateOrigin,
  450. // 记录最近一次向该会话下发 x-codex-turn-state 的铸造账号,供出站守卫
  451. // 剥离跨账号回带(openai_codex_turn_state.go)。
  452. openaiCodexTurnStateOrigins sync.Map
  453. openaiCodexTurnStateWrites atomic.Uint64
  454. }
  455. // NewOpenAIGatewayService creates a new OpenAIGatewayService
  456. func NewOpenAIGatewayService(
  457. accountRepo AccountRepository,
  458. usageLogRepo UsageLogRepository,
  459. usageBillingRepo UsageBillingRepository,
  460. userRepo UserRepository,
  461. userSubRepo UserSubscriptionRepository,
  462. userGroupRateRepo UserGroupRateRepository,
  463. cache GatewayCache,
  464. cfg *config.Config,
  465. schedulerSnapshot *SchedulerSnapshotService,
  466. concurrencyService *ConcurrencyService,
  467. billingService *BillingService,
  468. rateLimitService *RateLimitService,
  469. billingCacheService *BillingCacheService,
  470. httpUpstream HTTPUpstream,
  471. deferredService *DeferredService,
  472. openAITokenProvider *OpenAITokenProvider,
  473. grokTokenProvider *GrokTokenProvider,
  474. resolver *ModelPricingResolver,
  475. channelService *ChannelService,
  476. balanceNotifyService *BalanceNotifyService,
  477. settingService *SettingService,
  478. userPlatformQuotaRepo UserPlatformQuotaRepository,
  479. ) *OpenAIGatewayService {
  480. // enforceCodexIdentityHeaders 是 HTTP / 透传 / WS / 探针 等出站路径共用的纯函数收口点,
  481. // 拿不到配置,故在此发布进程级开关快照。配置取反义,零值即「强制统一出口开启」。
  482. if cfg != nil {
  483. SetCodexIdentityEnforcementEnabled(!cfg.Gateway.DisableCodexIdentityEnforcement)
  484. }
  485. svc := &OpenAIGatewayService{
  486. accountRepo: accountRepo,
  487. usageLogRepo: usageLogRepo,
  488. usageBillingRepo: usageBillingRepo,
  489. userRepo: userRepo,
  490. userSubRepo: userSubRepo,
  491. cache: cache,
  492. cfg: cfg,
  493. codexDetector: NewOpenAICodexClientRestrictionDetector(cfg),
  494. schedulerSnapshot: schedulerSnapshot,
  495. concurrencyService: concurrencyService,
  496. billingService: billingService,
  497. rateLimitService: rateLimitService,
  498. billingCacheService: billingCacheService,
  499. userGroupRateResolver: newUserGroupRateResolver(
  500. userGroupRateRepo,
  501. nil,
  502. resolveUserGroupRateCacheTTL(cfg),
  503. nil,
  504. "service.openai_gateway",
  505. ),
  506. httpUpstream: httpUpstream,
  507. deferredService: deferredService,
  508. openAITokenProvider: openAITokenProvider,
  509. grokTokenProvider: grokTokenProvider,
  510. toolCorrector: NewCodexToolCorrector(),
  511. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  512. resolver: resolver,
  513. channelService: channelService,
  514. balanceNotifyService: balanceNotifyService,
  515. settingService: settingService,
  516. userPlatformQuotaRepo: userPlatformQuotaRepo,
  517. liveAttestation: liveattestation.NewProvider(),
  518. liveAttestationCipher: newLiveAttestationCipher(cfg),
  519. responseHeaderFilter: compileResponseHeaderFilter(cfg),
  520. codexSnapshotThrottle: newAccountWriteThrottle(openAICodexSnapshotPersistMinInterval),
  521. openaiModelTransient: newOpenAIAccountModelTransientState(openAIModelTransientDefaultMax),
  522. }
  523. if rateLimitService != nil {
  524. rateLimitService.SetAccountRuntimeBlocker(svc)
  525. }
  526. if openAITokenProvider != nil {
  527. openAITokenProvider.SetAccountRuntimeBlocker(svc)
  528. }
  529. svc.logOpenAIWSModeBootstrap()
  530. return svc
  531. }
  532. // ResolveChannelMapping 解析渠道级模型映射(代理到 ChannelService)
  533. func (s *OpenAIGatewayService) ResolveChannelMapping(ctx context.Context, groupID int64, model string) ChannelMappingResult {
  534. if s.channelService == nil {
  535. return ChannelMappingResult{MappedModel: model}
  536. }
  537. return s.channelService.ResolveChannelMapping(ctx, groupID, model)
  538. }
  539. // IsModelRestricted 检查模型是否被渠道限制(代理到 ChannelService)
  540. func (s *OpenAIGatewayService) IsModelRestricted(ctx context.Context, groupID int64, model string) bool {
  541. if s.channelService == nil {
  542. return false
  543. }
  544. return s.channelService.IsModelRestricted(ctx, groupID, model)
  545. }
  546. // ResolveChannelMappingAndRestrict 解析渠道映射。
  547. // 模型限制检查已移至调度阶段,restricted 始终返回 false。
  548. func (s *OpenAIGatewayService) ResolveChannelMappingAndRestrict(ctx context.Context, groupID *int64, model string) (ChannelMappingResult, bool) {
  549. if s.channelService == nil {
  550. return ChannelMappingResult{MappedModel: model}, false
  551. }
  552. return s.channelService.ResolveChannelMappingAndRestrict(ctx, groupID, model)
  553. }
  554. func (s *OpenAIGatewayService) isCodexImageGenerationBridgeEnabled(ctx context.Context, account *Account, apiKey *APIKey) bool {
  555. if override := account.CodexImageGenerationBridgeOverride(); override != nil {
  556. return *override
  557. }
  558. if s != nil && s.channelService != nil && apiKey != nil && apiKey.GroupID != nil {
  559. ch, err := s.channelService.GetChannelForGroup(ctx, *apiKey.GroupID)
  560. if err != nil {
  561. slog.Warn("failed to resolve codex image generation bridge channel override", "group_id", *apiKey.GroupID, "error", err)
  562. } else if override := ch.CodexImageGenerationBridgeOverride(PlatformOpenAI); override != nil {
  563. return *override
  564. }
  565. }
  566. return s != nil && s.cfg != nil && s.cfg.Gateway.CodexImageGenerationBridgeEnabled
  567. }
  568. func (s *OpenAIGatewayService) checkChannelPricingRestriction(ctx context.Context, groupID *int64, requestedModel string) bool {
  569. if groupID == nil || s.channelService == nil || requestedModel == "" {
  570. return false
  571. }
  572. mapping := s.channelService.ResolveChannelMapping(ctx, *groupID, requestedModel)
  573. billingModel := billingModelForRestriction(mapping.BillingModelSource, requestedModel, mapping.MappedModel)
  574. if billingModel == "" {
  575. return false
  576. }
  577. return s.channelService.IsModelRestricted(ctx, *groupID, billingModel)
  578. }
  579. func (s *OpenAIGatewayService) isUpstreamModelRestrictedByChannel(ctx context.Context, groupID int64, account *Account, requestedModel string, requireCompact bool) bool {
  580. if s.channelService == nil {
  581. return false
  582. }
  583. if compactForwardModel, ok := openAIForwardModelFromContext(ctx); ok {
  584. requestedModel = compactForwardModel.model
  585. requireCompact = compactForwardModel.useCompactModelMapping
  586. }
  587. upstreamModel := resolveOpenAIAccountUpstreamModelForRequest(account, requestedModel, requireCompact)
  588. if upstreamModel == "" {
  589. return false
  590. }
  591. return s.channelService.IsModelRestricted(ctx, groupID, upstreamModel)
  592. }
  593. func (s *OpenAIGatewayService) needsUpstreamChannelRestrictionCheck(ctx context.Context, groupID *int64) bool {
  594. if groupID == nil || s.channelService == nil {
  595. return false
  596. }
  597. ch, err := s.channelService.GetChannelForGroup(ctx, *groupID)
  598. if err != nil {
  599. slog.Warn("failed to check openai channel upstream restriction", "group_id", *groupID, "error", err)
  600. return false
  601. }
  602. if ch == nil || !ch.RestrictModels {
  603. return false
  604. }
  605. return ch.BillingModelSource == BillingModelSourceUpstream
  606. }
  607. // ReplaceModelInBody 替换请求体中的 JSON model 字段(通用 gjson/sjson 实现)。
  608. func (s *OpenAIGatewayService) ReplaceModelInBody(body []byte, newModel string) []byte {
  609. return ReplaceModelInBody(body, newModel)
  610. }
  611. func (s *OpenAIGatewayService) getCodexSnapshotThrottle() *accountWriteThrottle {
  612. if s != nil && s.codexSnapshotThrottle != nil {
  613. return s.codexSnapshotThrottle
  614. }
  615. return defaultOpenAICodexSnapshotPersistThrottle
  616. }
  617. func (s *OpenAIGatewayService) billingDeps() *billingDeps {
  618. return &billingDeps{
  619. accountRepo: s.accountRepo,
  620. userRepo: s.userRepo,
  621. userSubRepo: s.userSubRepo,
  622. billingCacheService: s.billingCacheService,
  623. deferredService: s.deferredService,
  624. balanceNotifyService: s.balanceNotifyService,
  625. userPlatformQuotaRepo: s.userPlatformQuotaRepo,
  626. }
  627. }
  628. // CloseOpenAIWSPool 关闭 OpenAI WebSocket 连接池的后台 worker 和空闲连接。
  629. // 应在应用优雅关闭时调用。
  630. func (s *OpenAIGatewayService) CloseOpenAIWSPool() {
  631. if s != nil && s.openaiWSPool != nil {
  632. s.openaiWSPool.Close()
  633. }
  634. }
  635. func (s *OpenAIGatewayService) InvalidateAgentIdentityWSConnections(accountID int64) {
  636. if pool := s.getOpenAIWSConnPool(); pool != nil {
  637. pool.ClearAccount(accountID)
  638. }
  639. }
  640. func (s *OpenAIGatewayService) logOpenAIWSModeBootstrap() {
  641. if s == nil || s.cfg == nil {
  642. return
  643. }
  644. wsCfg := s.cfg.Gateway.OpenAIWS
  645. logOpenAIWSModeInfo(
  646. "bootstrap enabled=%v oauth_enabled=%v apikey_enabled=%v force_http=%v responses_websockets_v2=%v responses_websockets=%v payload_log_sample_rate=%.3f event_flush_batch_size=%d event_flush_interval_ms=%d prewarm_cooldown_ms=%d retry_backoff_initial_ms=%d retry_backoff_max_ms=%d retry_jitter_ratio=%.3f retry_total_budget_ms=%d ws_read_limit_bytes=%d",
  647. wsCfg.Enabled,
  648. wsCfg.OAuthEnabled,
  649. wsCfg.APIKeyEnabled,
  650. wsCfg.ForceHTTP,
  651. wsCfg.ResponsesWebsocketsV2,
  652. wsCfg.ResponsesWebsockets,
  653. wsCfg.PayloadLogSampleRate,
  654. wsCfg.EventFlushBatchSize,
  655. wsCfg.EventFlushIntervalMS,
  656. wsCfg.PrewarmCooldownMS,
  657. wsCfg.RetryBackoffInitialMS,
  658. wsCfg.RetryBackoffMaxMS,
  659. wsCfg.RetryJitterRatio,
  660. wsCfg.RetryTotalBudgetMS,
  661. openAIWSMessageReadLimitBytes,
  662. )
  663. }
  664. func (s *OpenAIGatewayService) getCodexClientRestrictionDetector() CodexClientRestrictionDetector {
  665. if s != nil && s.codexDetector != nil {
  666. return s.codexDetector
  667. }
  668. var cfg *config.Config
  669. if s != nil {
  670. cfg = s.cfg
  671. }
  672. return NewOpenAICodexClientRestrictionDetector(cfg)
  673. }
  674. func (s *OpenAIGatewayService) getOpenAIWSProtocolResolver() OpenAIWSProtocolResolver {
  675. if s != nil && s.openaiWSResolver != nil {
  676. return s.openaiWSResolver
  677. }
  678. var cfg *config.Config
  679. if s != nil {
  680. cfg = s.cfg
  681. }
  682. return NewOpenAIWSProtocolResolver(cfg)
  683. }
  684. func classifyOpenAIWSReconnectReason(err error) (string, bool) {
  685. if err == nil {
  686. return "", false
  687. }
  688. var fallbackErr *openAIWSFallbackError
  689. if !errors.As(err, &fallbackErr) || fallbackErr == nil {
  690. return "", false
  691. }
  692. reason := strings.TrimSpace(fallbackErr.Reason)
  693. if reason == "" {
  694. return "", false
  695. }
  696. baseReason := strings.TrimPrefix(reason, "prewarm_")
  697. switch baseReason {
  698. case "policy_violation",
  699. "message_too_big",
  700. "upgrade_required",
  701. "ws_unsupported",
  702. "auth_failed",
  703. "invalid_encrypted_content",
  704. "previous_response_not_found":
  705. return reason, false
  706. }
  707. switch baseReason {
  708. case "read_event",
  709. "write_request",
  710. "write",
  711. "acquire_timeout",
  712. "acquire_conn",
  713. "conn_queue_full",
  714. "dial_failed",
  715. "upstream_5xx",
  716. "event_error",
  717. "error_event",
  718. "upstream_error_event",
  719. "ws_connection_limit_reached",
  720. "missing_final_response":
  721. return reason, true
  722. default:
  723. return reason, false
  724. }
  725. }
  726. func resolveOpenAIWSFallbackErrorResponse(err error) (statusCode int, errType string, clientMessage string, upstreamMessage string, ok bool) {
  727. if err == nil {
  728. return 0, "", "", "", false
  729. }
  730. var fallbackErr *openAIWSFallbackError
  731. if !errors.As(err, &fallbackErr) || fallbackErr == nil {
  732. return 0, "", "", "", false
  733. }
  734. reason := strings.TrimSpace(fallbackErr.Reason)
  735. reason = strings.TrimPrefix(reason, "prewarm_")
  736. if reason == "" {
  737. return 0, "", "", "", false
  738. }
  739. var dialErr *openAIWSDialError
  740. if fallbackErr.Err != nil && errors.As(fallbackErr.Err, &dialErr) && dialErr != nil {
  741. if dialErr.StatusCode > 0 {
  742. statusCode = dialErr.StatusCode
  743. }
  744. if dialErr.Err != nil {
  745. upstreamMessage = sanitizeUpstreamErrorMessage(strings.TrimSpace(dialErr.Err.Error()))
  746. }
  747. }
  748. switch reason {
  749. case "invalid_encrypted_content":
  750. if statusCode == 0 {
  751. statusCode = http.StatusBadRequest
  752. }
  753. errType = "invalid_request_error"
  754. if upstreamMessage == "" {
  755. upstreamMessage = "encrypted content could not be verified"
  756. }
  757. case "previous_response_not_found":
  758. if statusCode == 0 {
  759. statusCode = http.StatusBadRequest
  760. }
  761. errType = "invalid_request_error"
  762. if upstreamMessage == "" {
  763. upstreamMessage = "previous response not found"
  764. }
  765. case "upgrade_required":
  766. if statusCode == 0 {
  767. statusCode = http.StatusUpgradeRequired
  768. }
  769. case "ws_unsupported":
  770. if statusCode == 0 {
  771. statusCode = http.StatusBadRequest
  772. }
  773. case "auth_failed":
  774. if statusCode == 0 {
  775. statusCode = http.StatusUnauthorized
  776. }
  777. case "upstream_rate_limited":
  778. if statusCode == 0 {
  779. statusCode = http.StatusTooManyRequests
  780. }
  781. default:
  782. if statusCode == 0 {
  783. return 0, "", "", "", false
  784. }
  785. }
  786. if upstreamMessage == "" && fallbackErr.Err != nil {
  787. upstreamMessage = sanitizeUpstreamErrorMessage(strings.TrimSpace(fallbackErr.Err.Error()))
  788. }
  789. if upstreamMessage == "" {
  790. switch reason {
  791. case "upgrade_required":
  792. upstreamMessage = "upstream websocket upgrade required"
  793. case "ws_unsupported":
  794. upstreamMessage = "upstream websocket not supported"
  795. case "auth_failed":
  796. upstreamMessage = "upstream authentication failed"
  797. case "upstream_rate_limited":
  798. upstreamMessage = "upstream rate limit exceeded, please retry later"
  799. default:
  800. upstreamMessage = "Upstream request failed"
  801. }
  802. }
  803. if errType == "" {
  804. if statusCode == http.StatusTooManyRequests {
  805. errType = "rate_limit_error"
  806. } else {
  807. errType = "upstream_error"
  808. }
  809. }
  810. clientMessage = upstreamMessage
  811. return statusCode, errType, clientMessage, upstreamMessage, true
  812. }
  813. func (s *OpenAIGatewayService) writeOpenAIWSFallbackErrorResponse(c *gin.Context, account *Account, wsErr error) bool {
  814. if c == nil || c.Writer == nil || c.Writer.Written() {
  815. return false
  816. }
  817. statusCode, errType, clientMessage, upstreamMessage, ok := resolveOpenAIWSFallbackErrorResponse(wsErr)
  818. if !ok {
  819. return false
  820. }
  821. if strings.TrimSpace(clientMessage) == "" {
  822. clientMessage = "Upstream request failed"
  823. }
  824. if strings.TrimSpace(upstreamMessage) == "" {
  825. upstreamMessage = clientMessage
  826. }
  827. setOpsUpstreamError(c, statusCode, upstreamMessage, "")
  828. if account != nil {
  829. proxyID, proxyName := opsUpstreamWSProxyAttribution(account)
  830. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  831. ProxyID: proxyID,
  832. ProxyName: proxyName,
  833. Platform: account.Platform,
  834. AccountID: account.ID,
  835. AccountName: account.Name,
  836. UpstreamStatusCode: statusCode,
  837. Kind: "ws_error",
  838. Message: upstreamMessage,
  839. })
  840. }
  841. c.JSON(statusCode, gin.H{
  842. "error": gin.H{
  843. "type": errType,
  844. "message": clientMessage,
  845. },
  846. })
  847. return true
  848. }
  849. func (s *OpenAIGatewayService) openAIWSRetryBackoff(attempt int) time.Duration {
  850. if attempt <= 0 {
  851. return 0
  852. }
  853. initial := openAIWSRetryBackoffInitialDefault
  854. maxBackoff := openAIWSRetryBackoffMaxDefault
  855. jitterRatio := openAIWSRetryJitterRatioDefault
  856. if s != nil && s.cfg != nil {
  857. wsCfg := s.cfg.Gateway.OpenAIWS
  858. if wsCfg.RetryBackoffInitialMS > 0 {
  859. initial = time.Duration(wsCfg.RetryBackoffInitialMS) * time.Millisecond
  860. }
  861. if wsCfg.RetryBackoffMaxMS > 0 {
  862. maxBackoff = time.Duration(wsCfg.RetryBackoffMaxMS) * time.Millisecond
  863. }
  864. if wsCfg.RetryJitterRatio >= 0 {
  865. jitterRatio = wsCfg.RetryJitterRatio
  866. }
  867. }
  868. if initial <= 0 {
  869. return 0
  870. }
  871. if maxBackoff <= 0 {
  872. maxBackoff = initial
  873. }
  874. if maxBackoff < initial {
  875. maxBackoff = initial
  876. }
  877. if jitterRatio < 0 {
  878. jitterRatio = 0
  879. }
  880. if jitterRatio > 1 {
  881. jitterRatio = 1
  882. }
  883. shift := attempt - 1
  884. if shift < 0 {
  885. shift = 0
  886. }
  887. backoff := initial
  888. if shift > 0 {
  889. backoff = initial * time.Duration(1<<shift)
  890. }
  891. if backoff > maxBackoff {
  892. backoff = maxBackoff
  893. }
  894. if jitterRatio <= 0 {
  895. return backoff
  896. }
  897. jitter := time.Duration(float64(backoff) * jitterRatio)
  898. if jitter <= 0 {
  899. return backoff
  900. }
  901. delta := time.Duration(rand.Int63n(int64(jitter)*2+1)) - jitter
  902. withJitter := backoff + delta
  903. if withJitter < 0 {
  904. return 0
  905. }
  906. return withJitter
  907. }
  908. func (s *OpenAIGatewayService) openAIWSRetryTotalBudget() time.Duration {
  909. if s != nil && s.cfg != nil {
  910. ms := s.cfg.Gateway.OpenAIWS.RetryTotalBudgetMS
  911. if ms <= 0 {
  912. return 0
  913. }
  914. return time.Duration(ms) * time.Millisecond
  915. }
  916. return 0
  917. }
  918. func (s *OpenAIGatewayService) recordOpenAIWSRetryAttempt(backoff time.Duration) {
  919. if s == nil {
  920. return
  921. }
  922. s.openaiWSRetryMetrics.retryAttempts.Add(1)
  923. if backoff > 0 {
  924. s.openaiWSRetryMetrics.retryBackoffMs.Add(backoff.Milliseconds())
  925. }
  926. }
  927. func (s *OpenAIGatewayService) recordOpenAIWSRetryExhausted() {
  928. if s == nil {
  929. return
  930. }
  931. s.openaiWSRetryMetrics.retryExhausted.Add(1)
  932. }
  933. func (s *OpenAIGatewayService) recordOpenAIWSNonRetryableFastFallback() {
  934. if s == nil {
  935. return
  936. }
  937. s.openaiWSRetryMetrics.nonRetryableFastFallback.Add(1)
  938. }
  939. func (s *OpenAIGatewayService) SnapshotOpenAIWSRetryMetrics() OpenAIWSRetryMetricsSnapshot {
  940. if s == nil {
  941. return OpenAIWSRetryMetricsSnapshot{}
  942. }
  943. return OpenAIWSRetryMetricsSnapshot{
  944. RetryAttemptsTotal: s.openaiWSRetryMetrics.retryAttempts.Load(),
  945. RetryBackoffMsTotal: s.openaiWSRetryMetrics.retryBackoffMs.Load(),
  946. RetryExhaustedTotal: s.openaiWSRetryMetrics.retryExhausted.Load(),
  947. NonRetryableFastFallbackTotal: s.openaiWSRetryMetrics.nonRetryableFastFallback.Load(),
  948. }
  949. }
  950. func SnapshotOpenAICompatibilityFallbackMetrics() OpenAICompatibilityFallbackMetricsSnapshot {
  951. legacyReadFallbackTotal, legacyReadFallbackHit, legacyDualWriteTotal := openAIStickyCompatStats()
  952. isMaxTokensOneHaiku, thinkingEnabled, prefetchedStickyAccount, prefetchedStickyGroup, singleAccountRetry, accountSwitchCount := RequestMetadataFallbackStats()
  953. readHitRate := float64(0)
  954. if legacyReadFallbackTotal > 0 {
  955. readHitRate = float64(legacyReadFallbackHit) / float64(legacyReadFallbackTotal)
  956. }
  957. metadataFallbackTotal := isMaxTokensOneHaiku + thinkingEnabled + prefetchedStickyAccount + prefetchedStickyGroup + singleAccountRetry + accountSwitchCount
  958. return OpenAICompatibilityFallbackMetricsSnapshot{
  959. SessionHashLegacyReadFallbackTotal: legacyReadFallbackTotal,
  960. SessionHashLegacyReadFallbackHit: legacyReadFallbackHit,
  961. SessionHashLegacyDualWriteTotal: legacyDualWriteTotal,
  962. SessionHashLegacyReadHitRate: readHitRate,
  963. MetadataLegacyFallbackIsMaxTokensOneHaikuTotal: isMaxTokensOneHaiku,
  964. MetadataLegacyFallbackThinkingEnabledTotal: thinkingEnabled,
  965. MetadataLegacyFallbackPrefetchedStickyAccount: prefetchedStickyAccount,
  966. MetadataLegacyFallbackPrefetchedStickyGroup: prefetchedStickyGroup,
  967. MetadataLegacyFallbackSingleAccountRetryTotal: singleAccountRetry,
  968. MetadataLegacyFallbackAccountSwitchCountTotal: accountSwitchCount,
  969. MetadataLegacyFallbackTotal: metadataFallbackTotal,
  970. }
  971. }
  972. func (s *OpenAIGatewayService) detectCodexClientRestriction(c *gin.Context, account *Account, body []byte) CodexClientRestrictionDetectionResult {
  973. // 安全默认:即便缺 settingService(仅测试/误配可达)也保持指纹门为默认种子,
  974. // 避免零值 policy(nil 信号)让指纹门失败开放。有 settingService 时整体覆盖为全局策略。
  975. policy := CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals}
  976. if account != nil && account.IsCodexCLIOnlyEnabled() && s != nil && s.settingService != nil {
  977. ctx := context.Background()
  978. if c != nil && c.Request != nil {
  979. ctx = c.Request.Context()
  980. }
  981. policy = s.settingService.GetCodexRestrictionPolicy(ctx)
  982. }
  983. return s.getCodexClientRestrictionDetector().Detect(c, account, policy, body)
  984. }
  985. func getAPIKeyIDFromContext(c *gin.Context) int64 {
  986. if c == nil {
  987. return 0
  988. }
  989. v, exists := c.Get("api_key")
  990. if !exists {
  991. return 0
  992. }
  993. apiKey, ok := v.(*APIKey)
  994. if !ok || apiKey == nil {
  995. return 0
  996. }
  997. return apiKey.ID
  998. }
  999. // isolateOpenAISessionID 将 apiKeyID 混入 session 标识符,
  1000. // 确保不同 API Key 的用户即使使用相同的原始 session_id/conversation_id,
  1001. // 到达上游的标识符也不同,防止跨用户会话碰撞。
  1002. func isolateOpenAISessionID(apiKeyID int64, raw string) string {
  1003. raw = strings.TrimSpace(raw)
  1004. if raw == "" {
  1005. return ""
  1006. }
  1007. h := xxhash.New()
  1008. _, _ = fmt.Fprintf(h, "k%d:", apiKeyID)
  1009. _, _ = h.WriteString(raw)
  1010. return fmt.Sprintf("%016x", h.Sum64())
  1011. }
  1012. func logCodexCLIOnlyDetection(ctx context.Context, c *gin.Context, account *Account, apiKeyID int64, result CodexClientRestrictionDetectionResult, body []byte) {
  1013. if !result.Enabled {
  1014. return
  1015. }
  1016. if ctx == nil {
  1017. ctx = context.Background()
  1018. }
  1019. accountID := int64(0)
  1020. if account != nil {
  1021. accountID = account.ID
  1022. }
  1023. fields := []zap.Field{
  1024. zap.String("component", "service.openai_gateway"),
  1025. zap.Int64("account_id", accountID),
  1026. zap.Bool("codex_cli_only_enabled", result.Enabled),
  1027. zap.Bool("codex_official_client_match", result.Matched),
  1028. zap.String("reject_reason", result.Reason),
  1029. }
  1030. if apiKeyID > 0 {
  1031. fields = append(fields, zap.Int64("api_key_id", apiKeyID))
  1032. }
  1033. if !result.Matched {
  1034. fields = appendCodexCLIOnlyRejectedRequestFields(fields, c, body)
  1035. }
  1036. log := logger.FromContext(ctx).With(fields...)
  1037. if result.Matched {
  1038. log.Info("OpenAI codex_cli_only 放行请求")
  1039. return
  1040. }
  1041. log.Warn("OpenAI codex_cli_only 拒绝非官方客户端请求")
  1042. }
  1043. func appendCodexCLIOnlyRejectedRequestFields(fields []zap.Field, c *gin.Context, body []byte) []zap.Field {
  1044. if c == nil || c.Request == nil {
  1045. return fields
  1046. }
  1047. req := c.Request
  1048. requestModel, requestStream, promptCacheKey := extractOpenAIRequestMetaFromBody(body)
  1049. fields = append(fields,
  1050. zap.String("request_method", strings.TrimSpace(req.Method)),
  1051. zap.String("request_path", strings.TrimSpace(req.URL.Path)),
  1052. zap.String("request_query", strings.TrimSpace(req.URL.RawQuery)),
  1053. zap.String("request_host", strings.TrimSpace(req.Host)),
  1054. zap.String("request_client_ip", strings.TrimSpace(ip.GetClientIP(c))),
  1055. zap.String("request_remote_addr", strings.TrimSpace(req.RemoteAddr)),
  1056. zap.String("request_user_agent", strings.TrimSpace(req.Header.Get("User-Agent"))),
  1057. zap.String("request_content_type", strings.TrimSpace(req.Header.Get("Content-Type"))),
  1058. zap.Int64("request_content_length", req.ContentLength),
  1059. zap.Bool("request_stream", requestStream),
  1060. )
  1061. if requestModel != "" {
  1062. fields = append(fields, zap.String("request_model", requestModel))
  1063. }
  1064. if promptCacheKey != "" {
  1065. fields = append(fields, zap.String("request_prompt_cache_key_sha256", hashSensitiveValueForLog(promptCacheKey)))
  1066. }
  1067. if headers := snapshotCodexCLIOnlyHeaders(req.Header); len(headers) > 0 {
  1068. fields = append(fields, zap.Any("request_headers", headers))
  1069. }
  1070. fields = append(fields, zap.Int("request_body_size", len(body)))
  1071. return fields
  1072. }
  1073. func snapshotCodexCLIOnlyHeaders(header http.Header) map[string]string {
  1074. if len(header) == 0 {
  1075. return nil
  1076. }
  1077. result := make(map[string]string, len(codexCLIOnlyDebugHeaderWhitelist))
  1078. for _, key := range codexCLIOnlyDebugHeaderWhitelist {
  1079. value := strings.TrimSpace(header.Get(key))
  1080. if value == "" {
  1081. continue
  1082. }
  1083. result[strings.ToLower(key)] = truncateString(value, codexCLIOnlyHeaderValueMaxBytes)
  1084. }
  1085. return result
  1086. }
  1087. func hashSensitiveValueForLog(raw string) string {
  1088. value := strings.TrimSpace(raw)
  1089. if value == "" {
  1090. return ""
  1091. }
  1092. sum := sha256.Sum256([]byte(value))
  1093. return hex.EncodeToString(sum[:8])
  1094. }
  1095. // GetAccessToken gets the access token for an OpenAI account
  1096. func (s *OpenAIGatewayService) GetAccessToken(ctx context.Context, account *Account) (string, string, error) {
  1097. if account.IsShadow() {
  1098. credAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account)
  1099. if err != nil {
  1100. return "", "", err
  1101. }
  1102. account = credAccount
  1103. }
  1104. switch account.Type {
  1105. case AccountTypeOAuth:
  1106. if account.IsOpenAIAgentIdentity() {
  1107. return "", OpenAIAuthModeAgentIdentity, nil
  1108. }
  1109. if account.Platform == PlatformGrok {
  1110. if s.grokTokenProvider != nil {
  1111. accessToken, err := s.grokTokenProvider.GetAccessToken(ctx, account)
  1112. if err != nil {
  1113. return "", "", err
  1114. }
  1115. return accessToken, "oauth", nil
  1116. }
  1117. accessToken := account.GetGrokAccessToken()
  1118. if accessToken == "" {
  1119. return "", "", errors.New("access_token not found in credentials")
  1120. }
  1121. return accessToken, "oauth", nil
  1122. }
  1123. // 使用 TokenProvider 获取缓存的 token
  1124. if s.openAITokenProvider != nil {
  1125. accessToken, err := s.openAITokenProvider.GetAccessToken(ctx, account)
  1126. if err != nil {
  1127. return "", "", err
  1128. }
  1129. return accessToken, "oauth", nil
  1130. }
  1131. // 降级:TokenProvider 未配置时直接从账号读取
  1132. accessToken := account.GetOpenAIAccessToken()
  1133. if accessToken == "" {
  1134. return "", "", errors.New("access_token not found in credentials")
  1135. }
  1136. return accessToken, "oauth", nil
  1137. case AccountTypeSetupToken:
  1138. if !account.IsOpenAIOAuthLike() {
  1139. return "", "", fmt.Errorf("unsupported account type: %s", account.Type)
  1140. }
  1141. // OpenAI setup tokens are inference-only bearer credentials. They use the
  1142. // Codex OAuth forwarding protocol but have no refresh-token lifecycle.
  1143. accessToken := account.GetOpenAIAccessToken()
  1144. if accessToken == "" {
  1145. return "", "", errors.New("access_token not found in credentials")
  1146. }
  1147. return accessToken, "oauth", nil
  1148. case AccountTypeAPIKey:
  1149. if account.Platform == PlatformGrok {
  1150. apiKey := strings.TrimSpace(account.GetCredential("api_key"))
  1151. if apiKey == "" {
  1152. return "", "", errors.New("api_key not found in credentials")
  1153. }
  1154. return apiKey, "apikey", nil
  1155. }
  1156. apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey())
  1157. if apiKey == "" {
  1158. return "", "", errors.New("api_key not found in credentials")
  1159. }
  1160. return apiKey, "apikey", nil
  1161. default:
  1162. return "", "", fmt.Errorf("unsupported account type: %s", account.Type)
  1163. }
  1164. }