From b8ada63ac3b75a506e11ce0d7a3eabb7d493130e Mon Sep 17 00:00:00 2001 From: alfadb Date: Wed, 18 Mar 2026 14:20:00 +0800 Subject: [PATCH] fix: strip empty text blocks in retry filter and fix error pattern matching Empty text blocks ({"type":"text","text":""}) cause Anthropic upstream to return 400: "text content blocks must be non-empty". This was not caught by the existing error detection pattern in isThinkingBlockSignatureError, nor handled by FilterThinkingBlocksForRetry. - Add empty text block stripping to FilterThinkingBlocksForRetry - Fix isThinkingBlockSignatureError to match new Anthropic error format - Add fast-path byte patterns to avoid unnecessary JSON parsing Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/internal/service/gateway_request.go | 25 +++++++++-- .../internal/service/gateway_request_test.go | 45 +++++++++++++++++++ backend/internal/service/gateway_service.go | 6 ++- 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index 3816aea9a9..4581a3c404 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -28,6 +28,10 @@ var ( patternEmptyContentSpaced = []byte(`"content": []`) patternEmptyContentSp1 = []byte(`"content" : []`) patternEmptyContentSp2 = []byte(`"content" :[]`) + + // Fast-path patterns for empty text blocks: {"type":"text","text":""} + patternEmptyText = []byte(`"text":""`) + patternEmptyTextSpaced = []byte(`"text": ""`) ) // SessionContext 粘性会话上下文,用于区分不同来源的请求。 @@ -233,15 +237,20 @@ func FilterThinkingBlocksForRetry(body []byte) []byte { bytes.Contains(body, patternThinkingField) || bytes.Contains(body, patternThinkingFieldSpaced) - // Also check for empty content arrays that need fixing. + // Also check for empty content arrays and empty text blocks that need fixing. // Note: This is a heuristic check; the actual empty content handling is done below. hasEmptyContent := bytes.Contains(body, patternEmptyContent) || bytes.Contains(body, patternEmptyContentSpaced) || bytes.Contains(body, patternEmptyContentSp1) || bytes.Contains(body, patternEmptyContentSp2) + // Check for empty text blocks: {"type":"text","text":""} + // These cause upstream 400: "text content blocks must be non-empty" + hasEmptyTextBlock := bytes.Contains(body, patternEmptyText) || + bytes.Contains(body, patternEmptyTextSpaced) + // Fast path: nothing to process - if !hasThinkingContent && !hasEmptyContent { + if !hasThinkingContent && !hasEmptyContent && !hasEmptyTextBlock { return body } @@ -260,7 +269,7 @@ func FilterThinkingBlocksForRetry(body []byte) []byte { bytes.Contains(body, patternTypeRedactedThinking) || bytes.Contains(body, patternTypeRedactedSpaced) || bytes.Contains(body, patternThinkingFieldSpaced) - if !hasEmptyContent && !containsThinkingBlocks { + if !hasEmptyContent && !hasEmptyTextBlock && !containsThinkingBlocks { if topThinking := gjson.Get(jsonStr, "thinking"); topThinking.Exists() { if out, err := sjson.DeleteBytes(body, "thinking"); err == nil { out = removeThinkingDependentContextStrategies(out) @@ -320,6 +329,16 @@ func FilterThinkingBlocksForRetry(body []byte) []byte { blockType, _ := blockMap["type"].(string) + // Strip empty text blocks: {"type":"text","text":""} + // Upstream rejects these with 400: "text content blocks must be non-empty" + if blockType == "text" { + if txt, _ := blockMap["text"].(string); txt == "" { + modifiedThisMsg = true + ensureNewContent(bi) + continue + } + } + // Convert thinking blocks to text (preserve content) and drop redacted_thinking. switch blockType { case "thinking": diff --git a/backend/internal/service/gateway_request_test.go b/backend/internal/service/gateway_request_test.go index f60ed9fbc8..b11fee9b13 100644 --- a/backend/internal/service/gateway_request_test.go +++ b/backend/internal/service/gateway_request_test.go @@ -404,6 +404,51 @@ func TestFilterThinkingBlocksForRetry_EmptyContentGetsPlaceholder(t *testing.T) require.NotEmpty(t, content0["text"]) } +func TestFilterThinkingBlocksForRetry_StripsEmptyTextBlocks(t *testing.T) { + // Empty text blocks cause upstream 400: "text content blocks must be non-empty" + input := []byte(`{ + "messages":[ + {"role":"user","content":[{"type":"text","text":"hello"},{"type":"text","text":""}]}, + {"role":"assistant","content":[{"type":"text","text":""}]} + ] + }`) + + out := FilterThinkingBlocksForRetry(input) + + var req map[string]any + require.NoError(t, json.Unmarshal(out, &req)) + msgs, ok := req["messages"].([]any) + require.True(t, ok) + + // First message: empty text block stripped, "hello" preserved + msg0 := msgs[0].(map[string]any) + content0 := msg0["content"].([]any) + require.Len(t, content0, 1) + require.Equal(t, "hello", content0[0].(map[string]any)["text"]) + + // Second message: only had empty text block → gets placeholder + msg1 := msgs[1].(map[string]any) + content1 := msg1["content"].([]any) + require.Len(t, content1, 1) + block1 := content1[0].(map[string]any) + require.Equal(t, "text", block1["type"]) + require.NotEmpty(t, block1["text"]) +} + +func TestFilterThinkingBlocksForRetry_PreservesNonEmptyTextBlocks(t *testing.T) { + // Non-empty text blocks should pass through unchanged + input := []byte(`{ + "messages":[ + {"role":"user","content":[{"type":"text","text":"hello"},{"type":"text","text":"world"}]} + ] + }`) + + out := FilterThinkingBlocksForRetry(input) + + // Fast path: no thinking content, no empty content, no empty text blocks → unchanged + require.Equal(t, input, out) +} + func TestFilterSignatureSensitiveBlocksForRetry_DowngradesTools(t *testing.T) { input := []byte(`{ "thinking":{"type":"enabled","budget_tokens":1024}, diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 767110d2ec..6113f871a1 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -6067,9 +6067,11 @@ func (s *GatewayService) isThinkingBlockSignatureError(respBody []byte) bool { return true } - // 检测空消息内容错误(可能是过滤 thinking blocks 后导致的) + // 检测空消息内容错误(可能是过滤 thinking blocks 后导致的,或客户端发送了空 text block) // 例如: "all messages must have non-empty content" - if strings.Contains(msg, "non-empty content") || strings.Contains(msg, "empty content") { + // "messages: text content blocks must be non-empty" + if strings.Contains(msg, "non-empty content") || strings.Contains(msg, "empty content") || + strings.Contains(msg, "must be non-empty") { logger.LegacyPrintf("service.gateway", "[SignatureCheck] Detected empty content error") return true }