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
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user