Files
sub2api/backend/internal/service/openai_gateway_grok_model_input_test.go
T

233 lines
14 KiB
Go

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))
}