Skip to content

Commit 0204674

Browse files
authored
Merge pull request Wei-Shaw#1212 from alfadb/fix/filter-empty-text-blocks-nested
fix(gateway): 修复 tool_result 嵌套内容中空 text block 导致上游 400 错误
2 parents 68d7ec9 + 70a9d0d commit 0204674

3 files changed

Lines changed: 254 additions & 0 deletions

File tree

backend/internal/service/gateway_request.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,118 @@ func sliceRawFromBody(body []byte, r gjson.Result) []byte {
253253
return []byte(r.Raw)
254254
}
255255

256+
// stripEmptyTextBlocksFromSlice removes empty text blocks from a content slice (including nested tool_result content).
257+
// Returns (cleaned slice, true) if any blocks were removed, or (original, false) if unchanged.
258+
func stripEmptyTextBlocksFromSlice(blocks []any) ([]any, bool) {
259+
var result []any
260+
changed := false
261+
for i, block := range blocks {
262+
blockMap, ok := block.(map[string]any)
263+
if !ok {
264+
if result != nil {
265+
result = append(result, block)
266+
}
267+
continue
268+
}
269+
blockType, _ := blockMap["type"].(string)
270+
271+
// Strip empty text blocks
272+
if blockType == "text" {
273+
if txt, _ := blockMap["text"].(string); txt == "" {
274+
if result == nil {
275+
result = make([]any, 0, len(blocks))
276+
result = append(result, blocks[:i]...)
277+
}
278+
changed = true
279+
continue
280+
}
281+
}
282+
283+
// Recurse into tool_result nested content
284+
if blockType == "tool_result" {
285+
if nestedContent, ok := blockMap["content"].([]any); ok {
286+
if cleaned, nestedChanged := stripEmptyTextBlocksFromSlice(nestedContent); nestedChanged {
287+
if result == nil {
288+
result = make([]any, 0, len(blocks))
289+
result = append(result, blocks[:i]...)
290+
}
291+
changed = true
292+
blockCopy := make(map[string]any, len(blockMap))
293+
for k, v := range blockMap {
294+
blockCopy[k] = v
295+
}
296+
blockCopy["content"] = cleaned
297+
result = append(result, blockCopy)
298+
continue
299+
}
300+
}
301+
}
302+
303+
if result != nil {
304+
result = append(result, block)
305+
}
306+
}
307+
if !changed {
308+
return blocks, false
309+
}
310+
return result, true
311+
}
312+
313+
// StripEmptyTextBlocks removes empty text blocks from the request body (including nested tool_result content).
314+
// This is a lightweight pre-filter for the initial request path to prevent upstream 400 errors.
315+
// Returns the original body unchanged if no empty text blocks are found.
316+
func StripEmptyTextBlocks(body []byte) []byte {
317+
// Fast path: check if body contains empty text patterns
318+
hasEmptyTextBlock := bytes.Contains(body, patternEmptyText) ||
319+
bytes.Contains(body, patternEmptyTextSpaced) ||
320+
bytes.Contains(body, patternEmptyTextSp1) ||
321+
bytes.Contains(body, patternEmptyTextSp2)
322+
if !hasEmptyTextBlock {
323+
return body
324+
}
325+
326+
jsonStr := *(*string)(unsafe.Pointer(&body))
327+
msgsRes := gjson.Get(jsonStr, "messages")
328+
if !msgsRes.Exists() || !msgsRes.IsArray() {
329+
return body
330+
}
331+
332+
var messages []any
333+
if err := json.Unmarshal(sliceRawFromBody(body, msgsRes), &messages); err != nil {
334+
return body
335+
}
336+
337+
modified := false
338+
for _, msg := range messages {
339+
msgMap, ok := msg.(map[string]any)
340+
if !ok {
341+
continue
342+
}
343+
content, ok := msgMap["content"].([]any)
344+
if !ok {
345+
continue
346+
}
347+
if cleaned, changed := stripEmptyTextBlocksFromSlice(content); changed {
348+
modified = true
349+
msgMap["content"] = cleaned
350+
}
351+
}
352+
353+
if !modified {
354+
return body
355+
}
356+
357+
msgsBytes, err := json.Marshal(messages)
358+
if err != nil {
359+
return body
360+
}
361+
out, err := sjson.SetRawBytes(body, "messages", msgsBytes)
362+
if err != nil {
363+
return body
364+
}
365+
return out
366+
}
367+
256368
// FilterThinkingBlocks removes thinking blocks from request body
257369
// Returns filtered body or original body if filtering fails (fail-safe)
258370
// This prevents 400 errors from invalid thinking block signatures
@@ -426,6 +538,23 @@ func FilterThinkingBlocksForRetry(body []byte) []byte {
426538
}
427539
}
428540

541+
// Recursively strip empty text blocks from tool_result nested content.
542+
if blockType == "tool_result" {
543+
if nestedContent, ok := blockMap["content"].([]any); ok {
544+
if cleaned, changed := stripEmptyTextBlocksFromSlice(nestedContent); changed {
545+
modifiedThisMsg = true
546+
ensureNewContent(bi)
547+
blockCopy := make(map[string]any, len(blockMap))
548+
for k, v := range blockMap {
549+
blockCopy[k] = v
550+
}
551+
blockCopy["content"] = cleaned
552+
newContent = append(newContent, blockCopy)
553+
continue
554+
}
555+
}
556+
}
557+
429558
if newContent != nil {
430559
newContent = append(newContent, block)
431560
}

backend/internal/service/gateway_request_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,122 @@ func TestFilterThinkingBlocksForRetry_StripsEmptyTextBlocks(t *testing.T) {
435435
require.NotEmpty(t, block1["text"])
436436
}
437437

438+
func TestFilterThinkingBlocksForRetry_StripsNestedEmptyTextInToolResult(t *testing.T) {
439+
// Empty text blocks nested inside tool_result content should also be stripped
440+
input := []byte(`{
441+
"messages":[
442+
{"role":"user","content":[
443+
{"type":"tool_result","tool_use_id":"t1","content":[
444+
{"type":"text","text":"valid result"},
445+
{"type":"text","text":""}
446+
]}
447+
]}
448+
]
449+
}`)
450+
451+
out := FilterThinkingBlocksForRetry(input)
452+
453+
var req map[string]any
454+
require.NoError(t, json.Unmarshal(out, &req))
455+
msgs := req["messages"].([]any)
456+
msg0 := msgs[0].(map[string]any)
457+
content0 := msg0["content"].([]any)
458+
require.Len(t, content0, 1)
459+
toolResult := content0[0].(map[string]any)
460+
require.Equal(t, "tool_result", toolResult["type"])
461+
nestedContent := toolResult["content"].([]any)
462+
require.Len(t, nestedContent, 1)
463+
require.Equal(t, "valid result", nestedContent[0].(map[string]any)["text"])
464+
}
465+
466+
func TestFilterThinkingBlocksForRetry_NestedAllEmptyGetsEmptySlice(t *testing.T) {
467+
// If all nested content blocks in tool_result are empty text, content becomes empty slice
468+
input := []byte(`{
469+
"messages":[
470+
{"role":"user","content":[
471+
{"type":"tool_result","tool_use_id":"t1","content":[
472+
{"type":"text","text":""}
473+
]},
474+
{"type":"text","text":"hello"}
475+
]}
476+
]
477+
}`)
478+
479+
out := FilterThinkingBlocksForRetry(input)
480+
481+
var req map[string]any
482+
require.NoError(t, json.Unmarshal(out, &req))
483+
msgs := req["messages"].([]any)
484+
msg0 := msgs[0].(map[string]any)
485+
content0 := msg0["content"].([]any)
486+
require.Len(t, content0, 2)
487+
toolResult := content0[0].(map[string]any)
488+
nestedContent := toolResult["content"].([]any)
489+
require.Len(t, nestedContent, 0)
490+
}
491+
492+
func TestStripEmptyTextBlocks(t *testing.T) {
493+
t.Run("strips top-level empty text", func(t *testing.T) {
494+
input := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hello"},{"type":"text","text":""}]}]}`)
495+
out := StripEmptyTextBlocks(input)
496+
var req map[string]any
497+
require.NoError(t, json.Unmarshal(out, &req))
498+
msgs := req["messages"].([]any)
499+
content := msgs[0].(map[string]any)["content"].([]any)
500+
require.Len(t, content, 1)
501+
require.Equal(t, "hello", content[0].(map[string]any)["text"])
502+
})
503+
504+
t.Run("strips nested empty text in tool_result", func(t *testing.T) {
505+
input := []byte(`{"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"ok"},{"type":"text","text":""}]}]}]}`)
506+
out := StripEmptyTextBlocks(input)
507+
var req map[string]any
508+
require.NoError(t, json.Unmarshal(out, &req))
509+
msgs := req["messages"].([]any)
510+
content := msgs[0].(map[string]any)["content"].([]any)
511+
toolResult := content[0].(map[string]any)
512+
nestedContent := toolResult["content"].([]any)
513+
require.Len(t, nestedContent, 1)
514+
require.Equal(t, "ok", nestedContent[0].(map[string]any)["text"])
515+
})
516+
517+
t.Run("no-op when no empty text", func(t *testing.T) {
518+
input := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
519+
out := StripEmptyTextBlocks(input)
520+
require.Equal(t, input, out)
521+
})
522+
523+
t.Run("preserves non-map blocks in content", func(t *testing.T) {
524+
// tool_result content can be a string; non-map blocks should pass through unchanged
525+
input := []byte(`{"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"string content"},{"type":"text","text":""}]}]}`)
526+
out := StripEmptyTextBlocks(input)
527+
var req map[string]any
528+
require.NoError(t, json.Unmarshal(out, &req))
529+
msgs := req["messages"].([]any)
530+
content := msgs[0].(map[string]any)["content"].([]any)
531+
require.Len(t, content, 1)
532+
toolResult := content[0].(map[string]any)
533+
require.Equal(t, "tool_result", toolResult["type"])
534+
require.Equal(t, "string content", toolResult["content"])
535+
})
536+
537+
t.Run("handles deeply nested tool_result", func(t *testing.T) {
538+
// Recursive: tool_result containing another tool_result with empty text
539+
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"}]}]}]}]}`)
540+
out := StripEmptyTextBlocks(input)
541+
var req map[string]any
542+
require.NoError(t, json.Unmarshal(out, &req))
543+
msgs := req["messages"].([]any)
544+
content := msgs[0].(map[string]any)["content"].([]any)
545+
outer := content[0].(map[string]any)
546+
innerContent := outer["content"].([]any)
547+
inner := innerContent[0].(map[string]any)
548+
deepContent := inner["content"].([]any)
549+
require.Len(t, deepContent, 1)
550+
require.Equal(t, "deep", deepContent[0].(map[string]any)["text"])
551+
})
552+
}
553+
438554
func TestFilterThinkingBlocksForRetry_PreservesNonEmptyTextBlocks(t *testing.T) {
439555
// Non-empty text blocks should pass through unchanged
440556
input := []byte(`{

backend/internal/service/gateway_service.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4119,6 +4119,9 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
41194119
// 调试日志:记录即将转发的账号信息
41204120
logger.LegacyPrintf("service.gateway", "[Forward] Using account: ID=%d Name=%s Platform=%s Type=%s TLSFingerprint=%v Proxy=%s",
41214121
account.ID, account.Name, account.Platform, account.Type, account.IsTLSFingerprintEnabled(), proxyURL)
4122+
// Pre-filter: strip empty text blocks (including nested in tool_result) to prevent upstream 400.
4123+
body = StripEmptyTextBlocks(body)
4124+
41224125
// 重试间复用同一请求体,避免每次 string(body) 产生额外分配。
41234126
setOpsUpstreamRequestBody(c, body)
41244127

@@ -4609,6 +4612,9 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput(
46094612
if c != nil {
46104613
c.Set("anthropic_passthrough", true)
46114614
}
4615+
// Pre-filter: strip empty text blocks (including nested in tool_result) to prevent upstream 400.
4616+
input.Body = StripEmptyTextBlocks(input.Body)
4617+
46124618
// 重试间复用同一请求体,避免每次 string(body) 产生额外分配。
46134619
setOpsUpstreamRequestBody(c, input.Body)
46144620

@@ -7887,6 +7893,9 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
78877893
body := parsed.Body
78887894
reqModel := parsed.Model
78897895

7896+
// Pre-filter: strip empty text blocks to prevent upstream 400.
7897+
body = StripEmptyTextBlocks(body)
7898+
78907899
isClaudeCode := isClaudeCodeRequest(ctx, c, parsed)
78917900
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
78927901

0 commit comments

Comments
 (0)