Merge pull request #1212 from alfadb/fix/filter-empty-text-blocks-nested
fix(gateway): 修复 tool_result 嵌套内容中空 text block 导致上游 400 错误
This commit is contained in:
@@ -253,6 +253,118 @@ func sliceRawFromBody(body []byte, r gjson.Result) []byte {
|
||||
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
|
||||
// Returns filtered body or original body if filtering fails (fail-safe)
|
||||
// This prevents 400 errors from invalid thinking block signatures
|
||||
@@ -426,6 +538,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 {
|
||||
newContent = append(newContent, block)
|
||||
}
|
||||
|
||||
@@ -435,6 +435,122 @@ func TestFilterThinkingBlocksForRetry_StripsEmptyTextBlocks(t *testing.T) {
|
||||
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) {
|
||||
// Non-empty text blocks should pass through unchanged
|
||||
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",
|
||||
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) 产生额外分配。
|
||||
setOpsUpstreamRequestBody(c, body)
|
||||
|
||||
@@ -4609,6 +4612,9 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput(
|
||||
if c != nil {
|
||||
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) 产生额外分配。
|
||||
setOpsUpstreamRequestBody(c, input.Body)
|
||||
|
||||
@@ -7887,6 +7893,9 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
|
||||
body := parsed.Body
|
||||
reqModel := parsed.Model
|
||||
|
||||
// Pre-filter: strip empty text blocks to prevent upstream 400.
|
||||
body = StripEmptyTextBlocks(body)
|
||||
|
||||
isClaudeCode := isClaudeCodeRequest(ctx, c, parsed)
|
||||
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
|
||||
|
||||
|
||||
Reference in New Issue
Block a user