fix(gateway): strip empty text blocks from nested tool_result content
Empty text blocks inside tool_result.content were not being filtered, causing upstream 400 errors: 'text content blocks must be non-empty'. Changes: - Add stripEmptyTextBlocksFromSlice helper for recursive content filtering - FilterThinkingBlocksForRetry now recurses into tool_result nested content - Add StripEmptyTextBlocks pre-filter on initial request path to avoid unnecessary 400+retry round-trips - Add unit tests for nested empty text block scenarios
This commit is contained in:
@@ -205,6 +205,118 @@ func sliceRawFromBody(body []byte, r gjson.Result) []byte {
|
|||||||
return []byte(r.Raw)
|
return []byte(r.Raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stripEmptyTextBlocksFromSlice removes empty text blocks from a content slice (including nested tool_result content).
|
||||||
|
// Returns (cleaned slice, true) if any blocks were removed, or (original, false) if unchanged.
|
||||||
|
func stripEmptyTextBlocksFromSlice(blocks []any) ([]any, bool) {
|
||||||
|
var result []any
|
||||||
|
changed := false
|
||||||
|
for i, block := range blocks {
|
||||||
|
blockMap, ok := block.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
if result != nil {
|
||||||
|
result = append(result, block)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
blockType, _ := blockMap["type"].(string)
|
||||||
|
|
||||||
|
// Strip empty text blocks
|
||||||
|
if blockType == "text" {
|
||||||
|
if txt, _ := blockMap["text"].(string); txt == "" {
|
||||||
|
if result == nil {
|
||||||
|
result = make([]any, 0, len(blocks))
|
||||||
|
result = append(result, blocks[:i]...)
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into tool_result nested content
|
||||||
|
if blockType == "tool_result" {
|
||||||
|
if nestedContent, ok := blockMap["content"].([]any); ok {
|
||||||
|
if cleaned, nestedChanged := stripEmptyTextBlocksFromSlice(nestedContent); nestedChanged {
|
||||||
|
if result == nil {
|
||||||
|
result = make([]any, 0, len(blocks))
|
||||||
|
result = append(result, blocks[:i]...)
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
blockCopy := make(map[string]any, len(blockMap))
|
||||||
|
for k, v := range blockMap {
|
||||||
|
blockCopy[k] = v
|
||||||
|
}
|
||||||
|
blockCopy["content"] = cleaned
|
||||||
|
result = append(result, blockCopy)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil {
|
||||||
|
result = append(result, block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return blocks, false
|
||||||
|
}
|
||||||
|
return result, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// StripEmptyTextBlocks removes empty text blocks from the request body (including nested tool_result content).
|
||||||
|
// This is a lightweight pre-filter for the initial request path to prevent upstream 400 errors.
|
||||||
|
// Returns the original body unchanged if no empty text blocks are found.
|
||||||
|
func StripEmptyTextBlocks(body []byte) []byte {
|
||||||
|
// Fast path: check if body contains empty text patterns
|
||||||
|
hasEmptyTextBlock := bytes.Contains(body, patternEmptyText) ||
|
||||||
|
bytes.Contains(body, patternEmptyTextSpaced) ||
|
||||||
|
bytes.Contains(body, patternEmptyTextSp1) ||
|
||||||
|
bytes.Contains(body, patternEmptyTextSp2)
|
||||||
|
if !hasEmptyTextBlock {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonStr := *(*string)(unsafe.Pointer(&body))
|
||||||
|
msgsRes := gjson.Get(jsonStr, "messages")
|
||||||
|
if !msgsRes.Exists() || !msgsRes.IsArray() {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages []any
|
||||||
|
if err := json.Unmarshal(sliceRawFromBody(body, msgsRes), &messages); err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
modified := false
|
||||||
|
for _, msg := range messages {
|
||||||
|
msgMap, ok := msg.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content, ok := msgMap["content"].([]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cleaned, changed := stripEmptyTextBlocksFromSlice(content); changed {
|
||||||
|
modified = true
|
||||||
|
msgMap["content"] = cleaned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !modified {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
msgsBytes, err := json.Marshal(messages)
|
||||||
|
if err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
out, err := sjson.SetRawBytes(body, "messages", msgsBytes)
|
||||||
|
if err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// FilterThinkingBlocks removes thinking blocks from request body
|
// FilterThinkingBlocks removes thinking blocks from request body
|
||||||
// Returns filtered body or original body if filtering fails (fail-safe)
|
// Returns filtered body or original body if filtering fails (fail-safe)
|
||||||
// This prevents 400 errors from invalid thinking block signatures
|
// This prevents 400 errors from invalid thinking block signatures
|
||||||
@@ -378,6 +490,23 @@ func FilterThinkingBlocksForRetry(body []byte) []byte {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recursively strip empty text blocks from tool_result nested content.
|
||||||
|
if blockType == "tool_result" {
|
||||||
|
if nestedContent, ok := blockMap["content"].([]any); ok {
|
||||||
|
if cleaned, changed := stripEmptyTextBlocksFromSlice(nestedContent); changed {
|
||||||
|
modifiedThisMsg = true
|
||||||
|
ensureNewContent(bi)
|
||||||
|
blockCopy := make(map[string]any, len(blockMap))
|
||||||
|
for k, v := range blockMap {
|
||||||
|
blockCopy[k] = v
|
||||||
|
}
|
||||||
|
blockCopy["content"] = cleaned
|
||||||
|
newContent = append(newContent, blockCopy)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if newContent != nil {
|
if newContent != nil {
|
||||||
newContent = append(newContent, block)
|
newContent = append(newContent, block)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -435,6 +435,122 @@ func TestFilterThinkingBlocksForRetry_StripsEmptyTextBlocks(t *testing.T) {
|
|||||||
require.NotEmpty(t, block1["text"])
|
require.NotEmpty(t, block1["text"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFilterThinkingBlocksForRetry_StripsNestedEmptyTextInToolResult(t *testing.T) {
|
||||||
|
// Empty text blocks nested inside tool_result content should also be stripped
|
||||||
|
input := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"user","content":[
|
||||||
|
{"type":"tool_result","tool_use_id":"t1","content":[
|
||||||
|
{"type":"text","text":"valid result"},
|
||||||
|
{"type":"text","text":""}
|
||||||
|
]}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out := FilterThinkingBlocksForRetry(input)
|
||||||
|
|
||||||
|
var req map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(out, &req))
|
||||||
|
msgs := req["messages"].([]any)
|
||||||
|
msg0 := msgs[0].(map[string]any)
|
||||||
|
content0 := msg0["content"].([]any)
|
||||||
|
require.Len(t, content0, 1)
|
||||||
|
toolResult := content0[0].(map[string]any)
|
||||||
|
require.Equal(t, "tool_result", toolResult["type"])
|
||||||
|
nestedContent := toolResult["content"].([]any)
|
||||||
|
require.Len(t, nestedContent, 1)
|
||||||
|
require.Equal(t, "valid result", nestedContent[0].(map[string]any)["text"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterThinkingBlocksForRetry_NestedAllEmptyGetsEmptySlice(t *testing.T) {
|
||||||
|
// If all nested content blocks in tool_result are empty text, content becomes empty slice
|
||||||
|
input := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"user","content":[
|
||||||
|
{"type":"tool_result","tool_use_id":"t1","content":[
|
||||||
|
{"type":"text","text":""}
|
||||||
|
]},
|
||||||
|
{"type":"text","text":"hello"}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out := FilterThinkingBlocksForRetry(input)
|
||||||
|
|
||||||
|
var req map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(out, &req))
|
||||||
|
msgs := req["messages"].([]any)
|
||||||
|
msg0 := msgs[0].(map[string]any)
|
||||||
|
content0 := msg0["content"].([]any)
|
||||||
|
require.Len(t, content0, 2)
|
||||||
|
toolResult := content0[0].(map[string]any)
|
||||||
|
nestedContent := toolResult["content"].([]any)
|
||||||
|
require.Len(t, nestedContent, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripEmptyTextBlocks(t *testing.T) {
|
||||||
|
t.Run("strips top-level empty text", func(t *testing.T) {
|
||||||
|
input := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hello"},{"type":"text","text":""}]}]}`)
|
||||||
|
out := StripEmptyTextBlocks(input)
|
||||||
|
var req map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(out, &req))
|
||||||
|
msgs := req["messages"].([]any)
|
||||||
|
content := msgs[0].(map[string]any)["content"].([]any)
|
||||||
|
require.Len(t, content, 1)
|
||||||
|
require.Equal(t, "hello", content[0].(map[string]any)["text"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("strips nested empty text in tool_result", func(t *testing.T) {
|
||||||
|
input := []byte(`{"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"ok"},{"type":"text","text":""}]}]}]}`)
|
||||||
|
out := StripEmptyTextBlocks(input)
|
||||||
|
var req map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(out, &req))
|
||||||
|
msgs := req["messages"].([]any)
|
||||||
|
content := msgs[0].(map[string]any)["content"].([]any)
|
||||||
|
toolResult := content[0].(map[string]any)
|
||||||
|
nestedContent := toolResult["content"].([]any)
|
||||||
|
require.Len(t, nestedContent, 1)
|
||||||
|
require.Equal(t, "ok", nestedContent[0].(map[string]any)["text"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no-op when no empty text", func(t *testing.T) {
|
||||||
|
input := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
|
||||||
|
out := StripEmptyTextBlocks(input)
|
||||||
|
require.Equal(t, input, out)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("preserves non-map blocks in content", func(t *testing.T) {
|
||||||
|
// tool_result content can be a string; non-map blocks should pass through unchanged
|
||||||
|
input := []byte(`{"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"string content"},{"type":"text","text":""}]}]}`)
|
||||||
|
out := StripEmptyTextBlocks(input)
|
||||||
|
var req map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(out, &req))
|
||||||
|
msgs := req["messages"].([]any)
|
||||||
|
content := msgs[0].(map[string]any)["content"].([]any)
|
||||||
|
require.Len(t, content, 1)
|
||||||
|
toolResult := content[0].(map[string]any)
|
||||||
|
require.Equal(t, "tool_result", toolResult["type"])
|
||||||
|
require.Equal(t, "string content", toolResult["content"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handles deeply nested tool_result", func(t *testing.T) {
|
||||||
|
// Recursive: tool_result containing another tool_result with empty text
|
||||||
|
input := []byte(`{"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"tool_result","tool_use_id":"t2","content":[{"type":"text","text":""},{"type":"text","text":"deep"}]}]}]}]}`)
|
||||||
|
out := StripEmptyTextBlocks(input)
|
||||||
|
var req map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(out, &req))
|
||||||
|
msgs := req["messages"].([]any)
|
||||||
|
content := msgs[0].(map[string]any)["content"].([]any)
|
||||||
|
outer := content[0].(map[string]any)
|
||||||
|
innerContent := outer["content"].([]any)
|
||||||
|
inner := innerContent[0].(map[string]any)
|
||||||
|
deepContent := inner["content"].([]any)
|
||||||
|
require.Len(t, deepContent, 1)
|
||||||
|
require.Equal(t, "deep", deepContent[0].(map[string]any)["text"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestFilterThinkingBlocksForRetry_PreservesNonEmptyTextBlocks(t *testing.T) {
|
func TestFilterThinkingBlocksForRetry_PreservesNonEmptyTextBlocks(t *testing.T) {
|
||||||
// Non-empty text blocks should pass through unchanged
|
// Non-empty text blocks should pass through unchanged
|
||||||
input := []byte(`{
|
input := []byte(`{
|
||||||
|
|||||||
@@ -4119,6 +4119,9 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
|||||||
// 调试日志:记录即将转发的账号信息
|
// 调试日志:记录即将转发的账号信息
|
||||||
logger.LegacyPrintf("service.gateway", "[Forward] Using account: ID=%d Name=%s Platform=%s Type=%s TLSFingerprint=%v Proxy=%s",
|
logger.LegacyPrintf("service.gateway", "[Forward] Using account: ID=%d Name=%s Platform=%s Type=%s TLSFingerprint=%v Proxy=%s",
|
||||||
account.ID, account.Name, account.Platform, account.Type, account.IsTLSFingerprintEnabled(), proxyURL)
|
account.ID, account.Name, account.Platform, account.Type, account.IsTLSFingerprintEnabled(), proxyURL)
|
||||||
|
// Pre-filter: strip empty text blocks (including nested in tool_result) to prevent upstream 400.
|
||||||
|
body = StripEmptyTextBlocks(body)
|
||||||
|
|
||||||
// 重试间复用同一请求体,避免每次 string(body) 产生额外分配。
|
// 重试间复用同一请求体,避免每次 string(body) 产生额外分配。
|
||||||
setOpsUpstreamRequestBody(c, body)
|
setOpsUpstreamRequestBody(c, body)
|
||||||
|
|
||||||
@@ -4603,6 +4606,9 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput(
|
|||||||
if c != nil {
|
if c != nil {
|
||||||
c.Set("anthropic_passthrough", true)
|
c.Set("anthropic_passthrough", true)
|
||||||
}
|
}
|
||||||
|
// Pre-filter: strip empty text blocks (including nested in tool_result) to prevent upstream 400.
|
||||||
|
input.Body = StripEmptyTextBlocks(input.Body)
|
||||||
|
|
||||||
// 重试间复用同一请求体,避免每次 string(body) 产生额外分配。
|
// 重试间复用同一请求体,避免每次 string(body) 产生额外分配。
|
||||||
setOpsUpstreamRequestBody(c, input.Body)
|
setOpsUpstreamRequestBody(c, input.Body)
|
||||||
|
|
||||||
@@ -7877,6 +7883,9 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
|
|||||||
body := parsed.Body
|
body := parsed.Body
|
||||||
reqModel := parsed.Model
|
reqModel := parsed.Model
|
||||||
|
|
||||||
|
// Pre-filter: strip empty text blocks to prevent upstream 400.
|
||||||
|
body = StripEmptyTextBlocks(body)
|
||||||
|
|
||||||
isClaudeCode := isClaudeCodeRequest(ctx, c, parsed)
|
isClaudeCode := isClaudeCodeRequest(ctx, c, parsed)
|
||||||
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
|
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user