openai_gateway_passthrough_function_args_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. package service
  2. import (
  3. "bufio"
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "io"
  8. "net/http"
  9. "net/http/httptest"
  10. "strconv"
  11. "strings"
  12. "testing"
  13. "time"
  14. "github.com/Wei-Shaw/sub2api/internal/config"
  15. "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
  16. "github.com/gin-gonic/gin"
  17. "github.com/stretchr/testify/require"
  18. "github.com/tidwall/gjson"
  19. )
  20. func TestHandleStreamingResponsePassthroughDeduplicatesFunctionCallArguments(t *testing.T) {
  21. gin.SetMode(gin.TestMode)
  22. argsA := `{"cmd":"echo hi","meta":{"nested":[1,{"ok":true}],"quote":"a}b"}}`
  23. argsB := `{"path":"/tmp/file","patch":{"ops":[{"op":"replace","value":{"lines":["x","y"]}}]}}`
  24. upstreamBody := strings.Join([]string{
  25. passthroughSSEData(`{"type":"response.created","response":{"id":"resp_passthrough_args","model":"gpt-5.4"}}`),
  26. passthroughSSEData(`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_a","call_id":"call_a","name":"exec_command","arguments":"","status":"in_progress"}}`),
  27. passthroughSSEData(functionArgsDeltaJSON(0, "fc_a", "call_a", "exec_command", `{"cmd":`)),
  28. passthroughSSEData(functionArgsDeltaJSON(0, "fc_a", "call_a", "exec_command", `"echo hi","meta":{"nested":[1,{"ok":true}],"quote":"a}b"}}`)),
  29. passthroughSSEData(functionArgsDoneJSON(0, "fc_a", "call_a", "exec_command", argsA+argsA)),
  30. passthroughSSEData(outputItemDoneJSON(0, "fc_a", "call_a", "exec_command", argsA+argsA)),
  31. passthroughSSEData(`{"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","id":"fc_b","call_id":"call_b","name":"apply_patch","arguments":"","status":"in_progress"}}`),
  32. passthroughSSEData(functionArgsDeltaJSON(1, "fc_b", "call_b", "apply_patch", `{"path":"/tmp/file",`)),
  33. passthroughSSEData(functionArgsDeltaJSON(1, "fc_b", "call_b", "apply_patch", `"patch":{"ops":[{"op":"replace","value":{"lines":["x","y"]}}]}}`)),
  34. passthroughSSEData(functionArgsDoneJSON(1, "fc_b", "call_b", "apply_patch", argsB+argsB)),
  35. passthroughSSEData(outputItemDoneJSON(1, "fc_b", "call_b", "apply_patch", argsB+argsB)),
  36. passthroughSSEData(completedWithFunctionCallsJSON(argsA+argsA, argsB+argsB)),
  37. "data: [DONE]\n\n",
  38. }, "")
  39. rec := httptest.NewRecorder()
  40. c, _ := gin.CreateTestContext(rec)
  41. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  42. resp := &http.Response{
  43. StatusCode: http.StatusOK,
  44. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  45. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  46. }
  47. svc := &OpenAIGatewayService{}
  48. result, err := svc.handleStreamingResponsePassthrough(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "gpt-5.4", "gpt-5.4")
  49. require.NoError(t, err)
  50. require.NotNil(t, result)
  51. events := collectSSEDataPayloads(t, rec.Body.String())
  52. require.Equal(t, argsA, accumulateFunctionArgumentDeltas(events, "call_a"))
  53. require.Equal(t, argsB, accumulateFunctionArgumentDeltas(events, "call_b"))
  54. require.Equal(t, argsA, gjson.Get(findSSEEvent(t, events, "response.function_call_arguments.done", "call_a"), "arguments").String())
  55. require.Equal(t, argsB, gjson.Get(findSSEEvent(t, events, "response.function_call_arguments.done", "call_b"), "arguments").String())
  56. require.Equal(t, argsA, gjson.Get(findSSEEvent(t, events, "response.output_item.done", "call_a"), "item.arguments").String())
  57. require.Equal(t, argsB, gjson.Get(findSSEEvent(t, events, "response.output_item.done", "call_b"), "item.arguments").String())
  58. completed := findSSEEvent(t, events, "response.completed", "")
  59. require.Equal(t, argsA, gjson.Get(completed, "response.output.0.arguments").String())
  60. require.Equal(t, argsB, gjson.Get(completed, "response.output.1.arguments").String())
  61. requireJSONArgument(t, gjson.Get(completed, "response.output.0.arguments").String())
  62. requireJSONArgument(t, gjson.Get(completed, "response.output.1.arguments").String())
  63. }
  64. func TestForwardResponsesChatCompletionsFallbackKeepsFunctionArgumentsSingle(t *testing.T) {
  65. gin.SetMode(gin.TestMode)
  66. body := []byte(`{"model":"gpt-5.4","input":"run a command","stream":true}`)
  67. rec := httptest.NewRecorder()
  68. c, _ := gin.CreateTestContext(rec)
  69. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(string(body)))
  70. c.Request.Header.Set("Content-Type", "application/json")
  71. upstreamBody := strings.Join([]string{
  72. passthroughSSEData(chatToolCallChunkJSON(true, "")),
  73. "",
  74. passthroughSSEData(chatToolCallChunkJSON(false, `{"cmd":"echo hi"}`)),
  75. "",
  76. `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}`,
  77. "",
  78. "data: [DONE]",
  79. "",
  80. }, "\n")
  81. upstream := &httpUpstreamRecorder{resp: &http.Response{
  82. StatusCode: http.StatusOK,
  83. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_fallback_tool_args"}},
  84. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  85. }}
  86. account := passthroughArgsFallbackAccount()
  87. account.Extra = map[string]any{
  88. openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions),
  89. }
  90. svc := &OpenAIGatewayService{
  91. cfg: passthroughArgsTestConfig(),
  92. httpUpstream: upstream,
  93. }
  94. result, err := svc.Forward(context.Background(), c, account, body)
  95. require.NoError(t, err)
  96. require.NotNil(t, result)
  97. const wantArgs = `{"cmd":"echo hi"}`
  98. events := collectSSEDataPayloads(t, rec.Body.String())
  99. require.Equal(t, wantArgs, accumulateFunctionArgumentDeltas(events, "chatcmpl-tool-a"))
  100. require.Equal(t, wantArgs, gjson.Get(findSSEEvent(t, events, "response.function_call_arguments.done", "chatcmpl-tool-a"), "arguments").String())
  101. require.Equal(t, wantArgs, gjson.Get(findSSEEvent(t, events, "response.output_item.done", "chatcmpl-tool-a"), "item.arguments").String())
  102. }
  103. func passthroughSSEData(payload string) string {
  104. return "data: " + payload + "\n\n"
  105. }
  106. func functionArgsDeltaJSON(outputIndex int, itemID, callID, name, delta string) string {
  107. return fmt.Sprintf(
  108. `{"type":"response.function_call_arguments.delta","output_index":%d,"item_id":%s,"call_id":%s,"name":%s,"delta":%s}`,
  109. outputIndex,
  110. strconv.Quote(itemID),
  111. strconv.Quote(callID),
  112. strconv.Quote(name),
  113. strconv.Quote(delta),
  114. )
  115. }
  116. func functionArgsDoneJSON(outputIndex int, itemID, callID, name, arguments string) string {
  117. return fmt.Sprintf(
  118. `{"type":"response.function_call_arguments.done","output_index":%d,"item_id":%s,"call_id":%s,"name":%s,"arguments":%s}`,
  119. outputIndex,
  120. strconv.Quote(itemID),
  121. strconv.Quote(callID),
  122. strconv.Quote(name),
  123. strconv.Quote(arguments),
  124. )
  125. }
  126. func outputItemDoneJSON(outputIndex int, itemID, callID, name, arguments string) string {
  127. return fmt.Sprintf(
  128. `{"type":"response.output_item.done","output_index":%d,"item":{"type":"function_call","id":%s,"call_id":%s,"name":%s,"arguments":%s,"status":"completed"}}`,
  129. outputIndex,
  130. strconv.Quote(itemID),
  131. strconv.Quote(callID),
  132. strconv.Quote(name),
  133. strconv.Quote(arguments),
  134. )
  135. }
  136. func completedWithFunctionCallsJSON(argsA, argsB string) string {
  137. return fmt.Sprintf(
  138. `{"type":"response.completed","response":{"id":"resp_passthrough_args","status":"completed","output":[{"type":"function_call","id":"fc_a","call_id":"call_a","name":"exec_command","arguments":%s,"status":"completed"},{"type":"function_call","id":"fc_b","call_id":"call_b","name":"apply_patch","arguments":%s,"status":"completed"}],"usage":{"input_tokens":2,"output_tokens":3,"total_tokens":5}}}`,
  139. strconv.Quote(argsA),
  140. strconv.Quote(argsB),
  141. )
  142. }
  143. func chatToolCallChunkJSON(includeIdentity bool, arguments string) string {
  144. identity := ""
  145. functionFields := make([]string, 0, 2)
  146. if includeIdentity {
  147. identity = `"id":"chatcmpl-tool-a","type":"function",`
  148. functionFields = append(functionFields, `"name":"exec_command"`)
  149. }
  150. if includeIdentity || arguments != "" {
  151. functionFields = append(functionFields, `"arguments":`+strconv.Quote(arguments))
  152. }
  153. return fmt.Sprintf(
  154. `{"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,%s"function":{%s}}]},"finish_reason":null}]}`,
  155. identity,
  156. strings.Join(functionFields, ","),
  157. )
  158. }
  159. func passthroughArgsTestConfig() *config.Config {
  160. return &config.Config{
  161. Security: config.SecurityConfig{
  162. URLAllowlist: config.URLAllowlistConfig{
  163. Enabled: false,
  164. AllowInsecureHTTP: true,
  165. },
  166. },
  167. }
  168. }
  169. func passthroughArgsFallbackAccount() *Account {
  170. return &Account{
  171. ID: 102,
  172. Name: "passthrough-args-openai-apikey",
  173. Platform: PlatformOpenAI,
  174. Type: AccountTypeAPIKey,
  175. Concurrency: 1,
  176. Credentials: map[string]any{
  177. "api_key": "sk-test",
  178. "base_url": "http://upstream.example",
  179. },
  180. }
  181. }
  182. func collectSSEDataPayloads(t *testing.T, body string) []string {
  183. t.Helper()
  184. scanner := bufio.NewScanner(strings.NewReader(body))
  185. var events []string
  186. for scanner.Scan() {
  187. data, ok := extractOpenAISSEDataLine(scanner.Text())
  188. if !ok {
  189. continue
  190. }
  191. if strings.TrimSpace(data) == "[DONE]" {
  192. continue
  193. }
  194. require.True(t, gjson.Valid(data), "invalid SSE data payload: %s", data)
  195. events = append(events, data)
  196. }
  197. require.NoError(t, scanner.Err())
  198. return events
  199. }
  200. func findSSEEvent(t *testing.T, events []string, eventType, callID string) string {
  201. t.Helper()
  202. for _, event := range events {
  203. if gjson.Get(event, "type").String() != eventType {
  204. continue
  205. }
  206. if callID == "" ||
  207. gjson.Get(event, "call_id").String() == callID ||
  208. gjson.Get(event, "item.call_id").String() == callID {
  209. return event
  210. }
  211. }
  212. t.Fatalf("missing event type=%s call_id=%s in %d events", eventType, callID, len(events))
  213. return ""
  214. }
  215. func accumulateFunctionArgumentDeltas(events []string, callID string) string {
  216. var b strings.Builder
  217. for _, event := range events {
  218. if gjson.Get(event, "type").String() != "response.function_call_arguments.delta" {
  219. continue
  220. }
  221. if gjson.Get(event, "call_id").String() != callID {
  222. continue
  223. }
  224. _, _ = b.WriteString(gjson.Get(event, "delta").String())
  225. }
  226. return b.String()
  227. }
  228. func requireJSONArgument(t *testing.T, arguments string) {
  229. t.Helper()
  230. var decoded any
  231. require.NoError(t, json.Unmarshal([]byte(arguments), &decoded))
  232. }