From c4d883b8da728d19ca065db2398e1a27db5f9d5f Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Thu, 13 Aug 2026 08:37:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Chat=20=E4=B8=8E=20Responses=20?= =?UTF-8?q?=E5=BE=80=E8=BF=94=E4=BF=9D=E7=95=99=20x=5Fsearch=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E8=A1=A5=20sources=20=E6=8A=BD=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat Completions 的 {"type":"x_search"} 会被 apicompat 丢掉,独立 /x_search 又没带 include 与结构化提示,主路径容易返回空结果仍计费。 - Chat↔Responses 保留 x_search 过滤字段与 tool_choice - declared 只注册实际存活的 x_search,web_search 选择项仍丢弃 - 上游补 include x_search_call.action.sources 与结构化输出提示 --- backend/internal/handler/openai_x_search.go | 15 +++- .../internal/handler/openai_x_search_test.go | 6 +- .../chatcompletions_responses_bridge.go | 22 ++++++ ...ions_responses_bridge_custom_tools_test.go | 14 ++++ .../apicompat/chatcompletions_to_responses.go | 12 +++ .../chatcompletions_x_search_test.go | 79 +++++++++++++++++++ backend/internal/pkg/apicompat/types.go | 20 ++++- 7 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 backend/internal/pkg/apicompat/chatcompletions_x_search_test.go diff --git a/backend/internal/handler/openai_x_search.go b/backend/internal/handler/openai_x_search.go index 2c4e9dcdad..a583cec0bf 100644 --- a/backend/internal/handler/openai_x_search.go +++ b/backend/internal/handler/openai_x_search.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "fmt" "strings" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" @@ -55,12 +56,24 @@ func buildGrokXSearchResponsesBody(req grokStandaloneSearchRequest, model string if req.EnableVideoUnderstanding != nil { tool["enable_video_understanding"] = *req.EnableVideoUnderstanding } + maxResults := 0 + if req.MaxResults != nil { + maxResults = *req.MaxResults + } return json.Marshal(map[string]any{ "model": xai.ResolveDefaultTextModel(model), - "input": input, + "input": buildGrokXSearchPrompt(input, maxResults), "tools": []map[string]any{tool}, "tool_choice": "required", + "include": []string{"x_search_call.action.sources"}, "store": false, "stream": false, }) } + +func buildGrokXSearchPrompt(query string, maxResults int) string { + return fmt.Sprintf(`Search X for the user query below. Return ONLY valid JSON with this exact shape: {"results":[{"url":"https://...","title":"post or page title","snippet":"concise factual summary"}]}. Return at most %d unique results. Every URL must be an actual x_search source. Populate a non-empty title and snippet for every result. Do not wrap the JSON in markdown. + +User query: +%s`, normalizeGrokWebSearchMaxResults(maxResults), query) +} diff --git a/backend/internal/handler/openai_x_search_test.go b/backend/internal/handler/openai_x_search_test.go index d5cabdba8d..6747e6c803 100644 --- a/backend/internal/handler/openai_x_search_test.go +++ b/backend/internal/handler/openai_x_search_test.go @@ -23,7 +23,9 @@ func TestBuildGrokXSearchResponsesBody(t *testing.T) { }, xai.DefaultTextModel) require.NoError(t, err) require.Equal(t, xai.DefaultTextModel, gjson.GetBytes(body, "model").String()) - require.Equal(t, "latest posts from xAI", gjson.GetBytes(body, "input").String()) + require.Contains(t, gjson.GetBytes(body, "input").String(), "latest posts from xAI") + require.Contains(t, gjson.GetBytes(body, "input").String(), "Return ONLY valid JSON") + require.Equal(t, "x_search_call.action.sources", gjson.GetBytes(body, "include.0").String()) require.Equal(t, "required", gjson.GetBytes(body, "tool_choice").String()) require.Equal(t, "x_search", gjson.GetBytes(body, "tools.0.type").String()) require.Equal(t, "xai", gjson.GetBytes(body, "tools.0.allowed_x_handles.0").String()) @@ -40,7 +42,7 @@ func TestBuildGrokXSearchResponsesBodyAcceptsInputAlias(t *testing.T) { t.Parallel() body, err := buildGrokXSearchResponsesBody(grokStandaloneSearchRequest{Input: "latest posts from xAI"}, xai.DefaultTextModel) require.NoError(t, err) - require.Equal(t, "latest posts from xAI", gjson.GetBytes(body, "input").String()) + require.Contains(t, gjson.GetBytes(body, "input").String(), "latest posts from xAI") } func TestResolveGrokStandaloneSearchModelUsesRuntimeDefault(t *testing.T) { diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index 4e20fe4216..70dee035d8 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -63,6 +63,9 @@ func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsR if tool.Function != nil { declared[tool.Function.Name] = true } + if strings.EqualFold(strings.TrimSpace(tool.Type), "x_search") { + declared["x_search"] = true + } } if tc := responsesToolChoiceToChatToolChoice(req.ToolChoice, declared); len(tc) > 0 { out.ToolChoice = tc @@ -847,6 +850,16 @@ func responsesToolsToChatTools(tools []ResponsesTool) ([]ChatTool, error) { return nil, err } out = append(out, flattened...) + case "x_search": + out = append(out, ChatTool{ + Type: "x_search", + AllowedXHandles: tool.AllowedXHandles, + ExcludedXHandles: tool.ExcludedXHandles, + FromDate: tool.FromDate, + ToDate: tool.ToDate, + EnableImageUnderstanding: tool.EnableImageUnderstanding, + EnableVideoUnderstanding: tool.EnableVideoUnderstanding, + }) } // 其余类型(web_search、image_generation 等服务端工具)在 chat 上游没有 // 对应能力,维持丢弃。 @@ -948,6 +961,15 @@ func responsesToolChoiceToChatToolChoice(raw json.RawMessage, declared map[strin } var name string switch rawString(choice["type"]) { + case "x_search": + if !declared["x_search"] { + return nil + } + out, err := json.Marshal(map[string]any{"type": "x_search"}) + if err != nil { + return raw + } + return out case "tool_search": // tool_search 未被丢弃而是降级为同名 function 代理(见 // responsesToolsToChatTools),强制选择它同样降级为 function 选择, diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go index 6265a64d0a..945900d6d6 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go @@ -661,6 +661,20 @@ func TestResponsesToChatCompletionsRequest_DropsToolChoiceForDroppedTool(t *test require.Len(t, out.Tools, 1) assert.Empty(t, out.ToolChoice, "指向被丢弃服务端工具的 tool_choice 必须丢弃") + out, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{ + {Type: "function", Name: "wait", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)}, + {Type: "web_search"}, + {Type: "x_search"}, + }, + ToolChoice: json.RawMessage(`{"type":"function","name":"web_search"}`), + }) + require.NoError(t, err) + require.Len(t, out.Tools, 2) + assert.Empty(t, out.ToolChoice, "surviving x_search must not keep a function tool_choice named web_search") + // 具名选择指向不存在的工具名。 out, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{ Model: "glm-5.2", diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index 0f65d217e8..6a3c751f13 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -419,6 +419,18 @@ func convertChatToolsToResponses(tools []ChatTool, functions []ChatFunction) []R var out []ResponsesTool for _, t := range tools { + if strings.EqualFold(strings.TrimSpace(t.Type), "x_search") { + out = append(out, ResponsesTool{ + Type: "x_search", + AllowedXHandles: t.AllowedXHandles, + ExcludedXHandles: t.ExcludedXHandles, + FromDate: t.FromDate, + ToDate: t.ToDate, + EnableImageUnderstanding: t.EnableImageUnderstanding, + EnableVideoUnderstanding: t.EnableVideoUnderstanding, + }) + continue + } if t.Type != "function" || t.Function == nil { continue } diff --git a/backend/internal/pkg/apicompat/chatcompletions_x_search_test.go b/backend/internal/pkg/apicompat/chatcompletions_x_search_test.go new file mode 100644 index 0000000000..2ffbea2a3b --- /dev/null +++ b/backend/internal/pkg/apicompat/chatcompletions_x_search_test.go @@ -0,0 +1,79 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestChatCompletionsToResponsesPreservesXSearchTool(t *testing.T) { + enabled := true + req := &ChatCompletionsRequest{ + Model: "grok-4.5", + Messages: []ChatMessage{ + {Role: "user", Content: json.RawMessage(`"latest xAI post"`)}, + }, + Tools: []ChatTool{{ + Type: "x_search", + AllowedXHandles: []string{"xai"}, + ExcludedXHandles: []string{"spam"}, + FromDate: "2026-08-01", + ToDate: "2026-08-10", + EnableImageUnderstanding: &enabled, + EnableVideoUnderstanding: &enabled, + }}, + ToolChoice: json.RawMessage(`{"type":"x_search"}`), + } + + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + require.Len(t, resp.Tools, 1) + require.Equal(t, "x_search", resp.Tools[0].Type) + require.Equal(t, []string{"xai"}, resp.Tools[0].AllowedXHandles) + require.Equal(t, []string{"spam"}, resp.Tools[0].ExcludedXHandles) + require.Equal(t, "2026-08-01", resp.Tools[0].FromDate) + require.Equal(t, "2026-08-10", resp.Tools[0].ToDate) + require.NotNil(t, resp.Tools[0].EnableImageUnderstanding) + require.True(t, *resp.Tools[0].EnableImageUnderstanding) + require.NotNil(t, resp.Tools[0].EnableVideoUnderstanding) + require.True(t, *resp.Tools[0].EnableVideoUnderstanding) + require.JSONEq(t, `{"type":"x_search"}`, string(resp.ToolChoice)) +} + +func TestResponsesToChatCompletionsPreservesXSearchTool(t *testing.T) { + enabled := true + req := &ResponsesRequest{ + Model: "grok-4.5", + Input: json.RawMessage(`"latest xAI post"`), + Tools: []ResponsesTool{{ + Type: "x_search", + AllowedXHandles: []string{"xai"}, + ExcludedXHandles: []string{"spam"}, + FromDate: "2026-08-01", + ToDate: "2026-08-10", + EnableImageUnderstanding: &enabled, + EnableVideoUnderstanding: &enabled, + }}, + ToolChoice: json.RawMessage(`{"type":"x_search"}`), + } + + chat, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + require.Len(t, chat.Tools, 1) + require.Equal(t, "x_search", chat.Tools[0].Type) + require.Equal(t, []string{"xai"}, chat.Tools[0].AllowedXHandles) + require.Equal(t, []string{"spam"}, chat.Tools[0].ExcludedXHandles) + require.JSONEq(t, `{"type":"x_search"}`, string(chat.ToolChoice)) +} + +func TestResponsesToChatCompletionsXSearchToolChoiceString(t *testing.T) { + chat, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "grok-4.5", + Input: json.RawMessage(`"latest xAI post"`), + Tools: []ResponsesTool{{Type: "x_search"}}, + ToolChoice: json.RawMessage(`"x_search"`), + }) + require.NoError(t, err) + require.JSONEq(t, `"x_search"`, string(chat.ToolChoice)) +} diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index c484a7e589..bcc8cd481e 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -302,7 +302,7 @@ type ResponsesContentPart struct { // ResponsesTool describes a tool in the Responses API. type ResponsesTool struct { - Type string `json:"type"` // "function" | "custom" | "web_search" | "local_shell" etc. + Type string `json:"type"` // "function" | "custom" | "web_search" | "x_search" | "local_shell" etc. Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` Parameters json.RawMessage `json:"parameters,omitempty"` @@ -311,6 +311,14 @@ type ResponsesTool struct { // type=namespace 的子工具列表(tools 与 children 二选一,语义相同)。 Tools []ResponsesTool `json:"tools,omitempty"` Children []ResponsesTool `json:"children,omitempty"` + + // type=x_search + AllowedXHandles []string `json:"allowed_x_handles,omitempty"` + ExcludedXHandles []string `json:"excluded_x_handles,omitempty"` + FromDate string `json:"from_date,omitempty"` + ToDate string `json:"to_date,omitempty"` + EnableImageUnderstanding *bool `json:"enable_image_understanding,omitempty"` + EnableVideoUnderstanding *bool `json:"enable_video_understanding,omitempty"` } // UnmarshalJSON 容忍字符串形式的工具声明:codex 会以 "name" 简写声明 custom 工具, @@ -675,8 +683,16 @@ type ChatImageURL struct { // ChatTool describes a tool available to the model. type ChatTool struct { - Type string `json:"type"` // "function" + Type string `json:"type"` // "function" | "x_search" Function *ChatFunction `json:"function,omitempty"` + + // type=x_search + AllowedXHandles []string `json:"allowed_x_handles,omitempty"` + ExcludedXHandles []string `json:"excluded_x_handles,omitempty"` + FromDate string `json:"from_date,omitempty"` + ToDate string `json:"to_date,omitempty"` + EnableImageUnderstanding *bool `json:"enable_image_understanding,omitempty"` + EnableVideoUnderstanding *bool `json:"enable_video_understanding,omitempty"` } // ChatFunction describes a function tool definition.