diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 1906b83780..8bd1d456dc 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -185,6 +185,10 @@ func patchGrokResponsesBody(body []byte, upstreamModel string) ([]byte, error) { if err != nil { return nil, err } + out, err = sanitizeGrokReasoningNullContent(out) + if err != nil { + return nil, err + } out, err = sanitizeGrokResponsesTools(out) if err != nil { return nil, err @@ -304,6 +308,35 @@ func sanitizeGrokResponsesInput(body []byte) ([]byte, error) { return sjson.SetRawBytes(body, "input", encoded) } +// sanitizeGrokReasoningNullContent 删除 reasoning 项中的 "content": null。 +// xAI 的 untagged enum 反序列化器拒收该字段,返回 422。 +func sanitizeGrokReasoningNullContent(body []byte) ([]byte, error) { + input := gjson.GetBytes(body, "input") + if !input.Exists() || !input.IsArray() { + return body, nil + } + + items := input.Array() + changed := false + for i := len(items) - 1; i >= 0; i-- { + item := items[i] + if strings.TrimSpace(item.Get("type").String()) != "reasoning" { + continue + } + contentResult := item.Get("content") + if contentResult.Exists() && contentResult.Type == gjson.Null { + var err error + body, err = sjson.DeleteBytes(body, fmt.Sprintf("input.%d.content", i)) + if err != nil { + return nil, err + } + changed = true + } + } + _ = changed + return body, nil +} + var grokResponsesSupportedToolTypes = map[string]struct{}{ "code_execution": {}, "code_interpreter": {}, diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index b45e0bccc0..3b64cbc16b 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -1771,3 +1771,70 @@ func TestFailoverOpenAIUpstreamHTTPErrorUsesOnlyGrokRateLimitPolicy(t *testing.T require.Equal(t, 1, repo.rateLimitedCalls) require.Zero(t, repo.tempUnschedCalls) } + +func TestPatchGrokResponsesBody_StripsReasoningContentNull(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "model": "grok-latest", + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}, + {"type":"reasoning","summary":[{"type":"summary_text","text":"thinking..."}],"content":null,"encrypted_content":null}, + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello!"}]} + ] + }`) + + patched, err := patchGrokResponsesBody(body, "grok-4.5") + require.NoError(t, err) + require.True(t, json.Valid(patched)) + + input := gjson.GetBytes(patched, "input") + require.True(t, input.IsArray()) + + items := input.Array() + require.Len(t, items, 3) + + reasoning := items[1] + require.Equal(t, "reasoning", reasoning.Get("type").String()) + require.True(t, reasoning.Get("summary").Exists(), "summary should be preserved") + require.False(t, reasoning.Get("content").Exists(), "content: null should be stripped") +} + +func TestPatchGrokResponsesBody_KeepsReasoningContentNonNull(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "model": "grok-latest", + "input": [ + {"type":"reasoning","summary":[{"type":"summary_text","text":"ok"}],"content":"real content"} + ] + }`) + + patched, err := patchGrokResponsesBody(body, "grok-4.5") + require.NoError(t, err) + + reasoning := gjson.GetBytes(patched, "input.0") + require.Equal(t, "real content", reasoning.Get("content").String(), "non-null content must not be stripped") +} + +func TestPatchGrokResponsesBody_MultipleReasoningContentNull(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "model": "grok-latest", + "input": [ + {"type":"reasoning","summary":[{"type":"summary_text","text":"r1"}],"content":null}, + {"type":"message","role":"user","content":"hi"}, + {"type":"reasoning","summary":[{"type":"summary_text","text":"r2"}],"content":null} + ] + }`) + + patched, err := patchGrokResponsesBody(body, "grok-4.5") + require.NoError(t, err) + + items := gjson.GetBytes(patched, "input").Array() + require.Len(t, items, 3) + + require.False(t, items[0].Get("content").Exists()) + require.False(t, items[2].Get("content").Exists()) +} diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index d56b11fdcc..b47c2c8638 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -136,12 +136,20 @@ func sanitizeEncryptedReasoningInputItem(item any) (next any, changed bool, keep return item, false, true } - _, hasEncryptedContent := inputItem["encrypted_content"] - if !hasEncryptedContent { - return item, false, true + if _, has := inputItem["encrypted_content"]; has { + delete(inputItem, "encrypted_content") + changed = true } - delete(inputItem, "encrypted_content") + // xAI 422: "content": null 导致 untagged enum 反序列化失败 + if v, has := inputItem["content"]; has && v == nil { + delete(inputItem, "content") + changed = true + } + + if !changed { + return item, false, true + } if len(inputItem) == 1 { return nil, true, false } diff --git a/backend/internal/service/openai_gateway_request_body_reasoning_test.go b/backend/internal/service/openai_gateway_request_body_reasoning_test.go new file mode 100644 index 0000000000..679d245884 --- /dev/null +++ b/backend/internal/service/openai_gateway_request_body_reasoning_test.go @@ -0,0 +1,106 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTrimOpenAIEncryptedReasoningItems_ContentNull(t *testing.T) { + reqBody := map[string]any{ + "model": "grok-4.5", + "input": []any{ + map[string]any{"type": "message", "role": "user", "content": "hi"}, + map[string]any{ + "type": "reasoning", + "summary": []any{map[string]any{"type": "summary_text", "text": "thinking..."}}, + "content": nil, + "encrypted_content": nil, + }, + map[string]any{"type": "message", "role": "assistant", "content": "Hello!"}, + }, + } + + changed := trimOpenAIEncryptedReasoningItems(reqBody) + require.True(t, changed) + + input := reqBody["input"].([]any) + require.Len(t, input, 3) + + reasoning := input[1].(map[string]any) + assert.Equal(t, "reasoning", reasoning["type"]) + assert.NotNil(t, reasoning["summary"]) + _, hasContent := reasoning["content"] + assert.False(t, hasContent, "content: null should be stripped") + _, hasEncrypted := reasoning["encrypted_content"] + assert.False(t, hasEncrypted, "encrypted_content should be stripped") +} + +func TestTrimOpenAIEncryptedReasoningItems_ContentNullOnly(t *testing.T) { + reqBody := map[string]any{ + "model": "grok-4.5", + "input": []any{ + map[string]any{ + "type": "reasoning", + "summary": []any{map[string]any{"type": "summary_text", "text": "ok"}}, + "content": nil, + }, + }, + } + + changed := trimOpenAIEncryptedReasoningItems(reqBody) + require.True(t, changed) + + input := reqBody["input"].([]any) + require.Len(t, input, 1) + + reasoning := input[0].(map[string]any) + _, hasContent := reasoning["content"] + assert.False(t, hasContent, "content: null should be stripped even without encrypted_content") +} + +func TestTrimOpenAIEncryptedReasoningItems_ContentNonNull(t *testing.T) { + reqBody := map[string]any{ + "model": "grok-4.5", + "input": []any{ + map[string]any{ + "type": "reasoning", + "summary": []any{map[string]any{"type": "summary_text", "text": "ok"}}, + "content": "some actual content", + }, + }, + } + + changed := trimOpenAIEncryptedReasoningItems(reqBody) + assert.False(t, changed, "non-null content should not be stripped") + + input := reqBody["input"].([]any) + reasoning := input[0].(map[string]any) + assert.Equal(t, "some actual content", reasoning["content"]) +} + +func TestTrimOpenAIEncryptedReasoningItems_NoReasoningItems(t *testing.T) { + reqBody := map[string]any{ + "model": "grok-4.5", + "input": []any{ + map[string]any{"type": "message", "role": "user", "content": "hi"}, + }, + } + + changed := trimOpenAIEncryptedReasoningItems(reqBody) + assert.False(t, changed) +} + +func TestTrimOpenAIEncryptedReasoningItems_ContentNullDropsBareSkeleton(t *testing.T) { + reqBody := map[string]any{ + "input": []any{ + map[string]any{"type": "reasoning", "content": nil}, + }, + } + + changed := trimOpenAIEncryptedReasoningItems(reqBody) + require.True(t, changed) + _, hasInput := reqBody["input"] + assert.False(t, hasInput, "bare reasoning skeleton should be dropped, emptying input") +}