openai_gateway_compact_body_signal_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. package handler
  2. import (
  3. "bytes"
  4. "net/http"
  5. "net/http/httptest"
  6. "testing"
  7. "github.com/Wei-Shaw/sub2api/internal/service"
  8. "github.com/gin-gonic/gin"
  9. "github.com/stretchr/testify/require"
  10. "github.com/tidwall/gjson"
  11. "go.uber.org/zap"
  12. )
  13. func newCompactBodySignalTestContext(t *testing.T, path string, body []byte) *gin.Context {
  14. t.Helper()
  15. gin.SetMode(gin.TestMode)
  16. rec := httptest.NewRecorder()
  17. c, _ := gin.CreateTestContext(rec)
  18. c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
  19. c.Request.Header.Set("Content-Type", "application/json")
  20. return c
  21. }
  22. func TestNormalizeOpenAIResponsesCompactRequest_RemoteV2StaysOnResponses(t *testing.T) {
  23. h := &OpenAIGatewayHandler{}
  24. tests := []struct {
  25. name string
  26. betaHeader string
  27. userAgent string
  28. }{
  29. {name: "headerless"},
  30. {name: "unrelated_header", betaHeader: "responses_websockets_v2"},
  31. {name: "wrong_case_header", betaHeader: "REMOTE_COMPACTION_V2"},
  32. {name: "declared_header", betaHeader: "remote_compaction_v2"},
  33. {name: "codex_cli_user_agent", userAgent: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color"},
  34. {name: "codex_desktop_user_agent", userAgent: "Codex Desktop/0.139.0 (Mac OS X 14; arm64) unknown"},
  35. }
  36. for _, tt := range tests {
  37. t.Run(tt.name, func(t *testing.T) {
  38. body := []byte(`{
  39. "model":"gpt-5.6-sol",
  40. "stream":true,
  41. "store":true,
  42. "prompt_cache_key":"pck-signal-1",
  43. "reasoning":{"effort":"max","context":"all_turns"},
  44. "input":[
  45. {"type":"message","role":"user","content":"hello"},
  46. {"type":"compaction_trigger"}
  47. ]
  48. }`)
  49. c := newCompactBodySignalTestContext(t, "/v1/responses", body)
  50. if tt.betaHeader != "" {
  51. c.Request.Header.Set("x-codex-beta-features", tt.betaHeader)
  52. }
  53. if tt.userAgent != "" {
  54. c.Request.Header.Set("User-Agent", tt.userAgent)
  55. }
  56. normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
  57. require.True(t, ok)
  58. require.Equal(t, "/v1/responses", c.Request.URL.Path)
  59. require.False(t, isOpenAILegacyCompactPath(c))
  60. require.Equal(t, body, normalized)
  61. require.True(t, gjson.GetBytes(normalized, "stream").Bool())
  62. require.True(t, gjson.GetBytes(normalized, "store").Bool())
  63. require.Equal(t, "pck-signal-1", gjson.GetBytes(normalized, "prompt_cache_key").String())
  64. require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String())
  65. require.Equal(t, "all_turns", gjson.GetBytes(normalized, "reasoning.context").String())
  66. legacyCompact := service.IsOpenAIResponsesCompactPath(c)
  67. nativeV2 := isBareOpenAIResponsesPath(c) && isOpenAIRemoteCompactionV2Request(normalized)
  68. require.False(t, legacyCompact)
  69. require.True(t, nativeV2)
  70. require.Equal(t, service.OpenAIEndpointCapabilityResponses,
  71. openAIResponsesRequiredCapabilityForRequest(false, nativeV2 || legacyCompact, service.PlatformOpenAI))
  72. reqStream, streamOK := parseOpenAICompatibleStream(normalized)
  73. require.True(t, streamOK)
  74. require.True(t, reqStream)
  75. _, seedExists := c.Get(service.OpenAICompactSessionSeedKeyForTest())
  76. require.False(t, seedExists)
  77. _, streamMarkerExists := c.Get(service.OpenAICompactClientStreamKeyForTest())
  78. require.False(t, streamMarkerExists)
  79. })
  80. }
  81. }
  82. func TestNormalizeOpenAIResponsesCompactRequest_RemoteV2PathAliasesStayOnResponses(t *testing.T) {
  83. h := &OpenAIGatewayHandler{}
  84. body := []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`)
  85. for _, path := range []string{
  86. "/v1/responses/",
  87. "/openai/v1/responses",
  88. "/responses",
  89. "/backend-api/codex/responses",
  90. } {
  91. t.Run(path, func(t *testing.T) {
  92. c := newCompactBodySignalTestContext(t, path, body)
  93. normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
  94. require.True(t, ok)
  95. require.Equal(t, path, c.Request.URL.Path)
  96. require.Equal(t, body, normalized)
  97. legacyCompact := service.IsOpenAIResponsesCompactPath(c)
  98. nativeV2 := isBareOpenAIResponsesPath(c) && isOpenAIRemoteCompactionV2Request(normalized)
  99. require.False(t, legacyCompact)
  100. require.True(t, nativeV2)
  101. require.Equal(t, service.OpenAIEndpointCapabilityResponses,
  102. openAIResponsesRequiredCapabilityForRequest(false, nativeV2 || legacyCompact, service.PlatformOpenAI))
  103. })
  104. }
  105. }
  106. func TestOpenAIResponsesCompactionRoutingFlags(t *testing.T) {
  107. h := &OpenAIGatewayHandler{}
  108. tests := []struct {
  109. name string
  110. body []byte
  111. path string
  112. wantLegacyBefore bool
  113. wantNativeBefore bool
  114. wantLegacyAfter bool
  115. wantNativeAfter bool
  116. wantCapabilityAfter service.OpenAIEndpointCapability
  117. wantPathAfter string
  118. wantBodyUnchanged bool
  119. }{
  120. {
  121. name: "native_v2_stream_trigger",
  122. body: []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`),
  123. path: "/v1/responses",
  124. wantLegacyBefore: false,
  125. wantNativeBefore: true,
  126. wantLegacyAfter: false,
  127. wantNativeAfter: true,
  128. wantCapabilityAfter: service.OpenAIEndpointCapabilityResponses,
  129. wantPathAfter: "/v1/responses",
  130. wantBodyUnchanged: true,
  131. },
  132. {
  133. name: "native_v2_without_trigger",
  134. body: []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"message","role":"user","content":"hello"}]}`),
  135. path: "/v1/responses",
  136. wantLegacyBefore: false,
  137. wantNativeBefore: false,
  138. wantLegacyAfter: false,
  139. wantNativeAfter: false,
  140. wantCapabilityAfter: service.OpenAIEndpointCapabilityChatCompletions,
  141. wantPathAfter: "/v1/responses",
  142. wantBodyUnchanged: true,
  143. },
  144. {
  145. name: "explicit_compact",
  146. body: []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`),
  147. path: "/v1/responses/compact",
  148. wantLegacyBefore: true,
  149. wantNativeBefore: false,
  150. wantLegacyAfter: true,
  151. wantNativeAfter: false,
  152. wantCapabilityAfter: service.OpenAIEndpointCapabilityResponses,
  153. wantPathAfter: "/v1/responses/compact",
  154. },
  155. {
  156. name: "nested_compact",
  157. body: []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`),
  158. path: "/v1/responses/compact/detail",
  159. wantLegacyBefore: true,
  160. wantNativeBefore: false,
  161. wantLegacyAfter: true,
  162. wantNativeAfter: false,
  163. wantCapabilityAfter: service.OpenAIEndpointCapabilityResponses,
  164. wantPathAfter: "/v1/responses/compact/detail",
  165. },
  166. {
  167. name: "responses_subpath_with_native_signal",
  168. body: []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`),
  169. path: "/v1/responses/resp_123/responses",
  170. wantLegacyBefore: false,
  171. wantNativeBefore: false,
  172. wantLegacyAfter: false,
  173. wantNativeAfter: false,
  174. wantCapabilityAfter: service.OpenAIEndpointCapabilityChatCompletions,
  175. wantPathAfter: "/v1/responses/resp_123/responses",
  176. wantBodyUnchanged: true,
  177. },
  178. {
  179. name: "stream_false_promotes",
  180. body: []byte(`{"model":"gpt-5.6-sol","stream":false,"input":[{"type":"compaction_trigger"}]}`),
  181. path: "/v1/responses",
  182. wantLegacyBefore: false,
  183. wantNativeBefore: false,
  184. wantLegacyAfter: true,
  185. wantNativeAfter: false,
  186. wantCapabilityAfter: service.OpenAIEndpointCapabilityResponses,
  187. wantPathAfter: "/v1/responses/compact",
  188. },
  189. {
  190. name: "stream_absent_promotes",
  191. body: []byte(`{"model":"gpt-5.6-sol","input":[{"type":"compaction_trigger"}]}`),
  192. path: "/v1/responses",
  193. wantLegacyBefore: false,
  194. wantNativeBefore: false,
  195. wantLegacyAfter: true,
  196. wantNativeAfter: false,
  197. wantCapabilityAfter: service.OpenAIEndpointCapabilityResponses,
  198. wantPathAfter: "/v1/responses/compact",
  199. },
  200. }
  201. for _, tt := range tests {
  202. t.Run(tt.name, func(t *testing.T) {
  203. c := newCompactBodySignalTestContext(t, tt.path, tt.body)
  204. legacyBefore := service.IsOpenAIResponsesCompactPath(c)
  205. nativeBefore := isBareOpenAIResponsesPath(c) && isOpenAIRemoteCompactionV2Request(tt.body)
  206. require.Equal(t, tt.wantLegacyBefore, legacyBefore)
  207. require.Equal(t, tt.wantNativeBefore, nativeBefore)
  208. normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), tt.body)
  209. require.True(t, ok)
  210. require.Equal(t, tt.wantPathAfter, c.Request.URL.Path)
  211. legacyAfter := service.IsOpenAIResponsesCompactPath(c)
  212. nativeAfter := isBareOpenAIResponsesPath(c) && isOpenAIRemoteCompactionV2Request(normalized)
  213. require.Equal(t, tt.wantLegacyAfter, legacyAfter)
  214. require.Equal(t, tt.wantNativeAfter, nativeAfter)
  215. require.Equal(t, tt.wantCapabilityAfter,
  216. openAIResponsesRequiredCapabilityForRequest(false, nativeAfter || legacyAfter, service.PlatformOpenAI))
  217. if tt.wantBodyUnchanged {
  218. require.Equal(t, tt.body, normalized)
  219. }
  220. })
  221. }
  222. }
  223. func TestNormalizeOpenAIResponsesCompactRequest_BodySignalTrailingSlashPromoted(t *testing.T) {
  224. h := &OpenAIGatewayHandler{}
  225. body := []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`)
  226. c := newCompactBodySignalTestContext(t, "/v1/responses/", body)
  227. _, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
  228. require.True(t, ok)
  229. require.Equal(t, "/v1/responses/compact", c.Request.URL.Path)
  230. }
  231. func TestNormalizeOpenAIResponsesCompactRequest_CodexDirectAliasPromoted(t *testing.T) {
  232. h := &OpenAIGatewayHandler{}
  233. body := []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`)
  234. c := newCompactBodySignalTestContext(t, "/backend-api/codex/responses", body)
  235. _, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
  236. require.True(t, ok)
  237. require.Equal(t, "/backend-api/codex/responses/compact", c.Request.URL.Path)
  238. }
  239. func TestNormalizeOpenAIResponsesCompactRequest_NonRemoteV2BodySignalPromoted(t *testing.T) {
  240. h := &OpenAIGatewayHandler{}
  241. tests := []struct {
  242. name string
  243. body []byte
  244. betaHeader string
  245. wantMarked bool
  246. }{
  247. {
  248. name: "stream_false_headerless",
  249. body: []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"compaction_trigger"}]}`),
  250. },
  251. {
  252. name: "stream_absent_headerless",
  253. body: []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`),
  254. },
  255. {
  256. name: "stream_false_declared_header",
  257. body: []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"compaction_trigger"}]}`),
  258. betaHeader: "remote_compaction_v2",
  259. },
  260. {
  261. name: "stream_absent_wrong_case_header",
  262. body: []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`),
  263. betaHeader: "REMOTE_COMPACTION_V2",
  264. },
  265. }
  266. for _, tt := range tests {
  267. t.Run(tt.name, func(t *testing.T) {
  268. c := newCompactBodySignalTestContext(t, "/v1/responses", tt.body)
  269. if tt.betaHeader != "" {
  270. c.Request.Header.Set("x-codex-beta-features", tt.betaHeader)
  271. }
  272. normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), tt.body)
  273. require.True(t, ok)
  274. require.Equal(t, "/v1/responses/compact", c.Request.URL.Path)
  275. require.False(t, gjson.GetBytes(normalized, "stream").Exists())
  276. marked, exists := c.Get(service.OpenAICompactClientStreamKeyForTest())
  277. require.Equal(t, tt.wantMarked, exists)
  278. if tt.wantMarked {
  279. require.Equal(t, true, marked)
  280. }
  281. })
  282. }
  283. }
  284. func TestNormalizeOpenAIResponsesCompactRequest_NoTriggerUntouched(t *testing.T) {
  285. h := &OpenAIGatewayHandler{}
  286. body := []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"message","role":"user","content":"hello"}]}`)
  287. c := newCompactBodySignalTestContext(t, "/v1/responses", body)
  288. normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
  289. require.True(t, ok)
  290. require.Equal(t, "/v1/responses", c.Request.URL.Path)
  291. require.False(t, isOpenAILegacyCompactPath(c))
  292. require.Equal(t, body, normalized)
  293. require.True(t, gjson.GetBytes(normalized, "stream").Bool())
  294. }
  295. func TestNormalizeOpenAIResponsesCompactRequest_PathBasedNoDoubleSuffix(t *testing.T) {
  296. h := &OpenAIGatewayHandler{}
  297. body := []byte(`{"model":"gpt-5.5","stream":true,"store":true,"input":[{"type":"message","role":"user","content":"hello"}]}`)
  298. c := newCompactBodySignalTestContext(t, "/v1/responses/compact", body)
  299. normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
  300. require.True(t, ok)
  301. require.Equal(t, "/v1/responses/compact", c.Request.URL.Path)
  302. require.False(t, gjson.GetBytes(normalized, "stream").Exists())
  303. require.False(t, gjson.GetBytes(normalized, "store").Exists())
  304. }
  305. func TestNormalizeOpenAIResponsesCompactRequest_SubpathNotPromoted(t *testing.T) {
  306. h := &OpenAIGatewayHandler{}
  307. body := []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`)
  308. c := newCompactBodySignalTestContext(t, "/v1/responses/resp_123/cancel", body)
  309. normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
  310. require.True(t, ok)
  311. require.Equal(t, "/v1/responses/resp_123/cancel", c.Request.URL.Path)
  312. require.Equal(t, body, normalized)
  313. }
  314. // path-based compact(Codex v1 unary 协议)即使 body 带 stream:true 也不标记,
  315. // 保持 JSON 写回行为不变。
  316. func TestNormalizeOpenAIResponsesCompactRequest_PathBasedStreamTrueNotMarked(t *testing.T) {
  317. h := &OpenAIGatewayHandler{}
  318. body := []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"message","role":"user","content":"hello"}]}`)
  319. c := newCompactBodySignalTestContext(t, "/v1/responses/compact", body)
  320. _, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
  321. require.True(t, ok)
  322. _, exists := c.Get(service.OpenAICompactClientStreamKeyForTest())
  323. require.False(t, exists)
  324. }