account_test_service_openai_compact_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. package service
  2. import (
  3. "bytes"
  4. "io"
  5. "net/http"
  6. "net/http/httptest"
  7. "strings"
  8. "testing"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/gin-gonic/gin"
  11. "github.com/google/uuid"
  12. "github.com/stretchr/testify/require"
  13. "github.com/tidwall/gjson"
  14. )
  15. // compactProbeSSESuccessBody 是原生 v2 压缩成功的最小 SSE 形态:
  16. // output_item.done 携带 compaction item + response.completed。
  17. const compactProbeSSESuccessBody = "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"compaction\",\"id\":\"cmp_probe\",\"encrypted_content\":\"blob\"}}\n\n" +
  18. "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_probe\",\"output\":[]}}\n\n"
  19. func TestAccountTestService_TestAccountConnection_OpenAICompactOAuthSuccessPersistsSupport(t *testing.T) {
  20. gin.SetMode(gin.TestMode)
  21. updateCalls := make(chan map[string]any, 1)
  22. account := Account{
  23. ID: 1,
  24. Name: "openai-oauth",
  25. Platform: PlatformOpenAI,
  26. Type: AccountTypeOAuth,
  27. Status: StatusActive,
  28. Schedulable: true,
  29. Concurrency: 1,
  30. Credentials: map[string]any{
  31. "access_token": "oauth-token",
  32. "chatgpt_account_id": "chatgpt-acc",
  33. "chatgpt_account_is_fedramp": true,
  34. },
  35. }
  36. repo := &snapshotUpdateAccountRepo{
  37. stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
  38. updateExtraCalls: updateCalls,
  39. }
  40. upstream := &httpUpstreamRecorder{resp: &http.Response{
  41. StatusCode: http.StatusOK,
  42. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid-probe"}},
  43. Body: io.NopCloser(strings.NewReader(compactProbeSSESuccessBody)),
  44. }}
  45. svc := &AccountTestService{
  46. accountRepo: repo,
  47. httpUpstream: upstream,
  48. }
  49. rec := httptest.NewRecorder()
  50. c, _ := gin.CreateTestContext(rec)
  51. c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/1/test", bytes.NewReader(nil))
  52. err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact)
  53. require.NoError(t, err)
  54. // 原生 v2:探测普通 /responses 线,不再打已下线的 /responses/compact。
  55. require.Equal(t, chatgptCodexAPIURL, upstream.lastReq.URL.String())
  56. require.Equal(t, "chatgpt.com", upstream.lastReq.Host)
  57. require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept"))
  58. require.Contains(t, upstream.lastReq.Header.Get("x-codex-beta-features"), "remote_compaction_v2")
  59. require.NotEmpty(t, upstream.lastReq.Header.Get("Session_Id"))
  60. require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context()))
  61. require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent"))
  62. require.Equal(t, "chatgpt-acc", upstream.lastReq.Header.Get("chatgpt-account-id"))
  63. require.Equal(t, "true", upstream.lastReq.Header.Get("x-openai-fedramp"))
  64. require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String())
  65. require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool())
  66. require.False(t, gjson.GetBytes(upstream.lastBody, "store").Bool())
  67. inputItems := gjson.GetBytes(upstream.lastBody, "input").Array()
  68. require.NotEmpty(t, inputItems)
  69. require.Equal(t, "compaction_trigger", inputItems[len(inputItems)-1].Get("type").String())
  70. updates := <-updateCalls
  71. require.Equal(t, true, updates["openai_compact_supported"])
  72. require.Equal(t, http.StatusOK, updates["openai_compact_last_status"])
  73. require.Contains(t, rec.Body.String(), `"type":"test_complete"`)
  74. }
  75. func TestAccountTestService_TestAccountConnection_OpenAICompactOAuth404MarksUnsupported(t *testing.T) {
  76. gin.SetMode(gin.TestMode)
  77. updateCalls := make(chan map[string]any, 1)
  78. account := Account{
  79. ID: 2,
  80. Name: "openai-oauth",
  81. Platform: PlatformOpenAI,
  82. Type: AccountTypeOAuth,
  83. Status: StatusActive,
  84. Schedulable: true,
  85. Concurrency: 1,
  86. Credentials: map[string]any{
  87. "access_token": "oauth-token",
  88. "chatgpt_account_id": "chatgpt-acc",
  89. },
  90. }
  91. repo := &snapshotUpdateAccountRepo{
  92. stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
  93. updateExtraCalls: updateCalls,
  94. }
  95. upstream := &httpUpstreamRecorder{resp: &http.Response{
  96. StatusCode: http.StatusNotFound,
  97. Header: http.Header{"Content-Type": []string{"application/json"}},
  98. Body: io.NopCloser(strings.NewReader(`404 page not found`)),
  99. }}
  100. svc := &AccountTestService{
  101. accountRepo: repo,
  102. httpUpstream: upstream,
  103. }
  104. rec := httptest.NewRecorder()
  105. c, _ := gin.CreateTestContext(rec)
  106. c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/2/test", bytes.NewReader(nil))
  107. err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact)
  108. require.Error(t, err)
  109. updates := <-updateCalls
  110. require.Equal(t, false, updates["openai_compact_supported"])
  111. require.Equal(t, http.StatusNotFound, updates["openai_compact_last_status"])
  112. require.Contains(t, rec.Body.String(), `"type":"error"`)
  113. }
  114. func TestAccountTestService_TestAccountConnection_OpenAICompactAPIKeyUsesNativeResponsesPath(t *testing.T) {
  115. gin.SetMode(gin.TestMode)
  116. updateCalls := make(chan map[string]any, 1)
  117. account := Account{
  118. ID: 3,
  119. Name: "openai-apikey",
  120. Platform: PlatformOpenAI,
  121. Type: AccountTypeAPIKey,
  122. Status: StatusActive,
  123. Schedulable: true,
  124. Concurrency: 1,
  125. Credentials: map[string]any{
  126. "api_key": "sk-test",
  127. "base_url": "https://example.com/v1",
  128. // post-#5641:compact_model_mapping 仅作用于 legacy /responses/compact,
  129. // 原生 v2 探测不应用它。
  130. "compact_model_mapping": map[string]any{"gpt-5.4": "gpt-5.4-openai-compact"},
  131. },
  132. }
  133. repo := &snapshotUpdateAccountRepo{
  134. stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
  135. updateExtraCalls: updateCalls,
  136. }
  137. upstream := &httpUpstreamRecorder{resp: &http.Response{
  138. StatusCode: http.StatusOK,
  139. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  140. Body: io.NopCloser(strings.NewReader(compactProbeSSESuccessBody)),
  141. }}
  142. svc := &AccountTestService{
  143. accountRepo: repo,
  144. httpUpstream: upstream,
  145. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  146. }
  147. rec := httptest.NewRecorder()
  148. c, _ := gin.CreateTestContext(rec)
  149. c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/3/test", bytes.NewReader(nil))
  150. err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact)
  151. require.NoError(t, err)
  152. require.Equal(t, "https://example.com/v1/responses", upstream.lastReq.URL.String())
  153. requireOpenAICodexProbeHeaders(t, upstream.lastReq.Header)
  154. require.Contains(t, upstream.lastReq.Header.Get("x-codex-beta-features"), "remote_compaction_v2")
  155. require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String(),
  156. "原生 v2 探测不应用 compact_model_mapping")
  157. updates := <-updateCalls
  158. require.Equal(t, true, updates["openai_compact_supported"])
  159. }
  160. func TestAccountTestService_TestAccountConnection_OpenAICompactAPIKeyDefaultBaseURLUsesResponsesPath(t *testing.T) {
  161. gin.SetMode(gin.TestMode)
  162. updateCalls := make(chan map[string]any, 1)
  163. account := Account{
  164. ID: 4,
  165. Name: "openai-apikey-default",
  166. Platform: PlatformOpenAI,
  167. Type: AccountTypeAPIKey,
  168. Status: StatusActive,
  169. Schedulable: true,
  170. Concurrency: 1,
  171. Credentials: map[string]any{
  172. "api_key": "sk-test",
  173. },
  174. }
  175. repo := &snapshotUpdateAccountRepo{
  176. stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
  177. updateExtraCalls: updateCalls,
  178. }
  179. upstream := &httpUpstreamRecorder{resp: &http.Response{
  180. StatusCode: http.StatusOK,
  181. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  182. Body: io.NopCloser(strings.NewReader(compactProbeSSESuccessBody)),
  183. }}
  184. svc := &AccountTestService{
  185. accountRepo: repo,
  186. httpUpstream: upstream,
  187. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  188. }
  189. rec := httptest.NewRecorder()
  190. c, _ := gin.CreateTestContext(rec)
  191. c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/4/test", bytes.NewReader(nil))
  192. err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact)
  193. require.NoError(t, err)
  194. require.Equal(t, "https://api.openai.com/v1/responses", upstream.lastReq.URL.String())
  195. <-updateCalls
  196. }
  197. func TestAccountTestService_TestAccountConnection_OpenAICompact2xxWithoutItemMarksUnsupported(t *testing.T) {
  198. gin.SetMode(gin.TestMode)
  199. updateCalls := make(chan map[string]any, 1)
  200. account := Account{
  201. ID: 5,
  202. Name: "openai-oauth-no-item",
  203. Platform: PlatformOpenAI,
  204. Type: AccountTypeOAuth,
  205. Status: StatusActive,
  206. Schedulable: true,
  207. Concurrency: 1,
  208. Credentials: map[string]any{
  209. "access_token": "oauth-token",
  210. "chatgpt_account_id": "chatgpt-acc",
  211. },
  212. }
  213. repo := &snapshotUpdateAccountRepo{
  214. stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
  215. updateExtraCalls: updateCalls,
  216. }
  217. // 200 但流里没有 compaction item:链路吞掉了 compaction_trigger 的形态
  218. //(#5478 的 "got 0 items"),必须判定为不支持。
  219. noItemBody := "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_1\"}}\n\n" +
  220. "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_x\",\"output\":[]}}\n\n"
  221. upstream := &httpUpstreamRecorder{resp: &http.Response{
  222. StatusCode: http.StatusOK,
  223. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  224. Body: io.NopCloser(strings.NewReader(noItemBody)),
  225. }}
  226. svc := &AccountTestService{
  227. accountRepo: repo,
  228. httpUpstream: upstream,
  229. }
  230. rec := httptest.NewRecorder()
  231. c, _ := gin.CreateTestContext(rec)
  232. c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/5/test", bytes.NewReader(nil))
  233. err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact)
  234. require.Error(t, err)
  235. updates := <-updateCalls
  236. require.Equal(t, false, updates["openai_compact_supported"])
  237. require.Contains(t, rec.Body.String(), `"type":"error"`)
  238. }
  239. // 探测与真实转发走同一 /responses 端点,出站身份必须与真实 Codex 同构:
  240. // session/thread 为 UUID、携带 x-codex-installation-id(收敛账号用收敛值)。
  241. func TestAccountTestService_TestAccountConnection_OpenAICompactProbeIdentityMatchesRealTraffic(t *testing.T) {
  242. gin.SetMode(gin.TestMode)
  243. updateCalls := make(chan map[string]any, 1)
  244. account := Account{
  245. ID: 6,
  246. Name: "openai-oauth-identity",
  247. Platform: PlatformOpenAI,
  248. Type: AccountTypeOAuth,
  249. Status: StatusActive,
  250. Schedulable: true,
  251. Concurrency: 1,
  252. Credentials: map[string]any{
  253. "access_token": "oauth-token",
  254. "chatgpt_account_id": "chatgpt-acc",
  255. },
  256. // 收敛是显式 opt-in(#5610),这里显式开启以验证探测身份与真实流量同构。
  257. Extra: map[string]any{
  258. "codex_fingerprint_mode": "session",
  259. codexFingerprintSeedExtraKey: testCodexFingerprintSeed,
  260. },
  261. }
  262. repo := &snapshotUpdateAccountRepo{
  263. stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
  264. updateExtraCalls: updateCalls,
  265. }
  266. upstream := &httpUpstreamRecorder{resp: &http.Response{
  267. StatusCode: http.StatusOK,
  268. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  269. Body: io.NopCloser(strings.NewReader(compactProbeSSESuccessBody)),
  270. }}
  271. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
  272. rec := httptest.NewRecorder()
  273. c, _ := gin.CreateTestContext(rec)
  274. c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/6/test", bytes.NewReader(nil))
  275. require.NoError(t, svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact))
  276. // 显式 session 收敛模式:出站身份 = 账号级收敛值
  277. seed, ok := codexFingerprintSeed(account.Extra)
  278. require.True(t, ok)
  279. converged := resolveConvergedSessionID(seed)
  280. require.Equal(t, converged, upstream.lastReq.Header.Get("session-id"))
  281. require.Equal(t, converged, upstream.lastReq.Header.Get("session_id"))
  282. require.Equal(t, resolveConvergedInstallationID(&account, seed), upstream.lastReq.Header.Get("x-codex-installation-id"),
  283. "真实 Codex 每个请求必带 installation-id,探测不得缺失")
  284. require.NotContains(t, upstream.lastReq.Header.Get("session-id"), "probe_compact",
  285. "探测标识不得是可被上游一眼识别的字面量")
  286. <-updateCalls
  287. }
  288. func TestCompactProbeSessionID_IsUUIDShaped(t *testing.T) {
  289. for _, id := range []int64{0, 1, 987654} {
  290. got := compactProbeSessionID(id)
  291. _, err := uuid.Parse(got)
  292. require.NoError(t, err, "探测会话标识必须是 UUID 形态: %s", got)
  293. }
  294. require.Equal(t, compactProbeSessionID(7), compactProbeSessionID(7), "同账号应稳定复用同一会话")
  295. require.NotEqual(t, compactProbeSessionID(7), compactProbeSessionID(8))
  296. }