diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index 29b6cfd6..fb28e404 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -205,6 +205,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 @@ -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 { newContent = append(newContent, block) } diff --git a/backend/internal/service/gateway_request_test.go b/backend/internal/service/gateway_request_test.go index b11fee9b..d262456d 100644 --- a/backend/internal/service/gateway_request_test.go +++ b/backend/internal/service/gateway_request_test.go @@ -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(`{ diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 72cef2ac..4de52a54 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -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) @@ -4603,6 +4606,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) @@ -7877,6 +7883,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