Merge pull request #3778 from fengshao1227/fix/websearch-emulation-history-blocks

fix(websearch): 剥离历史消息中上游不接受的 web search 块
This commit is contained in:
Wesley Liddick
2026-07-07 14:33:16 +08:00
committed by GitHub
3 changed files with 290 additions and 0 deletions
@@ -5105,6 +5105,13 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
if err := replaceBody(StripEmptyTextBlocks(body)); err != nil {
return nil, err
}
// Pre-filter: strip web-search history blocks the upstream cannot accept
// (emulation-synthesized server_tool_use / web_search_tool_result always;
// genuine ones additionally for passback-required upstreams). See
// FilterWebSearchHistoryBlocks. reqModel 此时已是映射后的模型 ID。
if err := replaceBody(FilterWebSearchHistoryBlocks(body, reqModel)); err != nil {
return nil, err
}
// Pre-filter: remove thinking blocks with missing/invalid signatures before forwarding.
// Clients (e.g. Claude Code) sometimes send multi-turn conversations where a historical
// assistant message contains a thinking block that is missing the required "signature" field,
@@ -5688,6 +5695,11 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput(
}
// Pre-filter: strip empty text blocks (including nested in tool_result) to prevent upstream 400.
input.Body = StripEmptyTextBlocks(input.Body)
// Pre-filter: strip web-search history blocks the upstream cannot accept
// (emulation-synthesized ones always; genuine ones additionally for
// passback-required third-party upstreams such as GLM/Kimi/DeepSeek,
// which reject server_tool_use with 400). input.RequestModel 已是映射后的模型 ID。
input.Body = FilterWebSearchHistoryBlocks(input.Body, input.RequestModel)
if input.Parsed != nil {
// 透传分支也会改写实际 wire body,成功 usage hash 依赖这里同步当前 body。
if err := input.Parsed.ReplaceBody(input.Body); err != nil {
@@ -0,0 +1,138 @@
package service
import (
"bytes"
"encoding/json"
"strings"
"unsafe"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const (
blockTypeServerToolUse = "server_tool_use"
blockTypeWebSearchToolResult = "web_search_tool_result"
)
// Fast-path byte patterns: both block types only ever appear as quoted JSON
// string values, so a raw substring check is a safe pre-filter regardless of
// key/value spacing.
var (
patternServerToolUse = []byte(`"server_tool_use"`)
patternWebSearchToolResult = []byte(`"web_search_tool_result"`)
)
// FilterWebSearchHistoryBlocks removes web-search content blocks from
// historical messages when the upstream cannot accept them:
//
// 1. Emulation-synthesized blocks — server_tool_use / web_search_tool_result
// whose tool-use ID carries webSearchToolUseIDPrefix — are fabricated
// locally by the web-search emulation (gateway_websearch_emulation.go).
// No upstream ever issued them, so clients replaying the conversation
// (e.g. Claude Code) poison every follow-up request. They are stripped
// for all upstreams.
// 2. For passback-required upstreams (DeepSeek/Kimi/GLM …, see
// ResolveThinkingProtocol) all server_tool_use / web_search_tool_result
// blocks are stripped: these upstreams only accept
// text/thinking/image/tool_use/tool_result and reject anything else with
// 400 "invalid value: `server_tool_use`". anthropic-strict and unknown
// upstreams keep genuine blocks untouched.
//
// The emulated assistant turn always carries a trailing text summary, so the
// search context survives the strip. A message whose content would become
// empty gets a placeholder text block (mirroring FilterThinkingBlocksForRetry).
// Returns the original body unchanged when nothing needs stripping.
func FilterWebSearchHistoryBlocks(body []byte, mappedModel string) []byte {
if !bytes.Contains(body, patternServerToolUse) && !bytes.Contains(body, patternWebSearchToolResult) {
return body
}
stripAll := ResolveThinkingProtocol(mappedModel) == ThinkingProtocolPassbackRequired
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
}
// 延迟分配:只有命中需剥离的块才构建新 slice。
var newContent []any
for i, block := range content {
blockMap, isMap := block.(map[string]any)
if isMap && shouldStripWebSearchBlock(blockMap, stripAll) {
if newContent == nil {
newContent = make([]any, 0, len(content))
newContent = append(newContent, content[:i]...)
}
continue
}
if newContent != nil {
newContent = append(newContent, block)
}
}
if newContent == nil {
continue
}
modified = true
if len(newContent) == 0 {
role, _ := msgMap["role"].(string)
placeholder := "(content removed)"
if role == "assistant" {
placeholder = "(assistant content removed)"
}
newContent = []any{map[string]any{"type": "text", "text": placeholder}}
}
msgMap["content"] = newContent
}
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
}
func shouldStripWebSearchBlock(block map[string]any, stripAll bool) bool {
blockType, _ := block["type"].(string)
switch blockType {
case blockTypeServerToolUse:
if stripAll {
return true
}
id, _ := block["id"].(string)
return strings.HasPrefix(id, webSearchToolUseIDPrefix)
case blockTypeWebSearchToolResult:
if stripAll {
return true
}
id, _ := block["tool_use_id"].(string)
return strings.HasPrefix(id, webSearchToolUseIDPrefix)
default:
return false
}
}
@@ -0,0 +1,140 @@
//go:build unit
package service
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// emulatedWebSearchBody is a follow-up /v1/messages request whose history
// contains an assistant turn synthesized by the web-search emulation
// (server_tool_use + web_search_tool_result with the local srvtoolu_ws_ ID
// prefix, followed by the text summary).
const emulatedWebSearchBody = `{"model":"claude-sonnet-4-6","max_tokens":1024,"messages":[` +
`{"role":"user","content":[{"type":"text","text":"search the weather"}]},` +
`{"role":"assistant","content":[` +
`{"type":"server_tool_use","id":"srvtoolu_ws_0123456789abcdef","name":"web_search","input":{"query":"weather"}},` +
`{"type":"web_search_tool_result","tool_use_id":"srvtoolu_ws_0123456789abcdef","content":[{"type":"web_search_result","url":"https://example.com","title":"Weather"}]},` +
`{"type":"text","text":"Here are the search results for \"weather\":"}]},` +
`{"role":"user","content":[{"type":"text","text":"thanks, continue"}]}]}`
// genuineWebSearchBody carries real Anthropic web-search blocks (upstream IDs
// do NOT have the local srvtoolu_ws_ prefix).
const genuineWebSearchBody = `{"model":"claude-sonnet-4-6","max_tokens":1024,"messages":[` +
`{"role":"user","content":[{"type":"text","text":"search"}]},` +
`{"role":"assistant","content":[` +
`{"type":"server_tool_use","id":"srvtoolu_01ABCDEF","name":"web_search","input":{"query":"weather"}},` +
`{"type":"web_search_tool_result","tool_use_id":"srvtoolu_01ABCDEF","content":[{"type":"web_search_result","url":"https://example.com","title":"Weather"}]},` +
`{"type":"text","text":"summary with citations"}]}]}`
func collectContentTypes(t *testing.T, body []byte) []string {
t.Helper()
var types []string
for _, msg := range gjson.GetBytes(body, "messages").Array() {
for _, block := range msg.Get("content").Array() {
types = append(types, block.Get("type").String())
}
}
return types
}
func TestFilterWebSearchHistoryBlocks_StripsEmulatedBlocksForAnthropicStrict(t *testing.T) {
out := FilterWebSearchHistoryBlocks([]byte(emulatedWebSearchBody), "claude-sonnet-4-6")
require.Equal(t, []string{"text", "text", "text"}, collectContentTypes(t, out))
// The emulated text summary must survive so the search context is preserved.
require.Contains(t, string(out), "Here are the search results")
require.NotContains(t, string(out), "srvtoolu_ws_")
require.True(t, gjson.ValidBytes(out))
}
func TestFilterWebSearchHistoryBlocks_KeepsGenuineBlocksForAnthropicStrict(t *testing.T) {
body := []byte(genuineWebSearchBody)
out := FilterWebSearchHistoryBlocks(body, "claude-sonnet-4-6")
require.Equal(t, string(body), string(out))
}
func TestFilterWebSearchHistoryBlocks_StripsAllBlocksForPassbackRequired(t *testing.T) {
// GLM only accepts text/thinking/image/tool_use/tool_result and rejects
// server_tool_use with 400, so genuine blocks must be stripped as well.
out := FilterWebSearchHistoryBlocks([]byte(genuineWebSearchBody), "glm-4.7")
require.Equal(t, []string{"text", "text"}, collectContentTypes(t, out))
require.NotContains(t, string(out), "server_tool_use")
require.NotContains(t, string(out), "web_search_tool_result")
require.Contains(t, string(out), "summary with citations")
}
func TestFilterWebSearchHistoryBlocks_StripsEmulatedBlocksForUnknownModel(t *testing.T) {
out := FilterWebSearchHistoryBlocks([]byte(emulatedWebSearchBody), "totally-unknown-model")
require.Equal(t, []string{"text", "text", "text"}, collectContentTypes(t, out))
require.NotContains(t, string(out), "srvtoolu_ws_")
}
func TestFilterWebSearchHistoryBlocks_KeepsGenuineBlocksForUnknownModel(t *testing.T) {
body := []byte(genuineWebSearchBody)
out := FilterWebSearchHistoryBlocks(body, "totally-unknown-model")
require.Equal(t, string(body), string(out))
}
func TestFilterWebSearchHistoryBlocks_NoWebSearchBlocksFastPath(t *testing.T) {
body := []byte(`{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
out := FilterWebSearchHistoryBlocks(body, "claude-sonnet-4-6")
require.Equal(t, string(body), string(out))
}
func TestFilterWebSearchHistoryBlocks_EmptiedMessageGetsPlaceholder(t *testing.T) {
body := []byte(`{"model":"glm-4.7","messages":[` +
`{"role":"user","content":[{"type":"text","text":"search"}]},` +
`{"role":"assistant","content":[` +
`{"type":"server_tool_use","id":"srvtoolu_01X","name":"web_search","input":{"query":"q"}},` +
`{"type":"web_search_tool_result","tool_use_id":"srvtoolu_01X","content":[]}]}]}`)
out := FilterWebSearchHistoryBlocks(body, "glm-4.7")
msgs := gjson.GetBytes(out, "messages").Array()
require.Len(t, msgs, 2)
assistant := msgs[1]
require.Equal(t, "assistant", assistant.Get("role").String())
content := assistant.Get("content").Array()
require.Len(t, content, 1)
require.Equal(t, "text", content[0].Get("type").String())
require.Equal(t, "(assistant content removed)", content[0].Get("text").String())
}
func TestFilterWebSearchHistoryBlocks_StringContentUntouched(t *testing.T) {
// A string mentioning the pattern inside a text value must not trigger a rewrite.
body := []byte(`{"model":"claude-sonnet-4-6","messages":[` +
`{"role":"user","content":"please explain \"server_tool_use\" blocks"}]}`)
out := FilterWebSearchHistoryBlocks(body, "claude-sonnet-4-6")
require.Equal(t, string(body), string(out))
}
func TestFilterWebSearchHistoryBlocks_InvalidMessagesUnchanged(t *testing.T) {
body := []byte(`{"model":"claude-sonnet-4-6","messages":"server_tool_use"}`)
out := FilterWebSearchHistoryBlocks(body, "claude-sonnet-4-6")
require.Equal(t, string(body), string(out))
}
func TestFilterWebSearchHistoryBlocks_PreservesOtherToolBlocks(t *testing.T) {
body := []byte(`{"model":"glm-4.7","messages":[` +
`{"role":"assistant","content":[` +
`{"type":"tool_use","id":"toolu_01A","name":"get_weather","input":{}},` +
`{"type":"server_tool_use","id":"srvtoolu_ws_abc","name":"web_search","input":{"query":"q"}},` +
`{"type":"text","text":"result"}]},` +
`{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01A","content":"sunny"}]}]}`)
out := FilterWebSearchHistoryBlocks(body, "glm-4.7")
require.Equal(t, []string{"tool_use", "text", "tool_result"}, collectContentTypes(t, out))
}