openai_gateway_responses_client_tools_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "io"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
  13. "github.com/gin-gonic/gin"
  14. "github.com/stretchr/testify/require"
  15. "github.com/tidwall/gjson"
  16. )
  17. func openAIClientToolsRequest(stream bool) []byte {
  18. streamValue := "false"
  19. if stream {
  20. streamValue = "true"
  21. }
  22. return []byte(`{"model":"gpt-5.4","input":"fix it","stream":` + streamValue + `,"tools":[{"type":"custom","name":"exec"},{"type":"custom","name":"apply_patch"}]}`)
  23. }
  24. func assertOpenAIClientToolsLowered(t *testing.T, body []byte) {
  25. t.Helper()
  26. for index, name := range []string{"exec", "apply_patch"} {
  27. tool := gjson.GetBytes(body, "tools."+string(rune('0'+index)))
  28. require.Equal(t, "function", tool.Get("type").String())
  29. require.Equal(t, name, tool.Get("name").String())
  30. require.Equal(t, "string", tool.Get("parameters.properties.input.type").String())
  31. }
  32. }
  33. func openAIClientToolsTestService(upstream *httpUpstreamRecorder) *OpenAIGatewayService {
  34. return &OpenAIGatewayService{
  35. httpUpstream: upstream,
  36. cfg: &config.Config{Security: config.SecurityConfig{
  37. URLAllowlist: config.URLAllowlistConfig{Enabled: false},
  38. }},
  39. }
  40. }
  41. func TestAdaptOpenAIResponsesClientToolsLeavesNamespaceOnlyBodyUnchanged(t *testing.T) {
  42. body := []byte(`{
  43. "model": "gpt-5.5",
  44. "tools": [{"type": "namespace", "name": "code_tools", "tools": [{"type": "function", "name": "run"}]}],
  45. "tool_choice": "auto"
  46. }`)
  47. adapted, mapping, err := adaptOpenAIResponsesClientTools(body)
  48. require.NoError(t, err)
  49. require.Equal(t, body, adapted)
  50. require.Empty(t, mapping.CustomTools)
  51. require.Empty(t, mapping.NamespaceTools)
  52. require.False(t, mapping.ToolSearch)
  53. }
  54. func TestAdaptOpenAIResponsesClientToolsRejectsTrailingData(t *testing.T) {
  55. tests := map[string][]byte{
  56. "trailing garbage": append(openAIClientToolsRequest(false), []byte(` garbage`)...),
  57. "second JSON document": append(openAIClientToolsRequest(false), []byte(` {"model":"other"}`)...),
  58. }
  59. for name, body := range tests {
  60. t.Run(name, func(t *testing.T) {
  61. adapted, mapping, err := adaptOpenAIResponsesClientTools(body)
  62. require.ErrorContains(t, err, "decode OpenAI Responses client tools trailing data")
  63. require.Equal(t, body, adapted)
  64. require.Empty(t, mapping)
  65. })
  66. }
  67. }
  68. func TestResponsesFunctionUpstreamsLowerToolSearchDiscoveryOutput(t *testing.T) {
  69. body := []byte(`{"tools":[{"type":"tool_search"}],"input":[{"type":"tool_search_output","call_id":"search_1","tools":[{"type":"namespace","name":"github"}],"status":"completed","execution":"client"}]}`)
  70. adapters := map[string]func([]byte) ([]byte, apicompat.ResponsesClientToolMapping, error){
  71. "OpenAI API-key": adaptOpenAIResponsesClientTools,
  72. "Grok": adaptGrokResponsesClientTools,
  73. }
  74. for name, adapt := range adapters {
  75. t.Run(name, func(t *testing.T) {
  76. adapted, mapping, err := adapt(body)
  77. require.NoError(t, err)
  78. require.True(t, mapping.ToolSearch)
  79. require.Equal(t, "function_call_output", gjson.GetBytes(adapted, "input.0.type").String())
  80. require.JSONEq(t, `[{"name":"github","type":"namespace"}]`, gjson.GetBytes(adapted, "input.0.output").String())
  81. require.False(t, gjson.GetBytes(adapted, "input.0.tools").Exists())
  82. require.False(t, gjson.GetBytes(adapted, "input.0.status").Exists())
  83. require.False(t, gjson.GetBytes(adapted, "input.0.execution").Exists())
  84. })
  85. }
  86. }
  87. func TestClearOpenAIResponsesClientToolMappingRemovesStaleContextState(t *testing.T) {
  88. gin.SetMode(gin.TestMode)
  89. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  90. c.Set(openAIResponsesClientToolMappingContextKey, apicompat.ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}})
  91. clearOpenAIResponsesClientToolMapping(c)
  92. _, ok := openAIResponsesClientToolMapping(c)
  93. require.False(t, ok)
  94. }
  95. func TestDeepSeekResponsesForwardRestoresClientToolsStreaming(t *testing.T) {
  96. gin.SetMode(gin.TestMode)
  97. body := openAIClientToolsRequest(true)
  98. recorder := httptest.NewRecorder()
  99. c, _ := gin.CreateTestContext(recorder)
  100. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  101. sse := strings.Join([]string{
  102. `data: {"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"type":"function_call","id":"i1","call_id":"c1","name":"exec","status":"in_progress"}}`,
  103. `data: {"type":"response.function_call_arguments.done","sequence_number":1,"item_id":"i1","call_id":"c1","name":"exec","arguments":"{\"input\":\"pwd\"}"}`,
  104. `data: {"type":"response.output_item.done","sequence_number":2,"output_index":0,"item":{"type":"function_call","id":"i1","call_id":"c1","name":"exec","arguments":"{\"input\":\"pwd\"}","status":"completed"}}`,
  105. `data: {"type":"response.completed","sequence_number":3,"response":{"id":"resp_ds_tools","status":"completed","output":[{"type":"function_call","id":"i1","call_id":"c1","name":"exec","arguments":"{\"input\":\"pwd\"}"}],"usage":{"input_tokens":1,"output_tokens":1}}}`,
  106. }, "\n\n") + "\n\n"
  107. upstream := &httpUpstreamRecorder{resp: &http.Response{
  108. StatusCode: http.StatusOK,
  109. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  110. Body: io.NopCloser(strings.NewReader(sse)),
  111. }}
  112. svc := openAIClientToolsTestService(upstream)
  113. account := &Account{
  114. ID: 5661,
  115. Platform: PlatformDeepseek,
  116. Type: AccountTypeAPIKey,
  117. Credentials: map[string]any{
  118. "api_key": "test-key",
  119. "api_protocol": APIProtocolResponses,
  120. "base_url": "https://relay.example",
  121. },
  122. }
  123. result, err := svc.Forward(context.Background(), c, account, body)
  124. require.NoError(t, err)
  125. require.NotNil(t, result)
  126. assertOpenAIClientToolsLowered(t, upstream.lastBody)
  127. require.Equal(t, "/responses", upstream.lastReq.URL.Path)
  128. output := recorder.Body.String()
  129. require.Contains(t, output, `"type":"custom_tool_call"`)
  130. require.Contains(t, output, `"type":"response.custom_tool_call_input.done"`)
  131. require.Contains(t, output, `"input":"pwd"`)
  132. }
  133. func TestDeepSeekAdaptiveResponsesForwardRestoresClientToolsNonStreaming(t *testing.T) {
  134. gin.SetMode(gin.TestMode)
  135. body := openAIClientToolsRequest(false)
  136. recorder := httptest.NewRecorder()
  137. c, _ := gin.CreateTestContext(recorder)
  138. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  139. upstream := &httpUpstreamRecorder{resp: &http.Response{
  140. StatusCode: http.StatusOK,
  141. Header: http.Header{"Content-Type": []string{"application/json"}},
  142. Body: io.NopCloser(strings.NewReader(`{"id":"resp_ds_adaptive_tools","status":"completed","output":[
  143. {"type":"function_call","id":"i1","call_id":"c1","name":"exec","arguments":"{\"input\":\"pwd\"}"},
  144. {"type":"function_call","id":"i2","call_id":"c2","name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\"}"}],
  145. "usage":{"input_tokens":1,"output_tokens":1}}`)),
  146. }}
  147. svc := openAIClientToolsTestService(upstream)
  148. account := &Account{
  149. ID: 5662,
  150. Platform: PlatformDeepseek,
  151. Type: AccountTypeAPIKey,
  152. Credentials: map[string]any{
  153. "api_key": "test-key",
  154. "api_protocol": APIProtocolAdaptive,
  155. "api_base_urls": map[string]any{
  156. APIProtocolResponses: "https://relay.example",
  157. },
  158. },
  159. }
  160. result, err := svc.Forward(context.Background(), c, account, body)
  161. require.NoError(t, err)
  162. require.NotNil(t, result)
  163. assertOpenAIClientToolsLowered(t, upstream.lastBody)
  164. require.Equal(t, "/responses", upstream.lastReq.URL.Path)
  165. require.Equal(t, "custom_tool_call", gjson.Get(recorder.Body.String(), "output.0.type").String())
  166. require.Equal(t, "pwd", gjson.Get(recorder.Body.String(), "output.0.input").String())
  167. require.Equal(t, "custom_tool_call", gjson.Get(recorder.Body.String(), "output.1.type").String())
  168. require.Equal(t, "*** Begin Patch", gjson.Get(recorder.Body.String(), "output.1.input").String())
  169. }
  170. func TestDeepSeekResponsesCompactSkipsClientToolAdaptation(t *testing.T) {
  171. gin.SetMode(gin.TestMode)
  172. body := openAIClientToolsRequest(false)
  173. recorder := httptest.NewRecorder()
  174. c, _ := gin.CreateTestContext(recorder)
  175. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewReader(body))
  176. upstream := &httpUpstreamRecorder{resp: &http.Response{
  177. StatusCode: http.StatusOK,
  178. Header: http.Header{"Content-Type": []string{"application/json"}},
  179. Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
  180. }}
  181. svc := openAIClientToolsTestService(upstream)
  182. account := &Account{
  183. ID: 5663,
  184. Platform: PlatformDeepseek,
  185. Type: AccountTypeAPIKey,
  186. Credentials: map[string]any{
  187. "api_key": "test-key",
  188. "api_protocol": APIProtocolResponses,
  189. "base_url": "https://relay.example",
  190. },
  191. }
  192. _, err := svc.Forward(context.Background(), c, account, body)
  193. require.NoError(t, err)
  194. require.Equal(t, "custom", gjson.GetBytes(upstream.lastBody, "tools.0.type").String())
  195. require.Equal(t, "/responses/compact", upstream.lastReq.URL.Path)
  196. }
  197. func TestOpenAIPassthroughAPIKeyRestoresClientToolsNonStreaming(t *testing.T) {
  198. gin.SetMode(gin.TestMode)
  199. body := openAIClientToolsRequest(false)
  200. recorder := httptest.NewRecorder()
  201. c, _ := gin.CreateTestContext(recorder)
  202. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  203. upstream := &httpUpstreamRecorder{resp: &http.Response{
  204. StatusCode: http.StatusOK,
  205. Header: http.Header{"Content-Type": []string{"application/json"}},
  206. Body: io.NopCloser(strings.NewReader(`{"id":"resp_tools","status":"completed","output":[
  207. {"type":"function_call","id":"i1","call_id":"c1","name":"exec","arguments":"{\"input\":\"pwd\"}"},
  208. {"type":"function_call","id":"i2","call_id":"c2","name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\"}"}],"usage":{}}`)),
  209. }}
  210. svc := openAIClientToolsTestService(upstream)
  211. account := &Account{ID: 5659, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test-key"}}
  212. result, err := svc.forwardOpenAIPassthrough(context.Background(), c, account, body, body, "gpt-5.4", false, nil, false, time.Now())
  213. require.NoError(t, err)
  214. require.NotNil(t, result)
  215. assertOpenAIClientToolsLowered(t, upstream.lastBody)
  216. require.Equal(t, "custom_tool_call", gjson.Get(recorder.Body.String(), "output.0.type").String())
  217. require.Equal(t, "pwd", gjson.Get(recorder.Body.String(), "output.0.input").String())
  218. require.Equal(t, "custom_tool_call", gjson.Get(recorder.Body.String(), "output.1.type").String())
  219. require.Equal(t, "*** Begin Patch", gjson.Get(recorder.Body.String(), "output.1.input").String())
  220. }
  221. func TestOpenAIPassthroughAPIKeyPreservesCustomToolOutputContentParts(t *testing.T) {
  222. gin.SetMode(gin.TestMode)
  223. body := []byte(`{"model":"gpt-5.4","stream":false,"tools":[{"type":"custom","name":"exec"}],"input":[{"type":"custom_tool_call_output","call_id":"call_1","output":[{"type":"input_text","text":"result"},{"type":"input_file","file_id":"file_123"}]}]}`)
  224. recorder := httptest.NewRecorder()
  225. c, _ := gin.CreateTestContext(recorder)
  226. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  227. upstream := &httpUpstreamRecorder{resp: &http.Response{
  228. StatusCode: http.StatusOK,
  229. Header: http.Header{"Content-Type": []string{"application/json"}},
  230. Body: io.NopCloser(strings.NewReader(`{"id":"resp_tools","status":"completed","output":[],"usage":{}}`)),
  231. }}
  232. svc := openAIClientToolsTestService(upstream)
  233. account := &Account{ID: 6240, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test-key"}}
  234. result, err := svc.forwardOpenAIPassthrough(context.Background(), c, account, body, body, "gpt-5.4", false, nil, false, time.Now())
  235. require.NoError(t, err)
  236. require.NotNil(t, result)
  237. require.Equal(t, "function_call_output", gjson.GetBytes(upstream.lastBody, "input.0.type").String())
  238. output := gjson.GetBytes(upstream.lastBody, "input.0.output")
  239. require.True(t, output.IsArray(), "native Responses content parts must reach the upstream as an array")
  240. require.Equal(t, "input_text", output.Get("0.type").String())
  241. require.Equal(t, "result", output.Get("0.text").String())
  242. require.Equal(t, "input_file", output.Get("1.type").String())
  243. require.Equal(t, "file_123", output.Get("1.file_id").String())
  244. }
  245. func TestOpenAIPassthroughAPIKeyRestoresClientToolsStreaming(t *testing.T) {
  246. gin.SetMode(gin.TestMode)
  247. body := openAIClientToolsRequest(true)
  248. recorder := httptest.NewRecorder()
  249. c, _ := gin.CreateTestContext(recorder)
  250. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  251. sse := strings.Join([]string{
  252. `data: {"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"type":"function_call","id":"i1","call_id":"c1","name":"apply_patch","status":"in_progress"}}`,
  253. `data: {"type":"response.function_call_arguments.done","sequence_number":1,"item_id":"i1","call_id":"c1","name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\"}"}`,
  254. `data: {"type":"response.output_item.done","sequence_number":2,"output_index":0,"item":{"type":"function_call","id":"i1","call_id":"c1","name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\"}","status":"completed"}}`,
  255. `data: {"type":"response.completed","sequence_number":3,"response":{"id":"resp_stream_tools","status":"completed","output":[{"type":"function_call","id":"i1","call_id":"c1","name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\"}"}],"usage":{"input_tokens":1,"output_tokens":1}}}`,
  256. }, "\n\n") + "\n\n"
  257. upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(sse))}}
  258. svc := openAIClientToolsTestService(upstream)
  259. account := &Account{ID: 5660, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test-key"}}
  260. result, err := svc.forwardOpenAIPassthrough(context.Background(), c, account, body, body, "gpt-5.4", false, nil, true, time.Now())
  261. require.NoError(t, err)
  262. require.NotNil(t, result)
  263. assertOpenAIClientToolsLowered(t, upstream.lastBody)
  264. output := recorder.Body.String()
  265. require.Contains(t, output, `"type":"custom_tool_call"`)
  266. require.Contains(t, output, `"type":"response.custom_tool_call_input.done"`)
  267. require.Contains(t, output, `"input":"*** Begin Patch"`)
  268. require.NotContains(t, output, `"input":{`)
  269. }