openai_codex_turn_state_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. package service
  2. import (
  3. "context"
  4. "net/http"
  5. "net/http/httptest"
  6. "testing"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/config"
  9. "github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
  10. "github.com/gin-gonic/gin"
  11. "github.com/stretchr/testify/require"
  12. )
  13. func newTurnStateTestContext(t *testing.T, apiKeyID int64, sessionID string) (*gin.Context, *httptest.ResponseRecorder) {
  14. t.Helper()
  15. gin.SetMode(gin.TestMode)
  16. rec := httptest.NewRecorder()
  17. c, _ := gin.CreateTestContext(rec)
  18. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  19. if sessionID != "" {
  20. c.Request.Header.Set("session_id", sessionID)
  21. }
  22. if apiKeyID > 0 {
  23. c.Set("api_key", &APIKey{ID: apiKeyID})
  24. }
  25. return c, rec
  26. }
  27. func TestOpenAICodexTurnStateSeed(t *testing.T) {
  28. c, _ := newTurnStateTestContext(t, 7, "sess-1")
  29. require.Equal(t, "7\x00sess-1", openAICodexTurnStateSeed(c))
  30. // 连字符形式优先(Codex CLI 标准头)
  31. c.Request.Header.Set("session-id", "sess-hyphen")
  32. require.Equal(t, "7\x00sess-hyphen", openAICodexTurnStateSeed(c))
  33. // 无会话标识 → 不跟踪
  34. cNoSession, _ := newTurnStateTestContext(t, 7, "")
  35. require.Empty(t, openAICodexTurnStateSeed(cNoSession))
  36. require.Empty(t, openAICodexTurnStateSeed(nil))
  37. }
  38. func TestRelayOpenAICodexTurnState_SetsHeaderAndRecordsProvenance(t *testing.T) {
  39. svc := &OpenAIGatewayService{}
  40. account := &Account{ID: 42}
  41. c, _ := newTurnStateTestContext(t, 7, "sess-relay")
  42. upstream := http.Header{}
  43. upstream.Set("x-codex-turn-state", "blob-A")
  44. svc.relayOpenAICodexTurnState(c, account, upstream)
  45. require.Equal(t, "blob-A", c.Writer.Header().Get("X-Codex-Turn-State"))
  46. raw, ok := svc.openaiCodexTurnStateOrigins.Load("7\x00sess-relay")
  47. require.True(t, ok)
  48. origin, ok := raw.(openAICodexTurnStateOrigin)
  49. require.True(t, ok)
  50. require.Equal(t, int64(42), origin.accountID)
  51. require.True(t, origin.expiresAt.After(time.Now()))
  52. }
  53. func TestRelayOpenAICodexTurnState_ClearsStaleValueWhenUpstreamAbsent(t *testing.T) {
  54. svc := &OpenAIGatewayService{}
  55. c, _ := newTurnStateTestContext(t, 7, "sess-stale")
  56. // 模拟上一 failover attempt 残留的值
  57. c.Writer.Header().Set("X-Codex-Turn-State", "blob-old")
  58. svc.relayOpenAICodexTurnState(c, &Account{ID: 43}, http.Header{})
  59. require.Empty(t, c.Writer.Header().Get("X-Codex-Turn-State"))
  60. _, ok := svc.openaiCodexTurnStateOrigins.Load("7\x00sess-stale")
  61. require.False(t, ok)
  62. }
  63. func TestStageOpenAICodexTurnState_StagedHeaders(t *testing.T) {
  64. svc := &OpenAIGatewayService{}
  65. c, _ := newTurnStateTestContext(t, 9, "sess-staged")
  66. // nil 集合 + 上游有值 → 创建集合并写入,但此刻还不记录溯源
  67. var staged http.Header
  68. upstream := http.Header{}
  69. upstream.Set("x-codex-turn-state", "blob-B")
  70. stageOpenAICodexTurnState(&staged, upstream)
  71. require.NotNil(t, staged)
  72. require.Equal(t, "blob-B", staged.Get("X-Codex-Turn-State"))
  73. _, noted := svc.openaiCodexTurnStateOrigins.Load("9\x00sess-staged")
  74. require.False(t, noted, "暂存阶段不得记录溯源:该 attempt 仍可能 failover 丢弃")
  75. // 真正提交时才记录
  76. svc.noteStagedOpenAICodexTurnStateCommitted(c, &Account{ID: 44}, staged)
  77. raw, ok := svc.openaiCodexTurnStateOrigins.Load("9\x00sess-staged")
  78. require.True(t, ok)
  79. origin, ok := raw.(openAICodexTurnStateOrigin)
  80. require.True(t, ok)
  81. require.Equal(t, int64(44), origin.accountID)
  82. // 上游无值 → 清除已暂存的值;nil 集合保持 nil
  83. stageOpenAICodexTurnState(&staged, http.Header{})
  84. require.Empty(t, staged.Get("X-Codex-Turn-State"))
  85. var nilStaged http.Header
  86. stageOpenAICodexTurnState(&nilStaged, http.Header{})
  87. require.Nil(t, nilStaged)
  88. }
  89. // 首输出超时导致 attempt 被丢弃时,溯源不得被该 attempt 污染——否则后续
  90. // 请求会把客户端持有的合法 blob 误判成跨账号回带而剥离。
  91. func TestStagedTurnState_AbandonedAttemptDoesNotPoisonProvenance(t *testing.T) {
  92. svc := &OpenAIGatewayService{}
  93. c, _ := newTurnStateTestContext(t, 11, "sess-abandoned")
  94. // 账号 A 的 attempt 暂存了 blob,但从未提交(首输出超时 → failover)
  95. var staged http.Header
  96. upstreamA := http.Header{}
  97. upstreamA.Set("x-codex-turn-state", "blob-A")
  98. stageOpenAICodexTurnState(&staged, upstreamA)
  99. // 账号 B 接手并真正提交
  100. svc.relayOpenAICodexTurnState(c, &Account{ID: 52}, upstreamA)
  101. // 客户端回带的 blob 来自 B,出站到 B 时不得被剥离
  102. h := http.Header{}
  103. h.Set("x-codex-turn-state", "blob-A")
  104. svc.guardOpenAICodexTurnStateEcho(c, &Account{ID: 52}, h)
  105. require.Equal(t, "blob-A", h.Get("x-codex-turn-state"))
  106. raw, ok := svc.openaiCodexTurnStateOrigins.Load("11\x00sess-abandoned")
  107. require.True(t, ok)
  108. origin, ok := raw.(openAICodexTurnStateOrigin)
  109. require.True(t, ok)
  110. require.Equal(t, int64(52), origin.accountID)
  111. }
  112. func TestNoteStagedOpenAICodexTurnStateCommitted_NoopWithoutState(t *testing.T) {
  113. svc := &OpenAIGatewayService{}
  114. c, _ := newTurnStateTestContext(t, 12, "sess-nostate")
  115. svc.noteStagedOpenAICodexTurnStateCommitted(c, &Account{ID: 60}, nil)
  116. svc.noteStagedOpenAICodexTurnStateCommitted(c, &Account{ID: 60}, http.Header{"X-Request-Id": []string{"rid"}})
  117. _, ok := svc.openaiCodexTurnStateOrigins.Load("12\x00sess-nostate")
  118. require.False(t, ok)
  119. }
  120. func TestGuardOpenAICodexTurnStateEcho(t *testing.T) {
  121. newOutbound := func(state string) http.Header {
  122. h := http.Header{}
  123. if state != "" {
  124. h.Set("x-codex-turn-state", state)
  125. }
  126. return h
  127. }
  128. t.Run("same_account_keeps_echo", func(t *testing.T) {
  129. svc := &OpenAIGatewayService{}
  130. c, _ := newTurnStateTestContext(t, 7, "sess-g1")
  131. upstream := http.Header{}
  132. upstream.Set("x-codex-turn-state", "blob-A")
  133. svc.relayOpenAICodexTurnState(c, &Account{ID: 42}, upstream)
  134. h := newOutbound("blob-A")
  135. svc.guardOpenAICodexTurnStateEcho(c, &Account{ID: 42}, h)
  136. require.Equal(t, "blob-A", h.Get("x-codex-turn-state"))
  137. })
  138. t.Run("foreign_account_strips_echo", func(t *testing.T) {
  139. svc := &OpenAIGatewayService{}
  140. c, _ := newTurnStateTestContext(t, 7, "sess-g2")
  141. upstream := http.Header{}
  142. upstream.Set("x-codex-turn-state", "blob-A")
  143. svc.relayOpenAICodexTurnState(c, &Account{ID: 42}, upstream)
  144. // failover 换到账号 43:blob 由 42 铸造,必须剥离
  145. h := newOutbound("blob-A")
  146. svc.guardOpenAICodexTurnStateEcho(c, &Account{ID: 43}, h)
  147. require.Empty(t, h.Get("x-codex-turn-state"))
  148. })
  149. t.Run("no_provenance_passthrough", func(t *testing.T) {
  150. svc := &OpenAIGatewayService{}
  151. c, _ := newTurnStateTestContext(t, 7, "sess-g3")
  152. h := newOutbound("blob-unknown")
  153. svc.guardOpenAICodexTurnStateEcho(c, &Account{ID: 43}, h)
  154. require.Equal(t, "blob-unknown", h.Get("x-codex-turn-state"))
  155. })
  156. t.Run("expired_provenance_passthrough_and_pruned", func(t *testing.T) {
  157. svc := &OpenAIGatewayService{}
  158. c, _ := newTurnStateTestContext(t, 7, "sess-g4")
  159. svc.openaiCodexTurnStateOrigins.Store("7\x00sess-g4", openAICodexTurnStateOrigin{
  160. accountID: 42,
  161. expiresAt: time.Now().Add(-time.Minute),
  162. })
  163. h := newOutbound("blob-A")
  164. svc.guardOpenAICodexTurnStateEcho(c, &Account{ID: 43}, h)
  165. require.Equal(t, "blob-A", h.Get("x-codex-turn-state"))
  166. _, ok := svc.openaiCodexTurnStateOrigins.Load("7\x00sess-g4")
  167. require.False(t, ok)
  168. })
  169. t.Run("no_session_seed_noop", func(t *testing.T) {
  170. svc := &OpenAIGatewayService{}
  171. c, _ := newTurnStateTestContext(t, 7, "")
  172. h := newOutbound("blob-A")
  173. svc.guardOpenAICodexTurnStateEcho(c, &Account{ID: 43}, h)
  174. require.Equal(t, "blob-A", h.Get("x-codex-turn-state"))
  175. })
  176. t.Run("no_echo_noop", func(t *testing.T) {
  177. svc := &OpenAIGatewayService{}
  178. c, _ := newTurnStateTestContext(t, 7, "sess-g5")
  179. h := newOutbound("")
  180. svc.guardOpenAICodexTurnStateEcho(c, &Account{ID: 43}, h)
  181. require.Empty(t, h.Get("x-codex-turn-state"))
  182. })
  183. }
  184. func TestSweepOpenAICodexTurnStateOrigins_PrunesExpiredEntries(t *testing.T) {
  185. svc := &OpenAIGatewayService{}
  186. svc.openaiCodexTurnStateOrigins.Store("expired", openAICodexTurnStateOrigin{
  187. accountID: 1,
  188. expiresAt: time.Now().Add(-time.Minute),
  189. })
  190. svc.openaiCodexTurnStateOrigins.Store("alive", openAICodexTurnStateOrigin{
  191. accountID: 2,
  192. expiresAt: time.Now().Add(time.Hour),
  193. })
  194. // 计数器推进到触发清扫的边界
  195. svc.openaiCodexTurnStateWrites.Store(255)
  196. svc.sweepOpenAICodexTurnStateOrigins()
  197. _, expiredOK := svc.openaiCodexTurnStateOrigins.Load("expired")
  198. require.False(t, expiredOK)
  199. _, aliveOK := svc.openaiCodexTurnStateOrigins.Load("alive")
  200. require.True(t, aliveOK)
  201. }
  202. func TestWriteOpenAIPassthroughResponseHeaders_RelaysAndClearsTurnState(t *testing.T) {
  203. // filter=nil 走 content-type 兜底分支;turn-state 强制放行不依赖 filter。
  204. dst := http.Header{}
  205. src := http.Header{}
  206. src.Set("X-Codex-Turn-State", "blob-P")
  207. writeOpenAIPassthroughResponseHeaders(dst, src, nil)
  208. require.Equal(t, "blob-P", dst.Get("X-Codex-Turn-State"))
  209. // 上游缺失时清除残留(failover 换号防串扰)
  210. writeOpenAIPassthroughResponseHeaders(dst, http.Header{"Content-Type": []string{"application/json"}}, nil)
  211. require.Empty(t, dst.Get("X-Codex-Turn-State"))
  212. }
  213. func TestWriteOpenAIPassthroughResponseHeaders_RelaysReasoningIncluded(t *testing.T) {
  214. dst := http.Header{}
  215. src := http.Header{}
  216. src.Set("X-Reasoning-Included", "1")
  217. writeOpenAIPassthroughResponseHeaders(
  218. dst,
  219. src,
  220. responseheaders.CompileHeaderFilter(config.ResponseHeaderConfig{}),
  221. )
  222. require.Equal(t, "1", dst.Get("X-Reasoning-Included"))
  223. }
  224. func TestEnsureOpenAIRemoteCompactionV2BetaFeature(t *testing.T) {
  225. t.Run("absent_sets_feature", func(t *testing.T) {
  226. h := http.Header{}
  227. ensureOpenAIRemoteCompactionV2BetaFeature(h)
  228. require.Equal(t, "remote_compaction_v2", h.Get("x-codex-beta-features"))
  229. })
  230. t.Run("present_unchanged", func(t *testing.T) {
  231. h := http.Header{}
  232. h.Set("x-codex-beta-features", "responses_websockets_v2, remote_compaction_v2")
  233. ensureOpenAIRemoteCompactionV2BetaFeature(h)
  234. require.Equal(t, "responses_websockets_v2, remote_compaction_v2", h.Get("x-codex-beta-features"))
  235. })
  236. t.Run("other_tokens_merged", func(t *testing.T) {
  237. h := http.Header{}
  238. h.Set("x-codex-beta-features", "responses_websockets_v2")
  239. ensureOpenAIRemoteCompactionV2BetaFeature(h)
  240. require.Equal(t, "responses_websockets_v2,remote_compaction_v2", h.Get("x-codex-beta-features"))
  241. })
  242. t.Run("multi_line_values_merged_single_line", func(t *testing.T) {
  243. h := http.Header{}
  244. h.Add("x-codex-beta-features", "feature_a")
  245. h.Add("x-codex-beta-features", "feature_b")
  246. ensureOpenAIRemoteCompactionV2BetaFeature(h)
  247. require.Equal(t, []string{"feature_a,feature_b,remote_compaction_v2"}, h.Values("x-codex-beta-features"))
  248. })
  249. }
  250. // 对齐真实 Codex:该头是会话级常量,挂在 OAuth 的每个请求上,而不是只在
  251. // 压缩回合出现(codex-rs build_model_client_beta_features_header)。
  252. func TestApplyOpenAICodexBetaFeatures(t *testing.T) {
  253. oauthAccount := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  254. apiKeyAccount := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  255. t.Run("oauth_plain_request_gets_default_codex_shape", func(t *testing.T) {
  256. c, _ := newTurnStateTestContext(t, 7, "sess-beta")
  257. h := http.Header{}
  258. applyOpenAICodexBetaFeatures(c, oauthAccount, h)
  259. require.Equal(t, "remote_compaction_v2", h.Get("x-codex-beta-features"),
  260. "OAuth 的普通请求也必须带会话级 beta 头")
  261. })
  262. t.Run("client_declared_header_preserved", func(t *testing.T) {
  263. c, _ := newTurnStateTestContext(t, 7, "sess-beta")
  264. h := http.Header{}
  265. h.Set("x-codex-beta-features", "some_other_feature")
  266. applyOpenAICodexBetaFeatures(c, oauthAccount, h)
  267. require.Equal(t, "some_other_feature", h.Get("x-codex-beta-features"),
  268. "客户端显式声明的能力集不得被网关改写(非空即视为用户已关闭 v2)")
  269. })
  270. t.Run("native_v2_forces_feature_even_when_client_trimmed_it", func(t *testing.T) {
  271. c, _ := newTurnStateTestContext(t, 7, "sess-beta")
  272. MarkOpenAINativeCompactionV2(c)
  273. h := http.Header{}
  274. h.Set("x-codex-beta-features", "some_other_feature")
  275. applyOpenAICodexBetaFeatures(c, oauthAccount, h)
  276. require.Contains(t, h.Get("x-codex-beta-features"), "remote_compaction_v2",
  277. "body 带 compaction_trigger 是实锤,必须确保 v2 在列")
  278. require.Contains(t, h.Get("x-codex-beta-features"), "some_other_feature")
  279. })
  280. t.Run("native_v2_applies_to_non_oauth_too", func(t *testing.T) {
  281. c, _ := newTurnStateTestContext(t, 7, "sess-beta")
  282. MarkOpenAINativeCompactionV2(c)
  283. h := http.Header{}
  284. applyOpenAICodexBetaFeatures(c, apiKeyAccount, h)
  285. require.Equal(t, "remote_compaction_v2", h.Get("x-codex-beta-features"))
  286. })
  287. t.Run("non_oauth_plain_request_untouched", func(t *testing.T) {
  288. c, _ := newTurnStateTestContext(t, 7, "sess-beta")
  289. h := http.Header{}
  290. applyOpenAICodexBetaFeatures(c, apiKeyAccount, h)
  291. require.Empty(t, h.Get("x-codex-beta-features"),
  292. "非 Codex 后端不做会话级注入")
  293. })
  294. t.Run("nil_account_plain_request_untouched", func(t *testing.T) {
  295. c, _ := newTurnStateTestContext(t, 7, "sess-beta")
  296. h := http.Header{}
  297. applyOpenAICodexBetaFeatures(c, nil, h)
  298. require.Empty(t, h.Get("x-codex-beta-features"))
  299. })
  300. }
  301. // WS 握手与 HTTP 出站必须给出同一份会话级 beta 头:真实 Codex 的
  302. // build_websocket_headers 复用 build_responses_headers(client.rs),
  303. // 两侧不一致还会让预热连接与实际请求落进不同的连接池兼容分桶。
  304. func TestBuildOpenAIWSHeaders_CarriesSessionBetaFeatures(t *testing.T) {
  305. gin.SetMode(gin.TestMode)
  306. svc := &OpenAIGatewayService{}
  307. decision := OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2}
  308. build := func(t *testing.T, account *Account, clientBeta string) http.Header {
  309. t.Helper()
  310. rec := httptest.NewRecorder()
  311. c, _ := gin.CreateTestContext(rec)
  312. c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
  313. if clientBeta != "" {
  314. c.Request.Header.Set("x-codex-beta-features", clientBeta)
  315. }
  316. headers, _, err := svc.buildOpenAIWSHeaders(
  317. context.Background(), c, account, "test-token", decision,
  318. true, "", "", "", "gpt-5.6-codex", "",
  319. )
  320. require.NoError(t, err)
  321. return headers
  322. }
  323. oauthAccount := &Account{
  324. Platform: PlatformOpenAI,
  325. Type: AccountTypeOAuth,
  326. Credentials: map[string]any{"chatgpt_account_id": "test-account"},
  327. }
  328. headers := build(t, oauthAccount, "")
  329. require.Equal(t, "remote_compaction_v2", headers.Get("x-codex-beta-features"),
  330. "WS 握手也必须带会话级 beta 头")
  331. declared := build(t, oauthAccount, "some_other_feature")
  332. require.Equal(t, []string{"some_other_feature"}, declared.Values("x-codex-beta-features"),
  333. "客户端已声明时原样保留")
  334. apiKeyHeaders := build(t, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, "")
  335. require.Empty(t, apiKeyHeaders.Get("x-codex-beta-features"),
  336. "非 Codex 后端不注入")
  337. }