Skip to content

Commit 70a9d0d

Browse files
committed
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
1 parent bda7c39 commit 70a9d0d

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
@@ -205,6 +205,118 @@ func sliceRawFromBody(body []byte, r gjson.Result) []byte {
205205
return []byte(r.Raw)
206206
}
207207

208+
// stripEmptyTextBlocksFromSlice removes empty text blocks from a content slice (including nested tool_result content).
209+
// Returns (cleaned slice, true) if any blocks were removed, or (original, false) if unchanged.
210+
func stripEmptyTextBlocksFromSlice(blocks []any) ([]any, bool) {
211+
var result []any
212+
changed := false
213+
for i, block := range blocks {
214+
blockMap, ok := block.(map[string]any)
215+
if !ok {
216+
if result != nil {
217+
result = append(result, block)
218+
}
219+
continue
220+
}
221+
blockType, _ := blockMap["type"].(string)
222+
223+
// Strip empty text blocks
224+
if blockType == "text" {
225+
if txt, _ := blockMap["text"].(string); txt == "" {
226+
if result == nil {
227+
result = make([]any, 0, len(blocks))
228+
result = append(result, blocks[:i]...)
229+
}
230+
changed = true
231+
continue
232+
}
233+
}
234+
235+
// Recurse into tool_result nested content
236+
if blockType == "tool_result" {
237+
if nestedContent, ok := blockMap["content"].([]any); ok {
238+
if cleaned, nestedChanged := stripEmptyTextBlocksFromSlice(nestedContent); nestedChanged {
239+
if result == nil {
240+
result = make([]any, 0, len(blocks))
241+
result = append(result, blocks[:i]...)
242+
}
243+
changed = true
244+
blockCopy := make(map[string]any, len(blockMap))
245+
for k, v := range blockMap {
246+
blockCopy[k] = v
247+
}
248+
blockCopy["content"] = cleaned
249+
result = append(result, blockCopy)
250+
continue
251+
}
252+
}
253+
}
254+
255+
if result != nil {
256+
result = append(result, block)
257+
}
258+
}
259+
if !changed {
260+
return blocks, false
261+
}
262+
return result, true
263+
}
264+
265+
// StripEmptyTextBlocks removes empty text blocks from the request body (including nested tool_result content).
266+
// This is a lightweight pre-filter for the initial request path to prevent upstream 400 errors.
267+
// Returns the original body unchanged if no empty text blocks are found.
268+
func StripEmptyTextBlocks(body []byte) []byte {
269+
// Fast path: check if body contains empty text patterns
270+
hasEmptyTextBlock := bytes.Contains(body, patternEmptyText) ||
271+
bytes.Contains(body, patternEmptyTextSpaced) ||
272+
bytes.Contains(body, patternEmptyTextSp1) ||
273+
bytes.Contains(body, patternEmptyTextSp2)
274+
if !hasEmptyTextBlock {
275+
return body
276+
}
277+
278+
jsonStr := *(*string)(unsafe.Pointer(&body))
279+
msgsRes := gjson.Get(jsonStr, "messages")
280+
if !msgsRes.Exists() || !msgsRes.IsArray() {
281+
return body
282+
}
283+
284+
var messages []any
285+
if err := json.Unmarshal(sliceRawFromBody(body, msgsRes), &messages); err != nil {
286+
return body
287+
}
288+
289+
modified := false
290+
for _, msg := range messages {
291+
msgMap, ok := msg.(map[string]any)
292+
if !ok {
293+
continue
294+
}
295+
content, ok := msgMap["content"].([]any)
296+
if !ok {
297+
continue
298+
}
299+
if cleaned, changed := stripEmptyTextBlocksFromSlice(content); changed {
300+
modified = true
301+
msgMap["content"] = cleaned
302+
}
303+
}
304+
305+
if !modified {
306+
return body
307+
}
308+
309+
msgsBytes, err := json.Marshal(messages)
310+
if err != nil {
311+
return body
312+
}
313+
out, err := sjson.SetRawBytes(body, "messages", msgsBytes)
314+
if err != nil {
315+
return body
316+
}
317+
return out
318+
}
319+
208320
// FilterThinkingBlocks removes thinking blocks from request body
209321
// Returns filtered body or original body if filtering fails (fail-safe)
210322
// This prevents 400 errors from invalid thinking block signatures
@@ -378,6 +490,23 @@ func FilterThinkingBlocksForRetry(body []byte) []byte {
378490
}
379491
}
380492

493+
// Recursively strip empty text blocks from tool_result nested content.
494+
if blockType == "tool_result" {
495+
if nestedContent, ok := blockMap["content"].([]any); ok {
496+
if cleaned, changed := stripEmptyTextBlocksFromSlice(nestedContent); changed {
497+
modifiedThisMsg = true
498+
ensureNewContent(bi)
499+
blockCopy := make(map[string]any, len(blockMap))
500+
for k, v := range blockMap {
501+
blockCopy[k] = v
502+
}
503+
blockCopy["content"] = cleaned
504+
newContent = append(newContent, blockCopy)
505+
continue
506+
}
507+
}
508+
}
509+
381510
if newContent != nil {
382511
newContent = append(newContent, block)
383512
}

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

@@ -4603,6 +4606,9 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput(
46034606
if c != nil {
46044607
c.Set("anthropic_passthrough", true)
46054608
}
4609+
// Pre-filter: strip empty text blocks (including nested in tool_result) to prevent upstream 400.
4610+
input.Body = StripEmptyTextBlocks(input.Body)
4611+
46064612
// 重试间复用同一请求体,避免每次 string(body) 产生额外分配。
46074613
setOpsUpstreamRequestBody(c, input.Body)
46084614

@@ -7877,6 +7883,9 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
78777883
body := parsed.Body
78787884
reqModel := parsed.Model
78797885

7886+
// Pre-filter: strip empty text blocks to prevent upstream 400.
7887+
body = StripEmptyTextBlocks(body)
7888+
78807889
isClaudeCode := isClaudeCodeRequest(ctx, c, parsed)
78817890
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
78827891

0 commit comments

Comments
 (0)