diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index dcaf3a645c..2ca554edb3 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -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 { diff --git a/backend/internal/service/gateway_websearch_block_filter.go b/backend/internal/service/gateway_websearch_block_filter.go new file mode 100644 index 0000000000..a0706c5c2d --- /dev/null +++ b/backend/internal/service/gateway_websearch_block_filter.go @@ -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 + } +} diff --git a/backend/internal/service/gateway_websearch_block_filter_test.go b/backend/internal/service/gateway_websearch_block_filter_test.go new file mode 100644 index 0000000000..cebda4d37b --- /dev/null +++ b/backend/internal/service/gateway_websearch_block_filter_test.go @@ -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)) +}