mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #3778 from fengshao1227/fix/websearch-emulation-history-blocks
fix(websearch): 剥离历史消息中上游不接受的 web search 块
This commit is contained in:
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user