Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
02046744
Unverified
Commit
02046744
authored
Mar 24, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 24, 2026
Browse files
Merge pull request #1212 from alfadb/fix/filter-empty-text-blocks-nested
fix(gateway): 修复 tool_result 嵌套内容中空 text block 导致上游 400 错误
parents
68d7ec91
70a9d0d3
Changes
3
Hide whitespace changes
Inline
Side-by-side
backend/internal/service/gateway_request.go
View file @
02046744
...
...
@@ -253,6 +253,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
...
...
@@ -426,6 +538,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
)
}
...
...
backend/internal/service/gateway_request_test.go
View file @
02046744
...
...
@@ -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
(
`{
...
...
backend/internal/service/gateway_service.go
View file @
02046744
...
...
@@ -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
)
...
...
@@ -4609,6 +4612,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
)
...
...
@@ -7887,6 +7893,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
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment