request.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. package openai
  2. import (
  3. "regexp"
  4. "strings"
  5. )
  6. // CodexCLIUserAgentPrefixes matches Codex CLI User-Agent patterns
  7. // Examples: "codex_vscode/1.0.0", "codex_cli_rs/0.1.2"
  8. var CodexCLIUserAgentPrefixes = []string{
  9. "codex_vscode/",
  10. "codex_cli_rs/",
  11. }
  12. // codexOfficialClientUAPrefixes:Codex 官方客户端家族 User-Agent 前缀(均含下划线/连字符,
  13. // 每项都是确定字面量;不含会被 TrimSpace 退化成裸 "codex" 的空格前缀)。
  14. // 用途:OpenAI OAuth `codex_cli_only` 访问限制判定 + passthrough 的「非官方 UA 安全兜底」
  15. // (IsCodexOfficialClientRequest 命中即视为官方真实 UA,逐字透传、不改写)。
  16. //
  17. // Cursor/VSCode 扩展两种 UA:默认 `codex_vscode/`、GitHub Copilot 集成模式 `codex_vscode_copilot/`
  18. // (取证 extension.js `IS="codex_vscode_copilot"` 经 env 注入);交互式 TUI 自报 `codex-tui/`
  19. // (连字符,2026-06-23 审计抽样约占真实流量 35%,必须显式列出)。`Codex Desktop/` 等 `Codex `
  20. // 前缀家族由 codexOfficialClientFamilyPrefix 单独处理(保留空格,避免退化为裸 codex 的宽松兜底)。
  21. var codexOfficialClientUAPrefixes = []string{
  22. "codex_cli_rs/",
  23. "codex-tui/",
  24. "codex_vscode/",
  25. "codex_vscode_copilot/",
  26. "codex_app/",
  27. "codex_chatgpt_desktop/",
  28. "codex_atlas/",
  29. "codex_exec/",
  30. "codex_sdk_ts/",
  31. }
  32. // codexOfficialClientFamilyPrefix 覆盖 `Codex ` 前缀家族(Codex Desktop 等),对应 codex-rs
  33. // is_first_party_originator 的 starts_with("Codex ")。**保留尾随空格**,并以 HasPrefix 直接比对
  34. // 已归一化(小写 + 去首尾空格)的值——绝不能再经 normalizeCodexClientHeader 处理本前缀,否则
  35. // 空格被 TrimSpace 去掉、退化成裸 "codex" 而把任何含 codex 的串都放行。
  36. const codexOfficialClientFamilyPrefix = "codex "
  37. // codexOfficialClientOriginators:Codex 官方客户端家族 originator 精确集合。
  38. // app-server `initialize` 把 originator 设为 clientInfo.name 逐字值(codex-rs default_client.rs),
  39. // 故官方集合是这些确定字面量;镜像 is_first_party_originator / is_first_party_chat_originator
  40. // 并叠加 sub2api 已取证变体。用精确匹配而非「含 codex_/codex」的宽松兜底,避免 evil-codex_ 之类
  41. // 伪造绕过(gate 仍需 UA 双因子佐证)。新官方/合作客户端经 allowed_client.go 命名预设放行,
  42. // 或在 bump context/codex 时同步补入本集合。
  43. var codexOfficialClientOriginators = map[string]bool{
  44. "codex_cli_rs": true, // CLI 默认 DEFAULT_ORIGINATOR
  45. "codex-tui": true, // 交互式 TUI(连字符,真实流量占比最高)
  46. "codex_vscode": true, // VSCode/Cursor 扩展
  47. "codex_vscode_copilot": true, // 扩展 GitHub Copilot 集成模式
  48. "codex_app": true, // 历史保留
  49. "codex_chatgpt_desktop": true, // is_first_party_chat_originator
  50. "codex_atlas": true, // is_first_party_chat_originator
  51. "codex_exec": true, // codex exec 非交互
  52. "codex_sdk_ts": true, // TypeScript SDK
  53. }
  54. // IsCodexCLIRequest checks if the User-Agent indicates a Codex CLI request
  55. func IsCodexCLIRequest(userAgent string) bool {
  56. ua := normalizeCodexClientHeader(userAgent)
  57. if ua == "" {
  58. return false
  59. }
  60. return matchCodexClientHeaderPrefixes(ua, CodexCLIUserAgentPrefixes)
  61. }
  62. // IsCodexOfficialClientRequest checks if the User-Agent indicates a Codex 官方客户端请求。
  63. // 与 IsCodexCLIRequest 解耦,避免影响历史兼容逻辑。宽松版:官方 UA 前缀集允许 Contains 子串兜底,
  64. // 供 passthrough(IsCodexOfficialClientByHeaders)等历史路径使用,行为不变。
  65. func IsCodexOfficialClientRequest(userAgent string) bool {
  66. return isCodexOfficialClientRequest(userAgent, false)
  67. }
  68. // IsCodexOfficialClientRequestStrict 同 IsCodexOfficialClientRequest,但官方 UA 前缀集只做前缀
  69. // 匹配(HasPrefix),不退化为 Contains 子串兜底——专供 codex_cli_only 访问门,收窄「浏览器前缀 +
  70. // 中段 codex token」之类的伪造面。`Codex ` 家族前缀与 UA 尾部兜底保持一致;passthrough 仍用宽松版。
  71. func IsCodexOfficialClientRequestStrict(userAgent string) bool {
  72. return isCodexOfficialClientRequest(userAgent, true)
  73. }
  74. // isCodexOfficialClientRequest 匹配层级(优先级由高到低):
  75. // 1. UA 前缀集 codexOfficialClientUAPrefixes(strict=仅 HasPrefix;否则含 Contains 子串兜底)
  76. // 2. `Codex ` 家族前缀(保留空格,避免退化为裸 codex)
  77. // 3. UA 尾部兜底:codex-rs 把 clientInfo.name 写入 UA 末尾括号组 `(name; version)`。
  78. // CODEX_INTERNAL_ORIGINATOR_OVERRIDE 只改前缀,不改尾部——可借此恢复被 override 的真实 client。
  79. // 生产审计(10GB / 23 天)显示,originator=cccc 的真实 codex-tui 占全 openai 流量 5.3%,
  80. // 若无此兜底则全部误拒。非官方尾部(如 evil/bash)仍被精确集拒绝。
  81. func isCodexOfficialClientRequest(userAgent string, strict bool) bool {
  82. ua := normalizeCodexClientHeader(userAgent)
  83. if ua == "" {
  84. return false
  85. }
  86. if strict {
  87. if matchCodexClientHeaderStrictPrefixes(ua, codexOfficialClientUAPrefixes) {
  88. return true
  89. }
  90. } else if matchCodexClientHeaderPrefixes(ua, codexOfficialClientUAPrefixes) {
  91. return true
  92. }
  93. if strings.HasPrefix(ua, codexOfficialClientFamilyPrefix) {
  94. return true
  95. }
  96. // UA 尾部兜底:提取最后一个括号组里的 name 段,用官方 originator 检测器判定。
  97. if name := codexUATrailerName(ua); name != "" {
  98. return IsCodexOfficialClientOriginator(name)
  99. }
  100. return false
  101. }
  102. // codexUATrailerName extracts the clientInfo.name from the last parenthesized group
  103. // of a codex-rs formatted User-Agent: `{orig}/{ver} ({os}; {arch}) {term} ({name}; {ver})`.
  104. //
  105. // CODEX_INTERNAL_ORIGINATOR_OVERRIDE 修改 UA 前缀(originator 段),但不修改尾部的
  106. // `(name; version)` 括号组——该组由 codex-rs engine 写入,保留真实 clientInfo.name。
  107. // 故从尾部提取 name 可以恢复被 override 的真实客户端标识(例如 cccc → codex-tui)。
  108. //
  109. // input 应为去首尾空格的 UA;本函数本身大小写无关,大小写由调用方按需处理
  110. // (isCodexOfficialClientRequest 传入已小写化的 UA 做匹配;PairCodexClientIdentity
  111. // 传入原始大小写以保留 originator 的真实大小写)。
  112. // 若无法解析则返回空字符串。
  113. func codexUATrailerName(ua string) string {
  114. last := strings.LastIndex(ua, "(")
  115. if last < 0 {
  116. return ""
  117. }
  118. rest := ua[last+1:]
  119. closeIdx := strings.Index(rest, ")")
  120. if closeIdx < 0 {
  121. return ""
  122. }
  123. inner := strings.TrimSpace(rest[:closeIdx])
  124. if semi := strings.Index(inner, ";"); semi >= 0 {
  125. inner = strings.TrimSpace(inner[:semi])
  126. }
  127. return inner
  128. }
  129. // IsCodexOfficialClientOriginator checks if originator indicates a Codex 官方客户端请求。
  130. // 精确集合匹配 + `Codex ` 家族前缀;不再用「含 codex」宽松兜底(避免伪造绕过)。
  131. func IsCodexOfficialClientOriginator(originator string) bool {
  132. v := normalizeCodexClientHeader(originator)
  133. if v == "" {
  134. return false
  135. }
  136. if codexOfficialClientOriginators[v] {
  137. return true
  138. }
  139. return strings.HasPrefix(v, codexOfficialClientFamilyPrefix)
  140. }
  141. // IsCodexOfficialClientByHeaders checks whether the request headers indicate an
  142. // official Codex client family request.
  143. func IsCodexOfficialClientByHeaders(userAgent, originator string) bool {
  144. return IsCodexOfficialClientRequest(userAgent) || IsCodexOfficialClientOriginator(originator)
  145. }
  146. func normalizeCodexClientHeader(value string) string {
  147. return strings.ToLower(strings.TrimSpace(value))
  148. }
  149. func matchCodexClientHeaderPrefixes(value string, prefixes []string) bool {
  150. for _, prefix := range prefixes {
  151. normalizedPrefix := normalizeCodexClientHeader(prefix)
  152. if normalizedPrefix == "" {
  153. continue
  154. }
  155. // 优先前缀匹配;若 UA/Originator 被网关拼接为复合字符串时,退化为包含匹配。
  156. if strings.HasPrefix(value, normalizedPrefix) || strings.Contains(value, normalizedPrefix) {
  157. return true
  158. }
  159. }
  160. return false
  161. }
  162. // matchCodexClientHeaderStrictPrefixes 仅前缀匹配(HasPrefix),不含 matchCodexClientHeaderPrefixes
  163. // 的 Contains 子串兜底。用于 codex_cli_only 官方门收窄伪造面;passthrough 历史路径仍用宽松版。
  164. // value 应为已归一化(小写 + 去首尾空格)的值。
  165. func matchCodexClientHeaderStrictPrefixes(value string, prefixes []string) bool {
  166. for _, prefix := range prefixes {
  167. if p := normalizeCodexClientHeader(prefix); p != "" && strings.HasPrefix(value, p) {
  168. return true
  169. }
  170. }
  171. return false
  172. }
  173. // PairCodexClientIdentity 由最终出站 User-Agent 推导与其配套的 originator,必要时归一化
  174. // UA 首段,保证两者一致。上游 /backend-api/codex 会校验 originator 与 UA 首段(首个 '/'
  175. // 之前的 client 名)是否配套,错配(如 originator=codex_cli_rs + UA=codex-tui/...)一律
  176. // 404(issue #3901,2026-07 实测)。
  177. //
  178. // 推导优先级:
  179. // 1. UA 首段是官方 originator(精确集合或 `Codex ` 家族前缀)→ 直接配对,UA 原样保留;
  180. // 2. UA 尾部括号组 `(name; version)` 的 name 是官方 originator——CODEX_INTERNAL_ORIGINATOR_OVERRIDE
  181. // 只改 UA 前缀不改尾部(如 cccc/0.142.0 ... (codex-tui; 0.142.0))→ 用尾部 name 重写
  182. // UA 首段后配对,保留真实版本/OS/终端指纹;
  183. // 3. 均不命中 → ok=false,调用方应整体回退为默认官方身份。
  184. func PairCodexClientIdentity(userAgent string) (originator string, pairedUA string, ok bool) {
  185. ua := strings.TrimSpace(userAgent)
  186. slash := strings.IndexByte(ua, '/')
  187. if slash <= 0 {
  188. return "", "", false
  189. }
  190. if leading := strings.TrimSpace(ua[:slash]); isSaneCodexOriginator(leading) && IsCodexOfficialClientOriginator(leading) {
  191. leading = canonicalizeCodexOriginator(leading)
  192. return leading, leading + ua[slash:], true
  193. }
  194. // 传原始大小写 UA 提取 trailer,保留 `Codex ` 家族身份的真实大小写;含 '/' 的
  195. // trailer 会破坏重写后 UA 首段与 originator 的一致性,直接拒绝。
  196. if trailer := codexUATrailerName(ua); trailer != "" && !strings.ContainsRune(trailer, '/') &&
  197. isSaneCodexOriginator(trailer) && IsCodexOfficialClientOriginator(trailer) {
  198. trailer = canonicalizeCodexOriginator(trailer)
  199. return trailer, trailer + ua[slash:], true
  200. }
  201. return "", "", false
  202. }
  203. // codexOriginatorMaxLen 官方 clientInfo.name 均为短 ASCII 标识,远低于此上限。
  204. const codexOriginatorMaxLen = 64
  205. // isSaneCodexOriginator 拒绝超长或含不可打印/非 ASCII 字节的候选 originator,
  206. // 避免 `Codex ` 家族宽前缀把客户端可控的任意字节当作官方身份逐字转发给上游。
  207. func isSaneCodexOriginator(name string) bool {
  208. if name == "" || len(name) > codexOriginatorMaxLen {
  209. return false
  210. }
  211. for i := 0; i < len(name); i++ {
  212. if c := name[i]; c < 0x20 || c > 0x7e {
  213. return false
  214. }
  215. }
  216. return true
  217. }
  218. // canonicalizeCodexOriginator 把精确集合的官方 originator 大小写变体归一为规范小写形态
  219. // (如 CODEX_CLI_RS → codex_cli_rs);`Codex ` 家族不在精确集合中,保留原大小写
  220. // (其规范形态本就是混合大小写,上游按大小写敏感 starts_with("Codex ") 判定)。
  221. func canonicalizeCodexOriginator(name string) string {
  222. if lower := normalizeCodexClientHeader(name); codexOfficialClientOriginators[lower] {
  223. return lower
  224. }
  225. return name
  226. }
  227. // CodexCLIOriginator 是 codex-rs 客户端的历史默认 originator,保留用于兼容识别。
  228. const CodexCLIOriginator = "codex_cli_rs"
  229. // CodexDefaultOriginator 是网关默认使用的 Codex TUI originator。
  230. const CodexDefaultOriginator = "codex-tui"
  231. // CodexUserAgentVersion 提取 Codex UA 的完整版本段,即 `{client}/{version} (...` 中的 version。
  232. // 与 ParseCodexEngineVersion 的区别:后者只取三段数字用于引擎版本比较(会丢掉 -alpha.4
  233. // 之类的预发布后缀),本函数保留原样,因为出站 version 头必须与 UA 版本段逐字一致。
  234. // 取不到(非 Codex 形态 UA)时返回空串。
  235. func CodexUserAgentVersion(userAgent string) string {
  236. ua := strings.TrimSpace(userAgent)
  237. slash := strings.IndexByte(ua, '/')
  238. if slash <= 0 {
  239. return ""
  240. }
  241. rest := ua[slash+1:]
  242. if space := strings.IndexByte(rest, ' '); space >= 0 {
  243. rest = rest[:space]
  244. }
  245. return strings.TrimSpace(rest)
  246. }
  247. // SetCodexUserAgentVersion 用 version 重建 Codex 形态 UA 中的版本声明,其余部分
  248. // (客户端名、OS / 架构 / 终端指纹)原样保留;UA 不是 `{client}/{version}` 形态时返回空串,
  249. // 由调用方决定整体回退。
  250. //
  251. // 尾部官方客户端标识组 `(name; version)` 与首段是同一个版本声明的两个出口
  252. // (CODEX_INTERNAL_ORIGINATOR_OVERRIDE 场景,如 `cccc/0.142.0 ... (codex-tui; 0.142.0)`),
  253. // 必须一并更新,否则会拼出首段声明新版本、尾部仍是旧版本的自相矛盾身份。
  254. // 仅在括号组确为官方客户端标识时才改写,避免误伤 OS 组(如 `(Ubuntu 22.4.0; x86_64)`)。
  255. func SetCodexUserAgentVersion(userAgent, version string) string {
  256. ua := strings.TrimSpace(userAgent)
  257. version = strings.TrimSpace(version)
  258. if version == "" {
  259. return ""
  260. }
  261. slash := strings.IndexByte(ua, '/')
  262. if slash <= 0 {
  263. return ""
  264. }
  265. client := strings.TrimSpace(ua[:slash])
  266. if client == "" {
  267. return ""
  268. }
  269. rest := ua[slash+1:]
  270. tail := ""
  271. if space := strings.IndexByte(rest, ' '); space >= 0 {
  272. tail = rest[space:]
  273. } else if strings.TrimSpace(rest) == "" {
  274. // `client/` 没有版本段,不是可重建的 Codex 形态。
  275. return ""
  276. }
  277. return rewriteCodexUATrailerVersion(client+"/"+version+tail, version)
  278. }
  279. // rewriteCodexUATrailerVersion 把尾部官方客户端标识组 `(name; version)` 的版本改成 version。
  280. // 括号组缺少 `;` 分隔的版本、或 name 不是官方 originator 时原样返回。
  281. func rewriteCodexUATrailerVersion(ua, version string) string {
  282. open := strings.LastIndex(ua, "(")
  283. if open < 0 {
  284. return ua
  285. }
  286. closeIdx := strings.Index(ua[open+1:], ")")
  287. if closeIdx < 0 {
  288. return ua
  289. }
  290. inner := ua[open+1 : open+1+closeIdx]
  291. semi := strings.Index(inner, ";")
  292. if semi < 0 {
  293. return ua
  294. }
  295. name := strings.TrimSpace(inner[:semi])
  296. if name == "" || !IsCodexOfficialClientOriginator(name) {
  297. return ua
  298. }
  299. return ua[:open+1] + name + "; " + version + ua[open+1+closeIdx:]
  300. }
  301. // codexEngineVersionPattern 提取版本段开头的三段数字 X.Y.Z(忽略 -alpha 等后缀)。
  302. var codexEngineVersionPattern = regexp.MustCompile(`^(\d+\.\d+\.\d+)`)
  303. // ParseCodexEngineVersion 从 codex-rs 形态 UA 取引擎版本:
  304. // `{originator}/{X.Y.Z} (...)`,第一个 '/' 后、首个空格或 '(' 前的三段版本。
  305. // 该版本是 codex-rs CARGO_PKG_VERSION(引擎版本,CLI/app-server 一致)。
  306. func ParseCodexEngineVersion(ua string) (string, bool) {
  307. ua = strings.TrimSpace(ua)
  308. slash := strings.IndexByte(ua, '/')
  309. if slash < 0 {
  310. return "", false
  311. }
  312. rest := ua[slash+1:]
  313. end := len(rest)
  314. for i := 0; i < len(rest); i++ {
  315. if rest[i] == ' ' || rest[i] == '(' {
  316. end = i
  317. break
  318. }
  319. }
  320. m := codexEngineVersionPattern.FindString(strings.TrimSpace(rest[:end]))
  321. if m == "" {
  322. return "", false
  323. }
  324. return m, true
  325. }