openai_gateway_apikey_item_id_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "runtime"
  9. "strings"
  10. "testing"
  11. "github.com/gin-gonic/gin"
  12. "github.com/stretchr/testify/require"
  13. "github.com/tidwall/gjson"
  14. )
  15. func TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidInputItemIDs(t *testing.T) {
  16. gin.SetMode(gin.TestMode)
  17. upstream := &httpUpstreamRecorder{resp: &http.Response{
  18. StatusCode: http.StatusOK,
  19. Header: http.Header{"Content-Type": []string{"application/json"}},
  20. Body: io.NopCloser(strings.NewReader(
  21. `{"id":"resp_test","model":"gpt-5.6-sol","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`,
  22. )),
  23. }}
  24. svc := newOpenAIImageGenerationControlTestService(upstream)
  25. c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1")
  26. account := newOpenAIImageGenerationControlTestAccount()
  27. account.Extra = map[string]any{"openai_passthrough": true}
  28. body := []byte(`{
  29. "model":"gpt-5.6-sol",
  30. "stream":false,
  31. "input":[
  32. {"type":"message","id":"item_bad_message","role":"assistant","content":[{"type":"output_text","text":"hello"}]},
  33. {"type":"function_call","id":"item_bad_call","call_id":"call_123","name":"exec_command","arguments":"{}"},
  34. {"type":"message","id":"msg_valid","role":"user","content":[{"type":"input_text","text":"continue"}]},
  35. {"type":"function_call","id":"fc_valid","call_id":"call_456","name":"apply_patch","arguments":"{}"},
  36. {"type":"custom_tool_call","id":"fc_wrong_custom","call_id":"call_custom_1","name":"apply_patch","input":"patch"},
  37. {"type":"custom_tool_call","id":"ctc_valid","call_id":"call_custom_2","name":"apply_patch","input":"patch"},
  38. {"type":"tool_search_call","id":"fc_wrong_search","call_id":"call_search_1","arguments":{"query":"docs"}},
  39. {"type":"tool_search_call","id":"tsc_valid","call_id":"call_search_2","arguments":{"query":"docs"}},
  40. {"type":"function_call_output","id":"item_output","call_id":"call_123","output":"done"},
  41. {"type":"web_search_call","id":"item_wrong_web"}
  42. ]
  43. }`)
  44. result, err := svc.Forward(context.Background(), c, account, body)
  45. require.NoError(t, err)
  46. require.NotNil(t, result)
  47. require.NotNil(t, upstream.lastReq)
  48. forwarded := upstream.lastBody
  49. require.False(t, gjson.GetBytes(forwarded, "input.0.id").Exists())
  50. require.Equal(t, "hello", gjson.GetBytes(forwarded, "input.0.content.0.text").String())
  51. require.False(t, gjson.GetBytes(forwarded, "input.1.id").Exists())
  52. require.Equal(t, "call_123", gjson.GetBytes(forwarded, "input.1.call_id").String())
  53. require.Equal(t, "exec_command", gjson.GetBytes(forwarded, "input.1.name").String())
  54. require.Equal(t, "{}", gjson.GetBytes(forwarded, "input.1.arguments").String())
  55. require.Equal(t, "msg_valid", gjson.GetBytes(forwarded, "input.2.id").String())
  56. require.Equal(t, "fc_valid", gjson.GetBytes(forwarded, "input.3.id").String())
  57. require.False(t, gjson.GetBytes(forwarded, "input.4.id").Exists())
  58. require.Equal(t, "ctc_valid", gjson.GetBytes(forwarded, "input.5.id").String())
  59. require.False(t, gjson.GetBytes(forwarded, "input.6.id").Exists())
  60. require.Equal(t, "tsc_valid", gjson.GetBytes(forwarded, "input.7.id").String())
  61. require.Equal(t, "item_output", gjson.GetBytes(forwarded, "input.8.id").String())
  62. require.Equal(t, "call_123", gjson.GetBytes(forwarded, "input.8.call_id").String())
  63. require.False(t, gjson.GetBytes(forwarded, "input.9.id").Exists())
  64. }
  65. func TestOpenAIGatewayService_OAuthPassthrough_SanitizesNativeToolItemIDs(t *testing.T) {
  66. gin.SetMode(gin.TestMode)
  67. for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
  68. t.Run(accountType, func(t *testing.T) {
  69. upstreamSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_test\",\"model\":\"gpt-5.6-sol\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\ndata: [DONE]\n\n"
  70. upstream := &httpUpstreamRecorder{resp: &http.Response{
  71. StatusCode: http.StatusOK,
  72. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  73. Body: io.NopCloser(strings.NewReader(upstreamSSE)),
  74. }}
  75. svc := newOpenAIImageGenerationControlTestService(upstream)
  76. c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1")
  77. account := newOpenAIImageGenerationControlTestAccount()
  78. account.Type = accountType
  79. account.Credentials = map[string]any{
  80. "access_token": "oauth-token",
  81. "chatgpt_account_id": "chatgpt-account",
  82. }
  83. account.Extra = map[string]any{"openai_passthrough": true}
  84. body := []byte(`{
  85. "model":"gpt-5.6-sol",
  86. "stream":true,
  87. "instructions":"test",
  88. "input":[
  89. {"type":"custom_tool_call","id":"fc_wrong_custom","call_id":"call_custom_1","name":"apply_patch","input":"patch"},
  90. {"type":"custom_tool_call","id":"ctc_valid","call_id":"call_custom_2","name":"apply_patch","input":"patch"},
  91. {"type":"tool_search_call","id":"fc_wrong_search","call_id":"call_search_1","arguments":{"query":"docs"}},
  92. {"type":"tool_search_call","id":"tsc_valid","call_id":"call_search_2","arguments":{"query":"docs"}}
  93. ]
  94. }`)
  95. result, err := svc.Forward(context.Background(), c, account, body)
  96. require.NoError(t, err)
  97. require.NotNil(t, result)
  98. require.NotNil(t, upstream.lastReq)
  99. require.Equal(t, "https://chatgpt.com/backend-api/codex/responses", upstream.lastReq.URL.String())
  100. require.False(t, gjson.GetBytes(upstream.lastBody, "input.0.id").Exists())
  101. require.Equal(t, "ctc_valid", gjson.GetBytes(upstream.lastBody, "input.1.id").String())
  102. require.False(t, gjson.GetBytes(upstream.lastBody, "input.2.id").Exists())
  103. require.Equal(t, "tsc_valid", gjson.GetBytes(upstream.lastBody, "input.3.id").String())
  104. })
  105. }
  106. }
  107. func TestOpenAIGatewayService_SetupTokenLegacy_SanitizesAndTransforms(t *testing.T) {
  108. gin.SetMode(gin.TestMode)
  109. upstreamSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_test\",\"model\":\"gpt-5.6-sol\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\ndata: [DONE]\n\n"
  110. upstream := &httpUpstreamRecorder{resp: &http.Response{
  111. StatusCode: http.StatusOK,
  112. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  113. Body: io.NopCloser(strings.NewReader(upstreamSSE)),
  114. }}
  115. svc := newOpenAIImageGenerationControlTestService(upstream)
  116. c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1")
  117. account := newOpenAIImageGenerationControlTestAccount()
  118. account.Type = AccountTypeSetupToken
  119. account.Credentials = map[string]any{
  120. "access_token": "setup-token",
  121. "chatgpt_account_id": "chatgpt-account",
  122. }
  123. account.Extra = map[string]any{"openai_passthrough": false}
  124. body := []byte(`{
  125. "model":"gpt-5.6-sol",
  126. "stream":true,
  127. "store":true,
  128. "reasoning":{"mode":"pro"},
  129. "instructions":"test",
  130. "input":[
  131. {"type":"custom_tool_call","id":"fc_wrong_custom","call_id":"call_custom_1","name":"apply_patch","input":"patch"},
  132. {"type":"function_call_output","call_id":"call_orphan","output":"orphan"}
  133. ]
  134. }`)
  135. result, err := svc.Forward(context.Background(), c, account, body)
  136. require.NoError(t, err)
  137. require.NotNil(t, result)
  138. require.NotNil(t, upstream.lastReq)
  139. require.Equal(t, "https://chatgpt.com/backend-api/codex/responses", upstream.lastReq.URL.String())
  140. require.Equal(t, false, gjson.GetBytes(upstream.lastBody, "store").Bool())
  141. require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
  142. require.False(t, gjson.GetBytes(upstream.lastBody, "reasoning.mode").Exists())
  143. require.False(t, gjson.GetBytes(upstream.lastBody, "input.0.id").Exists())
  144. require.Len(t, gjson.GetBytes(upstream.lastBody, "input").Array(), 1)
  145. }
  146. // TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidReasoningItemIDs
  147. // verifies that reasoning items with a non-rs id (e.g. item_*) are stripped
  148. // before forwarding. OpenAI upstream requires reasoning ids to begin with
  149. // "rs" and rejects item_* with 400:
  150. // "Expected an ID that begins with 'rs'." (#5410)
  151. func TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidReasoningItemIDs(t *testing.T) {
  152. gin.SetMode(gin.TestMode)
  153. upstream := &httpUpstreamRecorder{resp: &http.Response{
  154. StatusCode: http.StatusOK,
  155. Header: http.Header{"Content-Type": []string{"application/json"}},
  156. Body: io.NopCloser(strings.NewReader(
  157. `{"id":"resp_test","model":"gpt-5.6-sol","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`,
  158. )),
  159. }}
  160. svc := newOpenAIImageGenerationControlTestService(upstream)
  161. c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1")
  162. account := newOpenAIImageGenerationControlTestAccount()
  163. account.Extra = map[string]any{"openai_passthrough": true}
  164. body := []byte(`{
  165. "model":"gpt-5.6-sol",
  166. "stream":false,
  167. "input":[
  168. {"type":"reasoning","id":"item_bad_reasoning","summary":[]},
  169. {"type":"reasoning","id":"rs_valid","summary":[]},
  170. {"type":"message","id":"msg_valid","role":"user","content":[{"type":"input_text","text":"continue"}]}
  171. ]
  172. }`)
  173. result, err := svc.Forward(context.Background(), c, account, body)
  174. require.NoError(t, err)
  175. require.NotNil(t, result)
  176. require.NotNil(t, upstream.lastReq)
  177. forwarded := upstream.lastBody
  178. require.False(t, gjson.GetBytes(forwarded, "input.0.id").Exists(),
  179. "item_* id should be stripped from reasoning")
  180. require.Equal(t, "rs_valid", gjson.GetBytes(forwarded, "input.1.id").String(),
  181. "valid rs* id must be preserved")
  182. require.Equal(t, "msg_valid", gjson.GetBytes(forwarded, "input.2.id").String())
  183. }
  184. func TestShouldStripOpenAIResponsesInputItemID_Reasoning(t *testing.T) {
  185. cases := []struct {
  186. name string
  187. itemType string
  188. id string
  189. want bool
  190. }{
  191. {"reasoning item_* id", "reasoning", "item_bad_reasoning", true},
  192. {"reasoning rs id", "reasoning", "rs_abc123", false},
  193. {"reasoning empty id", "reasoning", "", true},
  194. {"message msg id", "message", "msg_abc", false},
  195. {"message item id", "message", "item_x", true},
  196. {"function_call fc id", "function_call", "fc_abc", false},
  197. {"function_call ctc id", "function_call", "ctc_abc", true},
  198. {"function_call item id", "function_call", "item_x", true},
  199. {"custom tool ctc id", "custom_tool_call", "ctc_abc", false},
  200. {"custom tool fc id", "custom_tool_call", "fc_abc", true},
  201. {"tool search tsc id", "tool_search_call", "tsc_abc", false},
  202. {"tool search fc id", "tool_search_call", "fc_abc", true},
  203. {"web search ws id", "web_search_call", "ws_001", false},
  204. {"web search item id", "web_search_call", "item_001", true},
  205. {"custom output fc id", "custom_tool_call_output", "fc_001", false},
  206. {"custom output ctco id", "custom_tool_call_output", "ctco_001", true},
  207. }
  208. for _, tc := range cases {
  209. t.Run(tc.name, func(t *testing.T) {
  210. require.Equal(t, tc.want, shouldStripOpenAIResponsesInputItemID(tc.itemType, tc.id))
  211. })
  212. }
  213. }
  214. func TestSanitizeOpenAIResponsesInputItemIDs_AllocationGrowthIsLinear(t *testing.T) {
  215. makeBody := func(itemCount int) []byte {
  216. items := make([]string, itemCount)
  217. for i := range items {
  218. items[i] = fmt.Sprintf(`{"type":"message","id":"item_%d","role":"user","content":[{"type":"input_text","text":"hello"}]}`, i)
  219. }
  220. return []byte(`{"model":"gpt-5.6-sol","input":[` + strings.Join(items, ",") + `]}`)
  221. }
  222. allocatedBytes := func(body []byte) uint64 {
  223. runtime.GC()
  224. var before, after runtime.MemStats
  225. runtime.ReadMemStats(&before)
  226. sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body)
  227. runtime.ReadMemStats(&after)
  228. require.NoError(t, err)
  229. require.True(t, changed)
  230. require.NotEmpty(t, sanitized)
  231. return after.TotalAlloc - before.TotalAlloc
  232. }
  233. smallAllocated := allocatedBytes(makeBody(20))
  234. largeAllocated := allocatedBytes(makeBody(200))
  235. require.Less(t, largeAllocated, smallAllocated*30,
  236. "10x more input items must not cause quadratic whole-body allocation growth")
  237. }
  238. func TestNormalizeOpenAIResponsesWebSocketCompatibilityBodyPreservesOpaqueReferences(t *testing.T) {
  239. body := []byte(`{"type":"response.create","input":[
  240. {"type":"custom_tool_call","id":"ctc_call","call_id":"call_custom","name":"apply_patch","input":"patch"},
  241. {"type":"custom_tool_call_output","id":"ctco_bad","call_id":"call_custom","output":"done"},
  242. {"type":"item_reference","id":"ctco_bad"},
  243. {"type":"future_item","id":"item_future","payload":"keep"}
  244. ]}`)
  245. for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} {
  246. t.Run(accountType, func(t *testing.T) {
  247. normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
  248. Platform: PlatformOpenAI,
  249. Type: accountType,
  250. }, false)
  251. require.NoError(t, err)
  252. require.True(t, changed)
  253. require.Len(t, gjson.GetBytes(normalized, "input").Array(), 4)
  254. require.Equal(t, "ctc_call", gjson.GetBytes(normalized, "input.0.id").String())
  255. require.Equal(t, "call_custom", gjson.GetBytes(normalized, "input.1.call_id").String())
  256. require.False(t, gjson.GetBytes(normalized, "input.1.id").Exists())
  257. require.Equal(t, "ctco_bad", gjson.GetBytes(normalized, "input.2.id").String())
  258. require.Equal(t, "item_future", gjson.GetBytes(normalized, "input.3.id").String())
  259. second, changedAgain, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, &Account{
  260. Platform: PlatformOpenAI,
  261. Type: accountType,
  262. }, false)
  263. require.NoError(t, err)
  264. require.False(t, changedAgain)
  265. require.JSONEq(t, string(normalized), string(second))
  266. })
  267. }
  268. }