openai_codex_identity.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. package service
  2. import (
  3. "net/http"
  4. "regexp"
  5. "strings"
  6. "sync"
  7. "sync/atomic"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  9. "github.com/google/uuid"
  10. )
  11. // codexUpstreamMinVersion 上游 /backend-api/codex 接受的最低 version 头:
  12. // 若请求携带 version 且低于该值,上游直接 404(issue #3901,2026-07 实测)。
  13. const codexUpstreamMinVersion = "0.144.0"
  14. // codexClientVersionMaxLen 官方版本号均为短 ASCII 串,远低于此上限。
  15. const codexClientVersionMaxLen = 64
  16. // codexClientVersionPattern 允许 0.146.0 与 0.147.0-alpha.4 两类官方形态。
  17. var codexClientVersionPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+){1,3}(-[0-9A-Za-z.]+)?$`)
  18. // NormalizeCodexClientVersion 校验并归一化 Codex 客户端版本号,非法值返回空串。
  19. // 该值会被拼进出站 User-Agent 与 version 头,必须拒绝任意字节,避免管理员误填或
  20. // 自动同步拿到异常值时把不可控内容透给上游。
  21. func NormalizeCodexClientVersion(version string) string {
  22. version = strings.TrimSpace(version)
  23. if version == "" || len(version) > codexClientVersionMaxLen || !codexClientVersionPattern.MatchString(version) {
  24. return ""
  25. }
  26. return version
  27. }
  28. // buildCodexCLIUserAgent 按版本号拼出规范 Codex TUI User-Agent。
  29. // UA 形态只在 codexCLIUserAgentSuffix 一处定义,避免多处拼装漂移。
  30. func buildCodexCLIUserAgent(version string) string {
  31. if version = NormalizeCodexClientVersion(version); version == "" {
  32. return codexCLIUserAgent
  33. }
  34. return openai.CodexDefaultOriginator + "/" + version + codexCLIUserAgentSuffix
  35. }
  36. // codexIdentityEnforcement 控制 enforceCodexIdentityHeaders 是否强制统一出站身份,
  37. // 由 gateway.disable_codex_identity_enforcement 在服务构造时取反发布。
  38. // 默认开启:上游在容量紧张时按客户端身份分优先级降载,被降载的请求会拿到
  39. // HTTP 200 + 流内 server_is_overloaded,本次请求即失败;强制统一出口可确保没有
  40. // 请求带着第三方或陈旧身份出站。关闭后退回「仅按最终 UA 配对 originator」的收口语义。
  41. var codexIdentityEnforcement = func() *atomic.Bool {
  42. v := &atomic.Bool{}
  43. v.Store(true)
  44. return v
  45. }()
  46. // SetCodexIdentityEnforcementEnabled 发布 Codex 出站身份强制统一开关。
  47. // enforceCodexIdentityHeaders 是所有出站路径共用的纯函数收口点,无法在热路径注入配置,
  48. // 故由持有配置的服务在构造时发布进程级快照。
  49. func SetCodexIdentityEnforcementEnabled(enabled bool) {
  50. codexIdentityEnforcement.Store(enabled)
  51. }
  52. // codexCanonicalUserAgentResolver 返回当前生效的规范 Codex User-Agent(后台设置 / 自动同步版本号)。
  53. // 由 SettingService 在装配时注入;解析器内部自带 TTL 缓存,热路径不触库。
  54. type codexCanonicalUserAgentResolver func() string
  55. var (
  56. codexCanonicalUAMu sync.RWMutex
  57. codexCanonicalUAResolver codexCanonicalUserAgentResolver
  58. )
  59. // SetCodexCanonicalUserAgentResolver 注入规范 User-Agent 解析器。
  60. // 未注入或解析结果非法时回退到编译期常量 codexCLIUserAgent。
  61. func SetCodexCanonicalUserAgentResolver(resolver func() string) {
  62. codexCanonicalUAMu.Lock()
  63. defer codexCanonicalUAMu.Unlock()
  64. codexCanonicalUAResolver = resolver
  65. }
  66. // CodexCanonicalUserAgent 返回当前生效的规范 Codex User-Agent。
  67. // 取值走与推理相同的解析链:面板 UA 指纹 + 面板/自动同步版本号 + 编译期兜底。
  68. // 供无账号句柄的出站路径(OAuth 换 Token / 刷新)使用。
  69. func CodexCanonicalUserAgent() string {
  70. return resolveCodexOutboundIdentity("").userAgent
  71. }
  72. // CodexCanonicalAuthIdentity 返回凭据面(auth.openai.com:换 Token / 刷新 / whoami)
  73. // 出站请求的身份对:规范 User-Agent 与配套 originator,与推理解析链同源。
  74. // 凭据面不发 version 头——真实 Codex 客户端在该面只携带 originator 与 User-Agent
  75. // (codex-rs login/default_client.rs 的 default_headers()),version 门槛
  76. // (issue #3901)只存在于 /backend-api/codex 推理面。
  77. func CodexCanonicalAuthIdentity() (userAgent, originator string) {
  78. identity := resolveCodexOutboundIdentity("")
  79. return identity.userAgent, identity.originator
  80. }
  81. // ApplyCodexCanonicalAuthIdentity 为凭据面出站请求写入身份对(不含 version)。
  82. func ApplyCodexCanonicalAuthIdentity(h http.Header) {
  83. if h == nil {
  84. return
  85. }
  86. userAgent, originator := CodexCanonicalAuthIdentity()
  87. h.Set("user-agent", userAgent)
  88. h.Set("originator", originator)
  89. }
  90. // CodexCanonicalClientVersion 返回当前生效的 Codex 客户端版本号。
  91. func CodexCanonicalClientVersion() string {
  92. return resolveCodexOutboundIdentity("").version
  93. }
  94. // codexCanonicalUserAgent 返回出站规范 User-Agent。
  95. func codexCanonicalUserAgent() string {
  96. codexCanonicalUAMu.RLock()
  97. resolver := codexCanonicalUAResolver
  98. codexCanonicalUAMu.RUnlock()
  99. if resolver != nil {
  100. if ua := strings.TrimSpace(resolver()); ua != "" {
  101. return ua
  102. }
  103. }
  104. return codexCLIUserAgent
  105. }
  106. // codexOutboundIdentity 出站身份三元组,三者必须同源自洽:
  107. // originator 与 User-Agent 首段配套(否则上游 404,issue #3901),
  108. // version 等于 User-Agent 的版本段且不低于上游门槛。
  109. type codexOutboundIdentity struct {
  110. userAgent string
  111. originator string
  112. version string
  113. }
  114. // resolveCodexOutboundIdentity 由候选 User-Agent 推导自洽的出站身份。
  115. // candidateUA 为空时使用规范 User-Agent;推导不出官方身份时整体回退为规范 TUI 身份。
  116. //
  117. // 候选 UA(面板 / 账号级的管理员显式配置)只贡献客户端名与 OS / 架构 / 终端指纹,
  118. // 其自带的版本段一律用当前生效版本重建:一条填写于某个历史版本的 UA 否则会把出站身份
  119. // 永久钉死在陈旧版本上,绕过版本自动同步,落回上游优先降载的那一侧。
  120. // 需要固定版本请填「Codex 客户端版本号」并关闭自动同步。
  121. func resolveCodexOutboundIdentity(candidateUA string) codexOutboundIdentity {
  122. canonical := codexCanonicalUserAgent()
  123. ua := strings.TrimSpace(candidateUA)
  124. if ua == "" {
  125. ua = canonical
  126. }
  127. originator, pairedUA, ok := openai.PairCodexClientIdentity(ua)
  128. if !ok {
  129. if originator, pairedUA, ok = openai.PairCodexClientIdentity(canonical); !ok {
  130. originator, pairedUA = openai.CodexDefaultOriginator, codexCLIUserAgent
  131. }
  132. }
  133. // 生效版本只有一个来源:规范身份(面板版本号 → 自动同步值 → 内置常量,见
  134. // SettingService.GetOpenAICodexClientVersion)。UA 与 version 头由此同源派生。
  135. version := codexClientVersionFromUA(canonical)
  136. if rebuilt := openai.SetCodexUserAgentVersion(pairedUA, version); rebuilt != "" {
  137. pairedUA = rebuilt
  138. }
  139. return codexOutboundIdentity{userAgent: pairedUA, originator: originator, version: version}
  140. }
  141. // codexClientVersionFromUA 取 UA 的版本段作为生效版本;
  142. // 非法或低于上游门槛(低于则上游 404,issue #3901)时回退编译期常量。
  143. func codexClientVersionFromUA(ua string) string {
  144. version := NormalizeCodexClientVersion(openai.CodexUserAgentVersion(ua))
  145. if version == "" || CompareVersions(version, codexUpstreamMinVersion) < 0 {
  146. return codexCLIVersion
  147. }
  148. return version
  149. }
  150. // ensureCodexIdentityHeaders 补齐 OAuth(ChatGPT 内部接口)出站请求所需的 Codex 身份头。
  151. // 已有 User-Agent 与 version 保持不变,交给紧随其后的 enforceCodexIdentityHeaders 收口。
  152. func ensureCodexIdentityHeaders(h http.Header) {
  153. if h == nil {
  154. return
  155. }
  156. identity := resolveCodexOutboundIdentity("")
  157. if strings.TrimSpace(h.Get("user-agent")) == "" {
  158. h.Set("user-agent", identity.userAgent)
  159. }
  160. if strings.TrimSpace(h.Get("originator")) == "" {
  161. h.Set("originator", identity.originator)
  162. }
  163. if strings.TrimSpace(h.Get("version")) == "" {
  164. h.Set("version", identity.version)
  165. }
  166. h.Set("OpenAI-Beta", "responses=experimental")
  167. }
  168. // applyOpenAICodexProbeHeaders 为合成探测请求补齐 Codex 身份和引擎指纹。
  169. func applyOpenAICodexProbeHeaders(h http.Header) {
  170. if h == nil {
  171. return
  172. }
  173. ensureCodexIdentityHeaders(h)
  174. h.Set("X-Codex-Window-ID", uuid.NewString())
  175. }
  176. // enforceCodexIdentityHeaders 收口 OAuth(ChatGPT 内部接口)出站请求的客户端身份头。
  177. // 见 enforceCodexIdentityHeadersWithUA;无账号级自定义 User-Agent 时使用本函数。
  178. func enforceCodexIdentityHeaders(h http.Header) {
  179. enforceCodexIdentityHeadersWithUA(h, "")
  180. }
  181. // enforceCodexIdentityHeadersWithUA 强制统一 OAuth 出站身份:User-Agent / originator / version
  182. // 一律改写为网关的规范身份,客户端自报身份不参与构造。上游在容量紧张时按客户端身份分优先级
  183. // 降载,被降载的请求会拿到 HTTP 200 + 流内 server_is_overloaded;统一出口可确保没有请求带着
  184. // 第三方或陈旧身份出站,也天然满足 originator 与 UA 首段配套的上游校验(issue #3901)。
  185. //
  186. // overrideUA 是账号级自定义 User-Agent:管理员的显式配置仍然生效,但只贡献客户端名与
  187. // OS / 架构 / 终端指纹——版本段与 originator 都由规范身份重建,不允许出现自相矛盾或陈旧的身份。
  188. //
  189. // 强制统一被 gateway.disable_codex_identity_enforcement 关闭时,退回「按最终 User-Agent 配对
  190. // originator + version 门槛校正」的收口语义,供上游策略变动时回滚。
  191. //
  192. // 仅对携带 originator 的请求生效:compat 桥接等非 ChatGPT 内部接口路径会显式删除 originator,
  193. // 不应被补回。需要从缺失身份头恢复的调用方应先调用 ensureCodexIdentityHeaders。
  194. // 必须在所有 User-Agent 改写之后调用。
  195. func enforceCodexIdentityHeadersWithUA(h http.Header, overrideUA string) {
  196. if h == nil || h.Get("originator") == "" {
  197. return
  198. }
  199. if !codexIdentityEnforcement.Load() {
  200. pairCodexIdentityHeaders(h)
  201. return
  202. }
  203. identity := resolveCodexOutboundIdentity(overrideUA)
  204. h.Set("user-agent", identity.userAgent)
  205. h.Set("originator", identity.originator)
  206. h.Set("version", identity.version)
  207. }
  208. // pairCodexIdentityHeaders 是关闭强制统一后的兜底收口:保留客户端真实身份,
  209. // 仅保证 originator 与最终 User-Agent 首段配套、version 不低于上游门槛(issue #3901)。
  210. func pairCodexIdentityHeaders(h http.Header) {
  211. originator, pairedUA, ok := openai.PairCodexClientIdentity(h.Get("user-agent"))
  212. if !ok {
  213. identity := resolveCodexOutboundIdentity("")
  214. originator, pairedUA = identity.originator, identity.userAgent
  215. h.Set("version", identity.version)
  216. }
  217. h.Set("user-agent", pairedUA)
  218. h.Set("originator", originator)
  219. if v := strings.TrimSpace(h.Get("version")); v != "" && CompareVersions(v, codexUpstreamMinVersion) < 0 {
  220. h.Set("version", resolveCodexOutboundIdentity("").version)
  221. }
  222. }