openai_gateway_grok_model_input_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. package service
  2. import (
  3. "net/http"
  4. "strconv"
  5. "testing"
  6. "github.com/stretchr/testify/require"
  7. "github.com/tidwall/gjson"
  8. )
  9. func TestSanitizeGrokResponsesModelInputNormalizesReplayHistory(t *testing.T) {
  10. body := []byte(`{
  11. "input":[
  12. {"role":"system","content":"rules"},
  13. {"type":"message","role":"assistant","id":"msg_partial","content":[{"type":"output_text","text":"done"}]},
  14. {"type":"reasoning","id":"rs_1","status":"completed","content":null,"summary":[{"type":"summary_text","text":"keep"}]},
  15. {"type":"message","role":"assistant","id":"msg_complete","status":"completed","content":[{"type":"output_text","text":"kept"}]}
  16. ]}`)
  17. patched, err := sanitizeGrokResponsesModelInput(body)
  18. require.NoError(t, err)
  19. require.Equal(t, "message", gjson.GetBytes(patched, "input.0.type").String())
  20. require.Equal(t, "done", gjson.GetBytes(patched, "input.1.content").String())
  21. require.False(t, gjson.GetBytes(patched, "input.1.id").Exists())
  22. require.False(t, gjson.GetBytes(patched, "input.2.status").Exists())
  23. require.False(t, gjson.GetBytes(patched, "input.2.content").Exists())
  24. require.Equal(t, "keep", gjson.GetBytes(patched, "input.2.summary.0.text").String())
  25. require.Equal(t, "msg_complete", gjson.GetBytes(patched, "input.3.id").String())
  26. require.Equal(t, "completed", gjson.GetBytes(patched, "input.3.status").String())
  27. }
  28. func TestSanitizeGrokResponsesModelInputStripsOnlyNonPairCallIDs(t *testing.T) {
  29. body := []byte(`{"input":[
  30. {"type":"message","role":"user","call_id":"remove_message","content":"continue"},
  31. {"type":"reasoning","call_id":"remove_reasoning","summary":[]},
  32. {"type":"image_generation_call","call_id":"remove_image","id":"ig_1","status":"completed"},
  33. {"type":"function_call","call_id":"keep_function","name":"lookup","arguments":"{}"},
  34. {"type":"function_call_output","call_id":"keep_function","output":"ok"}
  35. ]}`)
  36. patched, err := sanitizeGrokResponsesModelInput(body)
  37. require.NoError(t, err)
  38. for i := 0; i < 3; i++ {
  39. require.False(t, gjson.GetBytes(patched, "input."+strconv.Itoa(i)+".call_id").Exists())
  40. }
  41. require.Equal(t, "keep_function", gjson.GetBytes(patched, "input.3.call_id").String())
  42. require.Equal(t, "keep_function", gjson.GetBytes(patched, "input.4.call_id").String())
  43. }
  44. func TestSanitizeGrokResponsesModelInputPairsCallsInTwoPasses(t *testing.T) {
  45. body := []byte(`{
  46. "input":[
  47. {"type":"function_call_output","output":{"before":true,"large_id":9007199254740993}},
  48. {"type":"function_call","function":{"name":"lookup","arguments":{"query":"x","large_id":9007199254740993}}},
  49. {"type":"custom_tool_call","id":"custom_1","name":"apply_patch","input":"patch"},
  50. {"type":"custom_tool_call_output","tool_call_id":"custom_1","output":["ok"]},
  51. {"type":"tool_search_call","id":"search_1","arguments":{"query":"repo"}},
  52. {"type":"tool_search_output","call_id":"search_1","results":{"groups":["repo"]}},
  53. {"role":"tool","tool_call_id":"orphan","content":{"value":1}}
  54. ]}`)
  55. patched, err := sanitizeGrokResponsesModelInput(body)
  56. require.NoError(t, err)
  57. generated := gjson.GetBytes(patched, "input.1.call_id").String()
  58. require.NotEmpty(t, generated)
  59. require.Equal(t, generated, gjson.GetBytes(patched, "input.0.call_id").String())
  60. require.JSONEq(t, `{"before":true,"large_id":9007199254740993}`, gjson.GetBytes(patched, "input.0.output").String())
  61. require.Equal(t, "9007199254740993", gjson.Get(gjson.GetBytes(patched, "input.0.output").String(), "large_id").Raw)
  62. require.Equal(t, "lookup", gjson.GetBytes(patched, "input.1.name").String())
  63. require.JSONEq(t, `{"query":"x","large_id":9007199254740993}`, gjson.GetBytes(patched, "input.1.arguments").String())
  64. require.Equal(t, "9007199254740993", gjson.Get(gjson.GetBytes(patched, "input.1.arguments").String(), "large_id").Raw)
  65. customCallID := gjson.GetBytes(patched, "input.2.call_id").String()
  66. require.NotEmpty(t, customCallID)
  67. require.NotEqual(t, "custom_1", customCallID)
  68. require.JSONEq(t, `{"input":"patch"}`, gjson.GetBytes(patched, "input.2.arguments").String())
  69. require.Equal(t, customCallID, gjson.GetBytes(patched, "input.3.call_id").String())
  70. require.JSONEq(t, `["ok"]`, gjson.GetBytes(patched, "input.3.output").String())
  71. require.Equal(t, "tool_search", gjson.GetBytes(patched, "input.4.name").String())
  72. searchCallID := gjson.GetBytes(patched, "input.4.call_id").String()
  73. require.NotEmpty(t, searchCallID)
  74. require.NotEqual(t, "search_1", searchCallID)
  75. require.Equal(t, searchCallID, gjson.GetBytes(patched, "input.5.call_id").String())
  76. require.JSONEq(t, `{"groups":["repo"]}`, gjson.GetBytes(patched, "input.5.output").String())
  77. require.Equal(t, "function_call_output", gjson.GetBytes(patched, "input.6.type").String())
  78. require.Equal(t, "orphan", gjson.GetBytes(patched, "input.6.call_id").String())
  79. }
  80. func TestSanitizeGrokResponsesModelInputMapsAllItemAliasesAndRejectsConflicts(t *testing.T) {
  81. body := []byte(`{"input":[
  82. {"type":"function_call","id":"fc_alias","call_id":"call_canonical","name":"one","arguments":"{}"},
  83. {"type":"function_call_output","id":"fc_alias","output":"ok"},
  84. {"type":"function_call","id":"duplicate_item","name":"two","arguments":"{}"},
  85. {"type":"function_call","id":"duplicate_item","name":"three","arguments":"{}"},
  86. {"type":"function_call_output","id":"duplicate_item","output":"ambiguous"}
  87. ]}`)
  88. patched, err := sanitizeGrokResponsesModelInput(body)
  89. require.NoError(t, err)
  90. require.Equal(t, "call_canonical", gjson.GetBytes(patched, "input.0.call_id").String())
  91. require.Equal(t, "call_canonical", gjson.GetBytes(patched, "input.1.call_id").String())
  92. require.False(t, gjson.GetBytes(patched, "input.0.id").Exists())
  93. secondCallID := gjson.GetBytes(patched, "input.2.call_id").String()
  94. thirdCallID := gjson.GetBytes(patched, "input.3.call_id").String()
  95. ambiguousOutputID := gjson.GetBytes(patched, "input.4.call_id").String()
  96. require.NotEmpty(t, secondCallID)
  97. require.NotEmpty(t, thirdCallID)
  98. require.NotEmpty(t, ambiguousOutputID)
  99. require.NotEqual(t, "duplicate_item", secondCallID)
  100. require.NotEqual(t, secondCallID, thirdCallID)
  101. require.NotEqual(t, secondCallID, ambiguousOutputID)
  102. require.NotEqual(t, thirdCallID, ambiguousOutputID)
  103. }
  104. func TestSanitizeGrokResponsesModelInputPreservesGrokShellOutputImages(t *testing.T) {
  105. body := []byte(`{"input":[
  106. {"type":"function_call","call_id":"call_a","name":"read_file","arguments":"{}"},
  107. {"type":"function_call","call_id":"call_b","name":"read_file","arguments":"{}"},
  108. {"type":"function_call_output","call_id":"call_a","content":"Read image file: /tmp/a.png","images":[{"type":"image","url":"data:image/png;base64,QUE="}]},
  109. {"type":"function_call_output","call_id":"call_b","content":"Read image file: /tmp/b.png","images":[{"type":"image","url":"data:image/jpeg;base64,QkI="}]}
  110. ]}`)
  111. patched, err := sanitizeGrokResponsesModelInput(body)
  112. require.NoError(t, err)
  113. require.Equal(t, "function_call_output", gjson.GetBytes(patched, "input.2.type").String())
  114. require.Equal(t, "Read image file: /tmp/a.png", gjson.GetBytes(patched, "input.2.output").String())
  115. require.False(t, gjson.GetBytes(patched, "input.2.images").Exists())
  116. require.Equal(t, "function_call_output", gjson.GetBytes(patched, "input.3.type").String())
  117. require.Equal(t, "message", gjson.GetBytes(patched, "input.4.type").String())
  118. require.Equal(t, "user", gjson.GetBytes(patched, "input.4.role").String())
  119. require.Equal(t, "[Tool output media for call call_a]", gjson.GetBytes(patched, "input.4.content.0.text").String())
  120. require.Equal(t, "input_image", gjson.GetBytes(patched, "input.4.content.1.type").String())
  121. require.Equal(t, "data:image/png;base64,QUE=", gjson.GetBytes(patched, "input.4.content.1.image_url").String())
  122. require.Equal(t, "[Tool output media for call call_b]", gjson.GetBytes(patched, "input.4.content.2.text").String())
  123. require.Equal(t, "data:image/jpeg;base64,QkI=", gjson.GetBytes(patched, "input.4.content.3.image_url").String())
  124. }
  125. func TestSanitizeGrokResponsesModelInputPreservesGrok105StructuredOutputImages(t *testing.T) {
  126. body := []byte(`{"input":[
  127. {"type":"function_call","call_id":"call_read","name":"read_file","arguments":"{}"},
  128. {"type":"function_call_output","call_id":"call_read","output":[
  129. {"type":"input_text","text":"Read image file: /tmp/example.png"},
  130. {"type":"input_image","detail":"auto","image_url":"data:image/png;base64,QUE="}
  131. ]}
  132. ]}`)
  133. patched, err := sanitizeGrokResponsesModelInput(body)
  134. require.NoError(t, err)
  135. require.Equal(t, "function_call_output", gjson.GetBytes(patched, "input.1.type").String())
  136. require.Equal(t, "Read image file: /tmp/example.png", gjson.GetBytes(patched, "input.1.output").String())
  137. require.NotContains(t, gjson.GetBytes(patched, "input.1.output").String(), "base64")
  138. require.Equal(t, "message", gjson.GetBytes(patched, "input.2.type").String())
  139. require.Equal(t, "input_image", gjson.GetBytes(patched, "input.2.content.1.type").String())
  140. require.Equal(t, "data:image/png;base64,QUE=", gjson.GetBytes(patched, "input.2.content.1.image_url").String())
  141. }
  142. func TestSanitizeGrokResponsesModelInputSkipsInvalidOutputImages(t *testing.T) {
  143. body := []byte(`{"input":[
  144. {"type":"function_call_output","call_id":"call_empty","output":"done","images":[{"type":"image","url":"data:image/png;base64,"},{}]},
  145. {"role":"user","content":"continue"}
  146. ]}`)
  147. patched, err := sanitizeGrokResponsesModelInput(body)
  148. require.NoError(t, err)
  149. require.Len(t, gjson.GetBytes(patched, "input").Array(), 2)
  150. require.Equal(t, "function_call_output", gjson.GetBytes(patched, "input.0.type").String())
  151. require.Equal(t, "message", gjson.GetBytes(patched, "input.1.type").String())
  152. }
  153. func TestPatchGrokResponsesBodyCombinesAdditionalToolsAndDropsOrphanControls(t *testing.T) {
  154. body := []byte(`{
  155. "input":[{"type":"additional_tools","tools":[{"type":"function","name":"lookup"}]},{"role":"user","content":"hi"}],
  156. "tools":[{"type":"function","name":"lookup"}],
  157. "tool_choice":"auto","parallel_tool_calls":true
  158. }`)
  159. patched, err := patchGrokResponsesBody(body, "grok-4.5")
  160. require.NoError(t, err)
  161. require.Equal(t, int64(1), gjson.GetBytes(patched, "tools.#").Int())
  162. require.Equal(t, "lookup", gjson.GetBytes(patched, "tools.0.name").String())
  163. require.Equal(t, "message", gjson.GetBytes(patched, "input.0.type").String())
  164. withoutTools, err := patchGrokResponsesBody([]byte(`{"input":"hi","tool_choice":"auto","parallel_tool_calls":true}`), "grok-4.5")
  165. require.NoError(t, err)
  166. require.False(t, gjson.GetBytes(withoutTools, "tool_choice").Exists())
  167. require.False(t, gjson.GetBytes(withoutTools, "parallel_tool_calls").Exists())
  168. for _, malformedTools := range []string{"null", `{}`, `"invalid"`} {
  169. malformedBody := []byte(`{"input":"hi","tools":` + malformedTools + `,"tool_choice":"auto","parallel_tool_calls":true}`)
  170. patched, err := patchGrokResponsesBody(malformedBody, "grok-4.5")
  171. require.NoError(t, err)
  172. require.False(t, gjson.GetBytes(patched, "tool_choice").Exists(), string(patched))
  173. require.False(t, gjson.GetBytes(patched, "parallel_tool_calls").Exists(), string(patched))
  174. }
  175. }
  176. func TestSanitizeGrokCompactionReplayBodyPreservesVisibleSummary(t *testing.T) {
  177. body := []byte(`{
  178. "previous_response_id":"resp_stale",
  179. "input":[{"type":"compaction","encrypted_content":"opaque","summary":[{"type":"summary_text","text":"visible history"}]},{"type":"message","role":"user","content":"continue"}]
  180. }`)
  181. patched, changed, err := sanitizeGrokCompactionReplayBody(body)
  182. require.NoError(t, err)
  183. require.True(t, changed)
  184. require.False(t, gjson.GetBytes(patched, "previous_response_id").Exists())
  185. require.False(t, gjson.GetBytes(patched, `input.#(type=="reasoning")`).Exists())
  186. require.Contains(t, gjson.GetBytes(patched, "input.0.content.0.text").String(), "visible history")
  187. require.Equal(t, "continue", gjson.GetBytes(patched, "input.1.content").String())
  188. require.True(t, isGrokCompactionReplayDecodeError(http.StatusBadRequest, []byte(`{"error":{"message":"failed to deserialize compaction summary history"}}`)))
  189. require.True(t, isGrokCompactionReplayDecodeError(http.StatusBadRequest, []byte(`{"error":{"type":"invalid_request_error"},"detail":"could not decode compaction blob"}`)))
  190. }
  191. func TestGrokStructuredErrorCandidatesDoNotShadowTopLevelMessages(t *testing.T) {
  192. shadowedInvalidEncrypted := []byte(`{"code":"invalid-argument","error":{"type":"invalid_request_error"},"message":"Could not decrypt encrypted_content because it was modified"}`)
  193. require.True(t, isGrokInvalidEncryptedContentResponse(http.StatusBadRequest, shadowedInvalidEncrypted))
  194. require.True(t, isGrokCompactionReplayDecodeError(http.StatusBadRequest, []byte(`{"error":{"type":"invalid_request_error"},"message":"could not decode compaction history"}`)))
  195. }
  196. func TestGrokDecoderCompatibility422FailsOverWithoutCooldown(t *testing.T) {
  197. body := []byte(`{"detail":"data did not match any variant of untagged enum ModelInput at input[3]"}`)
  198. require.True(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, body))
  199. require.True(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"message":"could not decode ModelInput at input.3"}`)))
  200. require.True(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"error":{"type":"invalid_request_error"},"message":"could not deserialize ModelInput at input[3]"}`)))
  201. require.True(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"error":"Failed to deserialize the JSON body into the target type: messages[1]: data did not match any variant of untagged enum Content at line 1 column 6577"}`)))
  202. require.True(t, (&OpenAIGatewayService{}).shouldFailoverGrokUpstreamError(http.StatusUnprocessableEntity, body))
  203. decision := classifyGrokUpstreamFailure(http.StatusUnprocessableEntity, body, "grok-4.5")
  204. require.False(t, decision.ShouldCooldown)
  205. require.Equal(t, GrokFailureNone, decision.Class)
  206. require.False(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"error":{"message":"invalid user parameter"}}`)))
  207. require.False(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"error":"messages[1].content is required"}`)))
  208. require.False(t, isGrokDecoderCompatibilityError(http.StatusBadRequest, body))
  209. }