fix(grok): 清洗 reasoning 项中 content: null 避免 xAI 422

Fixes #4237
This commit is contained in:
li
2026-07-14 13:13:32 +08:00
parent 69bc6a87dd
commit ff639ba757
4 changed files with 218 additions and 4 deletions
@@ -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": {},
@@ -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())
}
@@ -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
}
@@ -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")
}