openai_responses_input_compat_test.go 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "net/http"
  7. "strings"
  8. "testing"
  9. "github.com/stretchr/testify/require"
  10. "github.com/tidwall/gjson"
  11. )
  12. func TestSanitizeOpenAIResponsesOrphanToolOutputs(t *testing.T) {
  13. t.Run("named standalone inputs do not legitimize orphan results", func(t *testing.T) {
  14. named := map[string]any{"type": "function_call_output", "name": "send_message_to_thread", "namespace": "codex_app", "output": "delegation"}
  15. input := []any{
  16. named,
  17. map[string]any{"type": "function_call_output", "output": "missing name and call id"},
  18. map[string]any{"type": "function_call_output", "name": " ", "output": "blank name"},
  19. map[string]any{"type": "function_call_output", "name": "send_message_to_thread", "call_id": "missing", "output": "orphan result"},
  20. map[string]any{"type": "custom_tool_call_output", "name": "apply_patch", "output": "missing call id"},
  21. }
  22. reqBody := map[string]any{"input": input}
  23. require.True(t, sanitizeOpenAIResponsesOrphanToolOutputs(reqBody, input, false))
  24. require.Equal(t, []any{named}, reqBody["input"])
  25. })
  26. t.Run("preserves matches regardless of item order", func(t *testing.T) {
  27. input := []any{
  28. map[string]any{"type": "tool_search_output", "call_id": "search_1", "output": "first"},
  29. map[string]any{"type": "tool_search_call", "id": "search_1", "query": "docs"},
  30. map[string]any{"type": "custom_tool_call_output", "call_id": "custom_1", "output": "second"},
  31. map[string]any{"type": "item_reference", "id": "custom_1"},
  32. }
  33. reqBody := map[string]any{"input": input}
  34. require.False(t, sanitizeOpenAIResponsesOrphanToolOutputs(reqBody, input, false))
  35. require.Equal(t, input, reqBody["input"])
  36. })
  37. t.Run("outputs do not legitimize each other", func(t *testing.T) {
  38. input := []any{
  39. map[string]any{"type": "function_call_output", "call_id": "missing", "output": "one"},
  40. map[string]any{"type": "tool_search_output", "call_id": "missing", "output": "two"},
  41. map[string]any{"type": "custom_tool_call_output", "call_id": "missing", "output": "three"},
  42. map[string]any{"type": "mcp_tool_call_output", "call_id": "missing", "output": "four"},
  43. }
  44. reqBody := map[string]any{"input": input}
  45. require.True(t, sanitizeOpenAIResponsesOrphanToolOutputs(reqBody, input, false))
  46. got, ok := reqBody["input"].([]any)
  47. require.True(t, ok)
  48. require.Empty(t, got)
  49. })
  50. t.Run("preserves all output variants with matching calls", func(t *testing.T) {
  51. pairs := []struct {
  52. callType string
  53. outputType string
  54. }{
  55. {callType: "function_call", outputType: "function_call_output"},
  56. {callType: "tool_search_call", outputType: "tool_search_output"},
  57. {callType: "custom_tool_call", outputType: "custom_tool_call_output"},
  58. {callType: "mcp_tool_call", outputType: "mcp_tool_call_output"},
  59. }
  60. input := make([]any, 0, len(pairs)*2)
  61. for index, pair := range pairs {
  62. callID := string(rune('a' + index))
  63. input = append(input,
  64. map[string]any{"type": pair.callType, "call_id": callID},
  65. map[string]any{"type": pair.outputType, "call_id": callID, "output": "ok"},
  66. )
  67. }
  68. reqBody := map[string]any{"input": input}
  69. require.False(t, sanitizeOpenAIResponsesOrphanToolOutputs(reqBody, input, false))
  70. })
  71. t.Run("previous response may contain the missing call", func(t *testing.T) {
  72. input := []any{map[string]any{"type": "function_call_output", "call_id": "remote", "output": "ok"}}
  73. reqBody := map[string]any{"input": input, "previous_response_id": "resp_1"}
  74. require.False(t, sanitizeOpenAIResponsesOrphanToolOutputs(reqBody, input, true))
  75. require.Equal(t, input, reqBody["input"])
  76. })
  77. }
  78. func TestWebSocketCompatibilityPreservesNamedDelegation(t *testing.T) {
  79. for _, toolName := range []string{"create_thread", "send_message_to_thread"} {
  80. for _, withHistory := range []bool{false, true} {
  81. for _, previousResponseID := range []string{"", "resp_previous"} {
  82. name := fmt.Sprintf("%s/history=%t/previous=%t", toolName, withHistory, previousResponseID != "")
  83. t.Run(name, func(t *testing.T) {
  84. input := []any{}
  85. if withHistory {
  86. input = append(input,
  87. map[string]any{"type": "function_call", "call_id": "fc_history", "name": "lookup", "arguments": "{}"},
  88. map[string]any{"type": "function_call_output", "call_id": "fc_history", "output": "historical result"},
  89. )
  90. }
  91. input = append(input,
  92. map[string]any{"type": "message", "role": "user", "content": []any{map[string]any{"type": "input_text", "text": "Updated environment context."}}},
  93. map[string]any{"type": "function_call_output", "name": toolName, "namespace": "codex_app", "output": "<codex_delegation>\n <source_thread_id>source-task</source_thread_id>\n <input>Reply with DELEGATION_OK.</input>\n</codex_delegation>"},
  94. )
  95. reqBody := map[string]any{"type": "response.create", "model": "gpt-5.5", "input": input}
  96. if previousResponseID != "" {
  97. reqBody["previous_response_id"] = previousResponseID
  98. }
  99. body, err := json.Marshal(reqBody)
  100. require.NoError(t, err)
  101. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  102. normalized, _, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account, false)
  103. require.NoError(t, err)
  104. var got map[string]any
  105. require.NoError(t, json.Unmarshal(normalized, &got))
  106. require.Equal(t, input, got["input"], "preserve the delegation envelope, native type and history in order")
  107. require.Equal(t, reqBody["previous_response_id"], got["previous_response_id"])
  108. again, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, account, false)
  109. require.NoError(t, err)
  110. require.False(t, changed, "normalization must be idempotent")
  111. require.JSONEq(t, string(normalized), string(again))
  112. })
  113. }
  114. }
  115. }
  116. }
  117. func TestOpenAIResponsesInputTextIsNeverSilentlyTruncated(t *testing.T) {
  118. atLimit := strings.Repeat("z", openAIResponsesInputTextMaxChars)
  119. oversized := strings.Repeat("a", openAIResponsesInputTextMaxChars) + "中"
  120. input := []any{
  121. map[string]any{"type": "function_call_output", "call_id": "limit", "output": atLimit},
  122. map[string]any{"type": "function_call_output", "call_id": "a", "output": oversized},
  123. map[string]any{"type": "tool_search_output", "call_id": "b", "output": oversized},
  124. map[string]any{"type": "custom_tool_call_output", "call_id": "c", "output": oversized},
  125. map[string]any{"type": "mcp_tool_call_output", "call_id": "d", "output": oversized},
  126. map[string]any{
  127. "role": "user",
  128. "content": []any{
  129. map[string]any{"type": "input_text", "text": "short"},
  130. map[string]any{"type": "input_text", "text": oversized},
  131. },
  132. },
  133. }
  134. reqBody := map[string]any{"input": input}
  135. require.False(t, truncateOpenAIResponsesInputText(reqBody))
  136. first, ok := input[0].(map[string]any)
  137. require.True(t, ok)
  138. require.Equal(t, atLimit, first["output"])
  139. for _, rawItem := range input[1:5] {
  140. item, ok := rawItem.(map[string]any)
  141. require.True(t, ok)
  142. require.Equal(t, oversized, item["output"])
  143. }
  144. last, ok := input[5].(map[string]any)
  145. require.True(t, ok)
  146. content, ok := last["content"].([]any)
  147. require.True(t, ok)
  148. require.Len(t, content, 2)
  149. shortPart, ok := content[0].(map[string]any)
  150. require.True(t, ok)
  151. oversizedPart, ok := content[1].(map[string]any)
  152. require.True(t, ok)
  153. require.Equal(t, "short", shortPart["text"])
  154. require.Equal(t, oversized, oversizedPart["text"])
  155. }
  156. func TestOpenAIResponsesInputNeverRequestsPreemptiveTruncation(t *testing.T) {
  157. short := []byte(`{"input":[{"type":"function_call_output","output":"ok"}]}`)
  158. largeUnrelated := []byte(`{"input":"` + strings.Repeat("x", openAIResponsesInputTextMaxChars+1) + `"}`)
  159. largeOutput := []byte(`{"input":[{"type":"function_call_output","output":"` + strings.Repeat("x", openAIResponsesInputTextMaxChars+1) + `"}]}`)
  160. require.False(t, openAIResponsesInputMayNeedTruncation(short))
  161. require.False(t, openAIResponsesInputMayNeedTruncation(largeUnrelated))
  162. require.False(t, openAIResponsesInputMayNeedTruncation(largeOutput))
  163. }
  164. func TestOpenAIGatewayService_OAuthDropsOrphanAfterDroppingPreviousResponse(t *testing.T) {
  165. body := []byte(`{"model":"gpt-5.5","stream":false,"previous_response_id":"resp_missing","input":[{"type":"function_call_output","call_id":"call_missing","output":"keep this result"}]}`)
  166. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  167. newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"id":"resp_ok","output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
  168. }}
  169. result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
  170. context.Background(),
  171. newOpenAIRejectedFieldTestContext(body),
  172. newOpenAIOAuthNamespaceTestAccount(),
  173. body,
  174. )
  175. require.NoError(t, err)
  176. require.NotNil(t, result)
  177. require.Len(t, upstream.bodies, 1)
  178. require.False(t, gjson.GetBytes(upstream.bodies[0], "previous_response_id").Exists())
  179. require.Empty(t, gjson.GetBytes(upstream.bodies[0], "input").Array())
  180. }
  181. func TestOpenAIGatewayService_PreservesOversizedToolOutputForUpstream(t *testing.T) {
  182. oversized := strings.Repeat("x", openAIResponsesInputTextMaxChars) + "中"
  183. body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"call_1","output":"` + oversized + `"}]}`)
  184. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  185. newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"id":"resp_ok","output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
  186. }}
  187. result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
  188. context.Background(),
  189. newOpenAIRejectedFieldTestContext(body),
  190. newOpenAIRejectedFieldTestAccount(),
  191. body,
  192. )
  193. require.NoError(t, err)
  194. require.NotNil(t, result)
  195. require.Len(t, upstream.bodies, 1)
  196. require.Equal(t, oversized, gjson.GetBytes(upstream.bodies[0], "input.1.output").String())
  197. }