openai_codex_turn_state.go 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. package service
  2. import (
  3. "net/http"
  4. "strconv"
  5. "strings"
  6. "time"
  7. "github.com/gin-gonic/gin"
  8. )
  9. // openAICodexTurnStateHeader 是 Codex 的回合状态头。上游在响应头中铸造该
  10. // 不透明 blob,客户端在同一回合的后续请求中原样回带(codex-rs 侧从
  11. // /responses SSE、/responses/compact JSON 与 WS 握手三种响应中捕获,见
  12. // codex-api/src/sse/responses.rs 与 endpoint/compact.rs)。
  13. const openAICodexTurnStateHeader = "x-codex-turn-state"
  14. // turn-state blob 是上游在"出站身份"(含 #5553 指纹收敛改写后的
  15. // installation/session/thread 标识)下铸造的,同账号回放自洽;跨账号回放
  16. // (failover 换号后客户端仍回带旧账号的 blob)是代理链独有、真实 Codex
  17. // 永远不会产生的矛盾信号。溯源表记录每个下游会话最近一次铸造该 blob 的
  18. // 账号,出站守卫据此剥离已知异账号的回带值。
  19. type openAICodexTurnStateOrigin struct {
  20. accountID int64
  21. expiresAt time.Time
  22. }
  23. // openAICodexTurnStateSeed 返回溯源表键:API Key + 客户端原始会话标识。
  24. // 客户端会话标识取自请求头(与指纹收敛的 thread 派生同源,见
  25. // extractClientSessionID),确保同一下游会话的记录/守卫两侧使用同一键。
  26. // 无会话标识时返回空串,表示不做跟踪(保持透传现状)。
  27. func openAICodexTurnStateSeed(c *gin.Context) string {
  28. if c == nil || c.Request == nil {
  29. return ""
  30. }
  31. sessionID := extractClientSessionID(c.Request.Header)
  32. if sessionID == "" {
  33. return ""
  34. }
  35. return strconv.FormatInt(getAPIKeyIDFromContext(c), 10) + "\x00" + sessionID
  36. }
  37. // relayOpenAICodexTurnState 将上游响应中的 turn-state 显式写入下游响应头,
  38. // 并记录铸造账号。必须在响应头提交点调用(WriteHeader 之前、且确认本次
  39. // 上游响应就是将要写回客户端的响应之后)。上游无该头时主动清除 writer 上
  40. // 可能残留的上一 failover attempt 的值——否则换号后旧账号的 blob 会粘到
  41. // 新账号的响应上,这正是本文件要防止的跨账号矛盾。
  42. func (s *OpenAIGatewayService) relayOpenAICodexTurnState(c *gin.Context, account *Account, upstream http.Header) {
  43. if c == nil || c.Writer == nil {
  44. return
  45. }
  46. canonical := http.CanonicalHeaderKey(openAICodexTurnStateHeader)
  47. state := extractOpenAICodexTurnState(upstream)
  48. if state == "" {
  49. c.Writer.Header().Del(canonical)
  50. return
  51. }
  52. c.Writer.Header().Set(canonical, state)
  53. s.noteOpenAICodexTurnStateProvenance(c, account)
  54. }
  55. // stageOpenAICodexTurnState 将上游 turn-state 暂存到延迟提交的响应头集合
  56. // (首输出守卫路径先缓存头、见到首个输出事件才提交)。此处**不**记录铸造
  57. // 账号:该 attempt 仍可能在首输出超时后 failover,暂存头会被整体丢弃,
  58. // 客户端从未收到该 blob。溯源必须在真正提交时记录,见
  59. // noteStagedOpenAICodexTurnStateCommitted。
  60. func stageOpenAICodexTurnState(dst *http.Header, upstream http.Header) {
  61. if dst == nil {
  62. return
  63. }
  64. canonical := http.CanonicalHeaderKey(openAICodexTurnStateHeader)
  65. state := extractOpenAICodexTurnState(upstream)
  66. if state == "" {
  67. if *dst != nil {
  68. dst.Del(canonical)
  69. }
  70. return
  71. }
  72. if *dst == nil {
  73. *dst = http.Header{}
  74. }
  75. dst.Set(canonical, state)
  76. }
  77. // noteStagedOpenAICodexTurnStateCommitted 在暂存响应头真正写入下游时记录
  78. // 铸造账号——只有此刻客户端才确定收到了该 blob,溯源表才与客户端持有的
  79. // 值一致(否则被 failover 丢弃的 attempt 会污染溯源,导致后续误剥离)。
  80. func (s *OpenAIGatewayService) noteStagedOpenAICodexTurnStateCommitted(c *gin.Context, account *Account, staged http.Header) {
  81. if staged == nil || strings.TrimSpace(staged.Get(openAICodexTurnStateHeader)) == "" {
  82. return
  83. }
  84. s.noteOpenAICodexTurnStateProvenance(c, account)
  85. }
  86. func extractOpenAICodexTurnState(upstream http.Header) string {
  87. if upstream == nil {
  88. return ""
  89. }
  90. return strings.TrimSpace(upstream.Get(openAICodexTurnStateHeader))
  91. }
  92. // noteOpenAICodexTurnStateProvenance 记录(下游会话 → 铸造账号)。
  93. func (s *OpenAIGatewayService) noteOpenAICodexTurnStateProvenance(c *gin.Context, account *Account) {
  94. if s == nil || account == nil || account.ID <= 0 {
  95. return
  96. }
  97. seed := openAICodexTurnStateSeed(c)
  98. if seed == "" {
  99. return
  100. }
  101. s.openaiCodexTurnStateOrigins.Store(seed, openAICodexTurnStateOrigin{
  102. accountID: account.ID,
  103. expiresAt: time.Now().Add(s.openAIWSSessionStickyTTL()),
  104. })
  105. s.sweepOpenAICodexTurnStateOrigins()
  106. }
  107. // guardOpenAICodexTurnStateEcho 出站守卫:客户端回带的 turn-state 若已知由
  108. // 其他账号铸造则剥离,同账号或无溯源记录时保持原样。只剥离、不注入——
  109. // /responses 路径的客户端是真实 Codex,会按自身回合语义自行回带;服务端
  110. // 注入是 Claude 兼容桥(无法回带的客户端)的专属行为。
  111. func (s *OpenAIGatewayService) guardOpenAICodexTurnStateEcho(c *gin.Context, account *Account, h http.Header) {
  112. if s == nil || h == nil || account == nil {
  113. return
  114. }
  115. if strings.TrimSpace(h.Get(openAICodexTurnStateHeader)) == "" {
  116. return
  117. }
  118. seed := openAICodexTurnStateSeed(c)
  119. if seed == "" {
  120. return
  121. }
  122. raw, ok := s.openaiCodexTurnStateOrigins.Load(seed)
  123. if !ok {
  124. return
  125. }
  126. origin, ok := raw.(openAICodexTurnStateOrigin)
  127. if !ok {
  128. s.openaiCodexTurnStateOrigins.Delete(seed)
  129. return
  130. }
  131. if !origin.expiresAt.IsZero() && time.Now().After(origin.expiresAt) {
  132. s.openaiCodexTurnStateOrigins.Delete(seed)
  133. return
  134. }
  135. if origin.accountID != account.ID {
  136. h.Del(openAICodexTurnStateHeader)
  137. }
  138. }
  139. // sweepOpenAICodexTurnStateOrigins 机会式清扫过期溯源记录:每 256 次写入
  140. // 全量遍历一轮,防止仅靠读侧惰性删除导致的慢泄漏(会话键无上界)。
  141. func (s *OpenAIGatewayService) sweepOpenAICodexTurnStateOrigins() {
  142. if s.openaiCodexTurnStateWrites.Add(1)%256 != 0 {
  143. return
  144. }
  145. now := time.Now()
  146. s.openaiCodexTurnStateOrigins.Range(func(key, value any) bool {
  147. origin, ok := value.(openAICodexTurnStateOrigin)
  148. if !ok || (!origin.expiresAt.IsZero() && now.After(origin.expiresAt)) {
  149. s.openaiCodexTurnStateOrigins.Delete(key)
  150. }
  151. return true
  152. })
  153. }