openai_compact_probe.go 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. package service
  2. import (
  3. "net/http"
  4. "strconv"
  5. "strings"
  6. "time"
  7. )
  8. const (
  9. // AccountTestModeDefault drives the standard /responses connection test.
  10. AccountTestModeDefault = "default"
  11. // AccountTestModeCompact drives the remote-compaction probe test
  12. // (native v2: streaming /responses with a compaction_trigger input item).
  13. AccountTestModeCompact = "compact"
  14. )
  15. func normalizeAccountTestMode(mode string) string {
  16. switch strings.ToLower(strings.TrimSpace(mode)) {
  17. case AccountTestModeCompact:
  18. return AccountTestModeCompact
  19. default:
  20. return AccountTestModeDefault
  21. }
  22. }
  23. // createOpenAICompactProbePayload 构造原生 remote compaction v2 探测载荷:
  24. // 流式 /responses + input 末尾 {"type":"compaction_trigger"}。上游已下线
  25. // legacy unary /responses/compact(v1 形态恒 404,#5598/#5624),现行 codex
  26. // 默认协议即 v2(RemoteCompactionV2 Stable + default_enabled)。
  27. func createOpenAICompactProbePayload(model string, isOAuth bool) map[string]any {
  28. payload := map[string]any{
  29. "model": strings.TrimSpace(model),
  30. "instructions": "You are a helpful coding assistant.",
  31. "input": []any{
  32. map[string]any{
  33. "type": "message",
  34. "role": "user",
  35. "content": "Respond with OK.",
  36. },
  37. map[string]any{"type": "compaction_trigger"},
  38. },
  39. "stream": true,
  40. }
  41. // ChatGPT internal API 要求 store: false,与真实转发一致。
  42. if isOAuth {
  43. payload["store"] = false
  44. }
  45. return payload
  46. }
  47. // openAICompactProbeFoundCompactionItem 判定探测响应是否产出了 compaction
  48. // 输出 item——v2 契约的核心(codex 缺它即 fatal "got 0 items")。三种形态都
  49. // 认:① SSE 的 output_item.done/added(原生 v2 主形态,codex 只从这里收集
  50. // item);② SSE 终态 response.completed 的 response.output[](部分上游只在
  51. // 终态给出 item);③ 整体 JSON 的 output[](老网关链把请求降级成 unary)。
  52. func openAICompactProbeFoundCompactionItem(body []byte) bool {
  53. if len(body) == 0 {
  54. return false
  55. }
  56. bodyText := string(body)
  57. if _, found := findRawCompactionItemFromSSE(bodyText); found {
  58. return true
  59. }
  60. if finalResponse, ok := extractCodexFinalResponse(bodyText); ok &&
  61. responsesOutputHasCompactionItem(finalResponse) {
  62. return true
  63. }
  64. return responsesOutputHasCompactionItem(body)
  65. }
  66. func shouldMarkOpenAICompactUnsupported(status int, body []byte) bool {
  67. switch status {
  68. case http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotImplemented:
  69. return true
  70. case http.StatusBadRequest, http.StatusForbidden, http.StatusUnprocessableEntity:
  71. lower := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(body) + " " + string(body)))
  72. if strings.Contains(lower, "compact") {
  73. for _, keyword := range []string{
  74. "unsupported",
  75. "not support",
  76. "does not support",
  77. "not available",
  78. "disabled",
  79. } {
  80. if strings.Contains(lower, keyword) {
  81. return true
  82. }
  83. }
  84. }
  85. }
  86. return false
  87. }
  88. // buildOpenAICompactProbeExtraUpdates 计算探测结果的账号 extra 更新。
  89. // compactionFound 是 v2 契约判据:HTTP 2xx 但响应无 compaction item 时同样
  90. // 记为不支持(链路把 compaction_trigger 吞掉的形态,等价 codex 的 "got 0
  91. // items" fatal,#5478/#5648)。极端场景(上游链只支持 legacy unary compact)
  92. // 可用账号级 openai_compact_mode=force_on 人工覆盖。
  93. func buildOpenAICompactProbeExtraUpdates(resp *http.Response, body []byte, probeErr error, compactionFound bool, now time.Time) map[string]any {
  94. updates := map[string]any{
  95. "openai_compact_checked_at": now.Format(time.RFC3339),
  96. "openai_compact_last_status": nil,
  97. }
  98. if resp != nil {
  99. updates["openai_compact_last_status"] = resp.StatusCode
  100. }
  101. switch {
  102. case probeErr != nil:
  103. updates["openai_compact_last_error"] = truncateString(sanitizeUpstreamErrorMessage(probeErr.Error()), 2048)
  104. case resp == nil:
  105. updates["openai_compact_last_error"] = "compact probe failed"
  106. default:
  107. errMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
  108. if errMsg == "" && len(body) > 0 {
  109. errMsg = strings.TrimSpace(string(body))
  110. }
  111. if errMsg == "" && (resp.StatusCode < 200 || resp.StatusCode >= 300) {
  112. errMsg = "HTTP " + strconv.Itoa(resp.StatusCode)
  113. }
  114. errMsg = truncateString(sanitizeUpstreamErrorMessage(errMsg), 2048)
  115. switch {
  116. case resp.StatusCode >= 200 && resp.StatusCode < 300 && compactionFound:
  117. updates["openai_compact_supported"] = true
  118. updates["openai_compact_last_error"] = ""
  119. case resp.StatusCode >= 200 && resp.StatusCode < 300:
  120. updates["openai_compact_supported"] = false
  121. updates["openai_compact_last_error"] = "upstream returned 2xx without a compaction output item (native remote compaction v2 unsupported)"
  122. default:
  123. if shouldMarkOpenAICompactUnsupported(resp.StatusCode, body) {
  124. updates["openai_compact_supported"] = false
  125. }
  126. updates["openai_compact_last_error"] = errMsg
  127. }
  128. }
  129. return updates
  130. }
  131. func mergeExtraUpdates(base map[string]any, more map[string]any) map[string]any {
  132. if len(base) == 0 && len(more) == 0 {
  133. return nil
  134. }
  135. out := make(map[string]any, len(base)+len(more))
  136. for key, value := range base {
  137. out[key] = value
  138. }
  139. for key, value := range more {
  140. out[key] = value
  141. }
  142. return out
  143. }
  144. // compactProbeSessionID 返回探测请求使用的会话标识。真实 Codex 的
  145. // session-id / thread-id 恒为 UUID(codex-protocol ThreadId 是 UUIDv7),
  146. // 探测既然与真实流量走同一个 /responses 端点,标识形态就必须同构——
  147. // 否则上游能凭 "probe_compact_5" 这类字面量一眼区分出探测流量。
  148. // 账号级稳定派生:重复探测复用同一会话,而不是每次新开一个。
  149. func compactProbeSessionID(accountID int64) string {
  150. if accountID <= 0 {
  151. return deriveStableUUIDv4("sub2api:codex-compact-probe:v1:anonymous")
  152. }
  153. return deriveStableUUIDv4("sub2api:codex-compact-probe:v1:" + strconv.FormatInt(accountID, 10))
  154. }