| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232 |
- package service
- import (
- "net/http"
- "strconv"
- "testing"
- "github.com/stretchr/testify/require"
- "github.com/tidwall/gjson"
- )
- func TestSanitizeGrokResponsesModelInputNormalizesReplayHistory(t *testing.T) {
- body := []byte(`{
- "input":[
- {"role":"system","content":"rules"},
- {"type":"message","role":"assistant","id":"msg_partial","content":[{"type":"output_text","text":"done"}]},
- {"type":"reasoning","id":"rs_1","status":"completed","content":null,"summary":[{"type":"summary_text","text":"keep"}]},
- {"type":"message","role":"assistant","id":"msg_complete","status":"completed","content":[{"type":"output_text","text":"kept"}]}
- ]}`)
- patched, err := sanitizeGrokResponsesModelInput(body)
- require.NoError(t, err)
- require.Equal(t, "message", gjson.GetBytes(patched, "input.0.type").String())
- require.Equal(t, "done", gjson.GetBytes(patched, "input.1.content").String())
- require.False(t, gjson.GetBytes(patched, "input.1.id").Exists())
- require.False(t, gjson.GetBytes(patched, "input.2.status").Exists())
- require.False(t, gjson.GetBytes(patched, "input.2.content").Exists())
- require.Equal(t, "keep", gjson.GetBytes(patched, "input.2.summary.0.text").String())
- require.Equal(t, "msg_complete", gjson.GetBytes(patched, "input.3.id").String())
- require.Equal(t, "completed", gjson.GetBytes(patched, "input.3.status").String())
- }
- func TestSanitizeGrokResponsesModelInputStripsOnlyNonPairCallIDs(t *testing.T) {
- body := []byte(`{"input":[
- {"type":"message","role":"user","call_id":"remove_message","content":"continue"},
- {"type":"reasoning","call_id":"remove_reasoning","summary":[]},
- {"type":"image_generation_call","call_id":"remove_image","id":"ig_1","status":"completed"},
- {"type":"function_call","call_id":"keep_function","name":"lookup","arguments":"{}"},
- {"type":"function_call_output","call_id":"keep_function","output":"ok"}
- ]}`)
- patched, err := sanitizeGrokResponsesModelInput(body)
- require.NoError(t, err)
- for i := 0; i < 3; i++ {
- require.False(t, gjson.GetBytes(patched, "input."+strconv.Itoa(i)+".call_id").Exists())
- }
- require.Equal(t, "keep_function", gjson.GetBytes(patched, "input.3.call_id").String())
- require.Equal(t, "keep_function", gjson.GetBytes(patched, "input.4.call_id").String())
- }
- func TestSanitizeGrokResponsesModelInputPairsCallsInTwoPasses(t *testing.T) {
- body := []byte(`{
- "input":[
- {"type":"function_call_output","output":{"before":true,"large_id":9007199254740993}},
- {"type":"function_call","function":{"name":"lookup","arguments":{"query":"x","large_id":9007199254740993}}},
- {"type":"custom_tool_call","id":"custom_1","name":"apply_patch","input":"patch"},
- {"type":"custom_tool_call_output","tool_call_id":"custom_1","output":["ok"]},
- {"type":"tool_search_call","id":"search_1","arguments":{"query":"repo"}},
- {"type":"tool_search_output","call_id":"search_1","results":{"groups":["repo"]}},
- {"role":"tool","tool_call_id":"orphan","content":{"value":1}}
- ]}`)
- patched, err := sanitizeGrokResponsesModelInput(body)
- require.NoError(t, err)
- generated := gjson.GetBytes(patched, "input.1.call_id").String()
- require.NotEmpty(t, generated)
- require.Equal(t, generated, gjson.GetBytes(patched, "input.0.call_id").String())
- require.JSONEq(t, `{"before":true,"large_id":9007199254740993}`, gjson.GetBytes(patched, "input.0.output").String())
- require.Equal(t, "9007199254740993", gjson.Get(gjson.GetBytes(patched, "input.0.output").String(), "large_id").Raw)
- require.Equal(t, "lookup", gjson.GetBytes(patched, "input.1.name").String())
- require.JSONEq(t, `{"query":"x","large_id":9007199254740993}`, gjson.GetBytes(patched, "input.1.arguments").String())
- require.Equal(t, "9007199254740993", gjson.Get(gjson.GetBytes(patched, "input.1.arguments").String(), "large_id").Raw)
- customCallID := gjson.GetBytes(patched, "input.2.call_id").String()
- require.NotEmpty(t, customCallID)
- require.NotEqual(t, "custom_1", customCallID)
- require.JSONEq(t, `{"input":"patch"}`, gjson.GetBytes(patched, "input.2.arguments").String())
- require.Equal(t, customCallID, gjson.GetBytes(patched, "input.3.call_id").String())
- require.JSONEq(t, `["ok"]`, gjson.GetBytes(patched, "input.3.output").String())
- require.Equal(t, "tool_search", gjson.GetBytes(patched, "input.4.name").String())
- searchCallID := gjson.GetBytes(patched, "input.4.call_id").String()
- require.NotEmpty(t, searchCallID)
- require.NotEqual(t, "search_1", searchCallID)
- require.Equal(t, searchCallID, gjson.GetBytes(patched, "input.5.call_id").String())
- require.JSONEq(t, `{"groups":["repo"]}`, gjson.GetBytes(patched, "input.5.output").String())
- require.Equal(t, "function_call_output", gjson.GetBytes(patched, "input.6.type").String())
- require.Equal(t, "orphan", gjson.GetBytes(patched, "input.6.call_id").String())
- }
- func TestSanitizeGrokResponsesModelInputMapsAllItemAliasesAndRejectsConflicts(t *testing.T) {
- body := []byte(`{"input":[
- {"type":"function_call","id":"fc_alias","call_id":"call_canonical","name":"one","arguments":"{}"},
- {"type":"function_call_output","id":"fc_alias","output":"ok"},
- {"type":"function_call","id":"duplicate_item","name":"two","arguments":"{}"},
- {"type":"function_call","id":"duplicate_item","name":"three","arguments":"{}"},
- {"type":"function_call_output","id":"duplicate_item","output":"ambiguous"}
- ]}`)
- patched, err := sanitizeGrokResponsesModelInput(body)
- require.NoError(t, err)
- require.Equal(t, "call_canonical", gjson.GetBytes(patched, "input.0.call_id").String())
- require.Equal(t, "call_canonical", gjson.GetBytes(patched, "input.1.call_id").String())
- require.False(t, gjson.GetBytes(patched, "input.0.id").Exists())
- secondCallID := gjson.GetBytes(patched, "input.2.call_id").String()
- thirdCallID := gjson.GetBytes(patched, "input.3.call_id").String()
- ambiguousOutputID := gjson.GetBytes(patched, "input.4.call_id").String()
- require.NotEmpty(t, secondCallID)
- require.NotEmpty(t, thirdCallID)
- require.NotEmpty(t, ambiguousOutputID)
- require.NotEqual(t, "duplicate_item", secondCallID)
- require.NotEqual(t, secondCallID, thirdCallID)
- require.NotEqual(t, secondCallID, ambiguousOutputID)
- require.NotEqual(t, thirdCallID, ambiguousOutputID)
- }
- func TestSanitizeGrokResponsesModelInputPreservesGrokShellOutputImages(t *testing.T) {
- body := []byte(`{"input":[
- {"type":"function_call","call_id":"call_a","name":"read_file","arguments":"{}"},
- {"type":"function_call","call_id":"call_b","name":"read_file","arguments":"{}"},
- {"type":"function_call_output","call_id":"call_a","content":"Read image file: /tmp/a.png","images":[{"type":"image","url":"data:image/png;base64,QUE="}]},
- {"type":"function_call_output","call_id":"call_b","content":"Read image file: /tmp/b.png","images":[{"type":"image","url":"data:image/jpeg;base64,QkI="}]}
- ]}`)
- patched, err := sanitizeGrokResponsesModelInput(body)
- require.NoError(t, err)
- require.Equal(t, "function_call_output", gjson.GetBytes(patched, "input.2.type").String())
- require.Equal(t, "Read image file: /tmp/a.png", gjson.GetBytes(patched, "input.2.output").String())
- require.False(t, gjson.GetBytes(patched, "input.2.images").Exists())
- require.Equal(t, "function_call_output", gjson.GetBytes(patched, "input.3.type").String())
- require.Equal(t, "message", gjson.GetBytes(patched, "input.4.type").String())
- require.Equal(t, "user", gjson.GetBytes(patched, "input.4.role").String())
- require.Equal(t, "[Tool output media for call call_a]", gjson.GetBytes(patched, "input.4.content.0.text").String())
- require.Equal(t, "input_image", gjson.GetBytes(patched, "input.4.content.1.type").String())
- require.Equal(t, "data:image/png;base64,QUE=", gjson.GetBytes(patched, "input.4.content.1.image_url").String())
- require.Equal(t, "[Tool output media for call call_b]", gjson.GetBytes(patched, "input.4.content.2.text").String())
- require.Equal(t, "data:image/jpeg;base64,QkI=", gjson.GetBytes(patched, "input.4.content.3.image_url").String())
- }
- func TestSanitizeGrokResponsesModelInputPreservesGrok105StructuredOutputImages(t *testing.T) {
- body := []byte(`{"input":[
- {"type":"function_call","call_id":"call_read","name":"read_file","arguments":"{}"},
- {"type":"function_call_output","call_id":"call_read","output":[
- {"type":"input_text","text":"Read image file: /tmp/example.png"},
- {"type":"input_image","detail":"auto","image_url":"data:image/png;base64,QUE="}
- ]}
- ]}`)
- patched, err := sanitizeGrokResponsesModelInput(body)
- require.NoError(t, err)
- require.Equal(t, "function_call_output", gjson.GetBytes(patched, "input.1.type").String())
- require.Equal(t, "Read image file: /tmp/example.png", gjson.GetBytes(patched, "input.1.output").String())
- require.NotContains(t, gjson.GetBytes(patched, "input.1.output").String(), "base64")
- require.Equal(t, "message", gjson.GetBytes(patched, "input.2.type").String())
- require.Equal(t, "input_image", gjson.GetBytes(patched, "input.2.content.1.type").String())
- require.Equal(t, "data:image/png;base64,QUE=", gjson.GetBytes(patched, "input.2.content.1.image_url").String())
- }
- func TestSanitizeGrokResponsesModelInputSkipsInvalidOutputImages(t *testing.T) {
- body := []byte(`{"input":[
- {"type":"function_call_output","call_id":"call_empty","output":"done","images":[{"type":"image","url":"data:image/png;base64,"},{}]},
- {"role":"user","content":"continue"}
- ]}`)
- patched, err := sanitizeGrokResponsesModelInput(body)
- require.NoError(t, err)
- require.Len(t, gjson.GetBytes(patched, "input").Array(), 2)
- require.Equal(t, "function_call_output", gjson.GetBytes(patched, "input.0.type").String())
- require.Equal(t, "message", gjson.GetBytes(patched, "input.1.type").String())
- }
- func TestPatchGrokResponsesBodyCombinesAdditionalToolsAndDropsOrphanControls(t *testing.T) {
- body := []byte(`{
- "input":[{"type":"additional_tools","tools":[{"type":"function","name":"lookup"}]},{"role":"user","content":"hi"}],
- "tools":[{"type":"function","name":"lookup"}],
- "tool_choice":"auto","parallel_tool_calls":true
- }`)
- patched, err := patchGrokResponsesBody(body, "grok-4.5")
- require.NoError(t, err)
- require.Equal(t, int64(1), gjson.GetBytes(patched, "tools.#").Int())
- require.Equal(t, "lookup", gjson.GetBytes(patched, "tools.0.name").String())
- require.Equal(t, "message", gjson.GetBytes(patched, "input.0.type").String())
- withoutTools, err := patchGrokResponsesBody([]byte(`{"input":"hi","tool_choice":"auto","parallel_tool_calls":true}`), "grok-4.5")
- require.NoError(t, err)
- require.False(t, gjson.GetBytes(withoutTools, "tool_choice").Exists())
- require.False(t, gjson.GetBytes(withoutTools, "parallel_tool_calls").Exists())
- for _, malformedTools := range []string{"null", `{}`, `"invalid"`} {
- malformedBody := []byte(`{"input":"hi","tools":` + malformedTools + `,"tool_choice":"auto","parallel_tool_calls":true}`)
- patched, err := patchGrokResponsesBody(malformedBody, "grok-4.5")
- require.NoError(t, err)
- require.False(t, gjson.GetBytes(patched, "tool_choice").Exists(), string(patched))
- require.False(t, gjson.GetBytes(patched, "parallel_tool_calls").Exists(), string(patched))
- }
- }
- func TestSanitizeGrokCompactionReplayBodyPreservesVisibleSummary(t *testing.T) {
- body := []byte(`{
- "previous_response_id":"resp_stale",
- "input":[{"type":"compaction","encrypted_content":"opaque","summary":[{"type":"summary_text","text":"visible history"}]},{"type":"message","role":"user","content":"continue"}]
- }`)
- patched, changed, err := sanitizeGrokCompactionReplayBody(body)
- require.NoError(t, err)
- require.True(t, changed)
- require.False(t, gjson.GetBytes(patched, "previous_response_id").Exists())
- require.False(t, gjson.GetBytes(patched, `input.#(type=="reasoning")`).Exists())
- require.Contains(t, gjson.GetBytes(patched, "input.0.content.0.text").String(), "visible history")
- require.Equal(t, "continue", gjson.GetBytes(patched, "input.1.content").String())
- require.True(t, isGrokCompactionReplayDecodeError(http.StatusBadRequest, []byte(`{"error":{"message":"failed to deserialize compaction summary history"}}`)))
- require.True(t, isGrokCompactionReplayDecodeError(http.StatusBadRequest, []byte(`{"error":{"type":"invalid_request_error"},"detail":"could not decode compaction blob"}`)))
- }
- func TestGrokStructuredErrorCandidatesDoNotShadowTopLevelMessages(t *testing.T) {
- shadowedInvalidEncrypted := []byte(`{"code":"invalid-argument","error":{"type":"invalid_request_error"},"message":"Could not decrypt encrypted_content because it was modified"}`)
- require.True(t, isGrokInvalidEncryptedContentResponse(http.StatusBadRequest, shadowedInvalidEncrypted))
- require.True(t, isGrokCompactionReplayDecodeError(http.StatusBadRequest, []byte(`{"error":{"type":"invalid_request_error"},"message":"could not decode compaction history"}`)))
- }
- func TestGrokDecoderCompatibility422FailsOverWithoutCooldown(t *testing.T) {
- body := []byte(`{"detail":"data did not match any variant of untagged enum ModelInput at input[3]"}`)
- require.True(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, body))
- require.True(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"message":"could not decode ModelInput at input.3"}`)))
- require.True(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"error":{"type":"invalid_request_error"},"message":"could not deserialize ModelInput at input[3]"}`)))
- 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"}`)))
- require.True(t, (&OpenAIGatewayService{}).shouldFailoverGrokUpstreamError(http.StatusUnprocessableEntity, body))
- decision := classifyGrokUpstreamFailure(http.StatusUnprocessableEntity, body, "grok-4.5")
- require.False(t, decision.ShouldCooldown)
- require.Equal(t, GrokFailureNone, decision.Class)
- require.False(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"error":{"message":"invalid user parameter"}}`)))
- require.False(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"error":"messages[1].content is required"}`)))
- require.False(t, isGrokDecoderCompatibilityError(http.StatusBadRequest, body))
- }
|