返回 DeepSeek-Reasonix
web_search_test.go
根目录 / internal / provider / anthropic / web_search_test.go
1 package anthropic
2
3 import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "strings"
9 "testing"
10
11 "reasonix/internal/provider"
12 )
13
14 // TestBuildRequestWebSearchServerTool covers the tools-array shape when the
15 // server-side web_search tool is enabled: it is prepended as a typed entry
16 // without an input_schema, and named tools keep their schema untouched.
17 func TestBuildRequestWebSearchServerTool(t *testing.T) {
18 c := &client{name: "deepseek", model: "deepseek-v4-flash", search: provider.SearchPolicy{NativeEnabled: true}}
19 r := c.buildRequest(context.Background(), provider.Request{
20 Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
21 Tools: []provider.ToolSchema{{Name: "read_file", Parameters: json.RawMessage(`{"type":"object"}`)}},
22 })
23 if len(r.Tools) != 2 {
24 t.Fatalf("want 2 tools (web_search + read_file), got %d: %+v", len(r.Tools), r.Tools)
25 }
26 if r.Tools[0].Type != "web_search_20250305" || r.Tools[0].Name != "web_search" {
27 t.Fatalf("tools[0] = %+v, want typed web_search server tool", r.Tools[0])
28 }
29 if len(r.Tools[0].InputSchema) != 0 {
30 t.Fatalf("server tool must not carry input_schema, got %s", r.Tools[0].InputSchema)
31 }
32 if r.Tools[1].Type != "" || r.Tools[1].Name != "read_file" || len(r.Tools[1].InputSchema) == 0 {
33 t.Fatalf("tools[1] = %+v, want named tool with schema and no type", r.Tools[1])
34 }
35
36 // Disabled (default) ⇒ no server tool is injected.
37 off := &client{name: "deepseek", model: "deepseek-v4-flash"}
38 r = off.buildRequest(context.Background(), provider.Request{
39 Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
40 Tools: []provider.ToolSchema{{Name: "read_file", Parameters: json.RawMessage(`{"type":"object"}`)}},
41 })
42 if len(r.Tools) != 1 || r.Tools[0].Name != "read_file" {
43 t.Fatalf("webSearch off: tools = %+v, want only read_file", r.Tools)
44 }
45 }
46
47 // TestAnthToolWireShape pins the JSON encoding both tool kinds put on the wire:
48 // the typed server tool omits input_schema entirely, and the omitempty on
49 // input_schema must not leak into named tools (every named tool keeps a schema
50 // because buildRequest substitutes a default for empty parameters).
51 func TestAnthToolWireShape(t *testing.T) {
52 server, err := json.Marshal(anthTool{Type: "web_search_20250305", Name: "web_search"})
53 if err != nil {
54 t.Fatalf("marshal server tool: %v", err)
55 }
56 if got := string(server); got != `{"type":"web_search_20250305","name":"web_search"}` {
57 t.Fatalf("server tool wire = %s", got)
58 }
59
60 named, err := json.Marshal(anthTool{Name: "read_file", InputSchema: json.RawMessage(`{"type":"object"}`)})
61 if err != nil {
62 t.Fatalf("marshal named tool: %v", err)
63 }
64 if got := string(named); got != `{"name":"read_file","input_schema":{"type":"object"}}` {
65 t.Fatalf("named tool wire = %s", got)
66 }
67 }
68
69 func TestFormatWebSearchResults(t *testing.T) {
70 cases := []struct {
71 name string
72 raw string
73 want string
74 }{
75 {"empty payload", "", ""},
76 {"malformed json", `{"not":"an array"`, ""},
77 {"non-array json", `{"title":"x"}`, ""},
78 {"empty array", `[]`, ""},
79 {"all entries blank", `[{"text":"body only"},{}]`, ""},
80 {
81 // DeepSeek returns encrypted_content alongside title/url; unknown
82 // fields must be ignored rather than failing the whole block.
83 "titles and urls",
84 `[{"type":"web_search_result","title":"Change Log","url":"https://api-docs.deepseek.com/updates/","encrypted_content":"xxx"},{"title":"No URL"}]`,
85 "\n\n- **Change Log**\n <https://api-docs.deepseek.com/updates/>\n- **No URL**\n",
86 },
87 }
88 for _, tc := range cases {
89 t.Run(tc.name, func(t *testing.T) {
90 if got := formatWebSearchResults(json.RawMessage(tc.raw)); got != tc.want {
91 t.Fatalf("formatWebSearchResults(%s) = %q, want %q", tc.raw, got, tc.want)
92 }
93 })
94 }
95 }
96
97 // TestStreamSurfacesWebSearchResults drives a full SSE round-trip: a
98 // web_search_tool_result block must surface as a typed search chunk, not
99 // assistant text, and server_tool_use must not look like a client tool call.
100 func TestStreamSurfacesWebSearchResults(t *testing.T) {
101 sse := strings.Join([]string{
102 `data: {"type":"message_start","message":{"usage":{"input_tokens":10}}}`,
103 ``,
104 `data: {"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"s1","name":"web_search"}}`,
105 ``,
106 `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"latest\"}"}}`,
107 ``,
108 `data: {"type":"content_block_start","index":1,"content_block":{"type":"web_search_tool_result","tool_use_id":"s1","content":[{"title":"Change Log","url":"https://api-docs.deepseek.com/updates/"}]}}`,
109 ``,
110 `data: {"type":"content_block_start","index":2,"content_block":{"type":"text"}}`,
111 ``,
112 `data: {"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"answer"}}`,
113 ``,
114 `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`,
115 ``,
116 `data: {"type":"message_stop"}`,
117 ``,
118 }, "\n")
119 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
120 w.Header().Set("Content-Type", "text/event-stream")
121 _, _ = w.Write([]byte(sse))
122 }))
123 defer srv.Close()
124
125 p, err := New(provider.Config{Name: "deepseek", BaseURL: srv.URL, Model: "deepseek-v4-flash", APIKey: "k", Extra: map[string]any{"web_search": true}})
126 if err != nil {
127 t.Fatalf("New: %v", err)
128 }
129 ch, err := p.Stream(context.Background(), provider.Request{
130 Messages: []provider.Message{{Role: provider.RoleUser, Content: "search something"}},
131 })
132 if err != nil {
133 t.Fatalf("Stream: %v", err)
134 }
135
136 var text strings.Builder
137 var searches []provider.ServerSearchCall
138 for chunk := range ch {
139 switch chunk.Type {
140 case provider.ChunkText:
141 text.WriteString(chunk.Text)
142 case provider.ChunkServerSearch:
143 if chunk.ServerSearch != nil {
144 searches = provider.MergeServerSearch(searches, *chunk.ServerSearch)
145 }
146 case provider.ChunkToolCallStart, provider.ChunkToolCall:
147 t.Fatalf("server-side search must not surface as a client tool call, got %+v", chunk)
148 case provider.ChunkError:
149 t.Fatalf("stream error: %v", chunk.Err)
150 }
151 }
152 if text.String() != "answer" {
153 t.Fatalf("answer text = %q, want only the model reply", text.String())
154 }
155 if len(searches) != 1 || searches[0].ID != "s1" || searches[0].Query != "latest" || len(searches[0].Results) != 1 || searches[0].Results[0].Title != "Change Log" {
156 t.Fatalf("searches = %#v", searches)
157 }
158 }
159
160 // TestStreamSurfacesWebSearchResultDelta covers streams that deliver the
161 // result array in a web_search_tool_result_delta after an empty block start
162 // instead of inlining it in the block-start content.
163 func TestStreamSurfacesWebSearchResultDelta(t *testing.T) {
164 sse := strings.Join([]string{
165 `data: {"type":"message_start","message":{"usage":{"input_tokens":10}}}`,
166 ``,
167 `data: {"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"s1","name":"web_search"}}`,
168 ``,
169 `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"latest\"}"}}`,
170 ``,
171 `data: {"type":"content_block_start","index":1,"content_block":{"type":"web_search_tool_result","tool_use_id":"s1","content":[]}}`,
172 ``,
173 `data: {"type":"content_block_delta","index":1,"delta":{"type":"web_search_tool_result_delta","results":[{"title":"Change Log","url":"https://api-docs.deepseek.com/updates/"}]}}`,
174 ``,
175 `data: {"type":"content_block_start","index":2,"content_block":{"type":"text"}}`,
176 ``,
177 `data: {"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"answer"}}`,
178 ``,
179 `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`,
180 ``,
181 `data: {"type":"message_stop"}`,
182 ``,
183 }, "\n")
184 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
185 w.Header().Set("Content-Type", "text/event-stream")
186 _, _ = w.Write([]byte(sse))
187 }))
188 defer srv.Close()
189
190 p, err := New(provider.Config{Name: "deepseek", BaseURL: srv.URL, Model: "deepseek-v4-flash", APIKey: "k", Extra: map[string]any{"web_search": true}})
191 if err != nil {
192 t.Fatalf("New: %v", err)
193 }
194 ch, err := p.Stream(context.Background(), provider.Request{
195 Messages: []provider.Message{{Role: provider.RoleUser, Content: "search something"}},
196 })
197 if err != nil {
198 t.Fatalf("Stream: %v", err)
199 }
200
201 var text strings.Builder
202 var searches []provider.ServerSearchCall
203 for chunk := range ch {
204 switch chunk.Type {
205 case provider.ChunkText:
206 text.WriteString(chunk.Text)
207 case provider.ChunkServerSearch:
208 if chunk.ServerSearch != nil {
209 searches = provider.MergeServerSearch(searches, *chunk.ServerSearch)
210 }
211 case provider.ChunkError:
212 t.Fatalf("stream error: %v", chunk.Err)
213 }
214 }
215 if text.String() != "answer" {
216 t.Fatalf("answer text = %q, want only the model reply", text.String())
217 }
218 if len(searches) != 1 || searches[0].ID != "s1" || searches[0].Query != "latest" || len(searches[0].Results) != 1 || searches[0].Results[0].Title != "Change Log" {
219 t.Fatalf("delta-delivered searches = %#v", searches)
220 }
221 }
222
223 func TestBuildRequestReplaysServerSearchBlocks(t *testing.T) {
224 c := &client{name: "deepseek", model: "deepseek-v4-flash", search: provider.SearchPolicy{NativeEnabled: true}}
225 raw := json.RawMessage(`[{"title":"Change Log","url":"https://api-docs.deepseek.com/updates/","encrypted_content":"xxx"}]`)
226 r := c.buildRequest(context.Background(), provider.Request{
227 Messages: []provider.Message{{
228 Role: provider.RoleAssistant,
229 Content: "answer",
230 ServerSearch: []provider.ServerSearchCall{{
231 ID: "s1", Query: "latest", Raw: raw,
232 }},
233 }},
234 })
235 if len(r.Messages) != 1 {
236 t.Fatalf("messages = %d", len(r.Messages))
237 }
238 blocks := r.Messages[0].Content
239 if len(blocks) != 3 {
240 t.Fatalf("blocks = %#v", blocks)
241 }
242 if blocks[0].Type != "server_tool_use" || blocks[0].ID != "s1" || blocks[0].Name != "web_search" || !strings.Contains(string(blocks[0].Input), "latest") {
243 t.Fatalf("server_tool_use = %+v", blocks[0])
244 }
245 if blocks[1].Type != "web_search_tool_result" || blocks[1].ToolUseID != "s1" {
246 t.Fatalf("web_search_tool_result = %+v", blocks[1])
247 }
248 gotRaw, _ := json.Marshal(blocks[1].Content)
249 if !strings.Contains(string(gotRaw), "encrypted_content") {
250 t.Fatalf("replay dropped encrypted_content: %s", gotRaw)
251 }
252 if blocks[2].Type != "text" || blocks[2].Text != "answer" {
253 t.Fatalf("text = %+v", blocks[2])
254 }
255 }
256
257 func TestBuildRequestDeepSeekReplaysThinkingBeforeServerSearch(t *testing.T) {
258 c := &client{name: "deepseek", model: "deepseek-v4-flash", deepseek: true, thinking: "enabled", search: provider.SearchPolicy{NativeEnabled: true}}
259 r := c.buildRequest(context.Background(), provider.Request{Messages: []provider.Message{{
260 Role: provider.RoleAssistant, Content: "answer", ReasoningContent: "search first",
261 ServerSearch: []provider.ServerSearchCall{{
262 ID: "s1", Query: "latest", Raw: json.RawMessage(`[{"title":"Change Log","encrypted_content":"xxx"}]`),
263 }},
264 }}})
265 blocks := r.Messages[0].Content
266 if len(blocks) != 4 {
267 t.Fatalf("blocks = %#v", blocks)
268 }
269 if blocks[0].Type != "thinking" || blocks[0].Thinking != "search first" || blocks[0].Signature != "" {
270 t.Fatalf("thinking = %+v", blocks[0])
271 }
272 if blocks[1].Type != "server_tool_use" || blocks[2].Type != "web_search_tool_result" || blocks[3].Type != "text" {
273 t.Fatalf("block order = %#v", blocks)
274 }
275 }
276
277 func TestBuildRequestDeepSeekProjectsMissingThinkingServerSearchToPlainText(t *testing.T) {
278 c := &client{name: "deepseek", model: "deepseek-v4-flash", deepseek: true, thinking: "enabled", search: provider.SearchPolicy{NativeEnabled: true}}
279 r := c.buildRequest(context.Background(), provider.Request{Messages: []provider.Message{{
280 Role: provider.RoleAssistant, Content: "answer",
281 ServerSearch: []provider.ServerSearchCall{{ID: "s1", Query: "latest", Raw: json.RawMessage(`[]`)}},
282 }}})
283 blocks := r.Messages[0].Content
284 if len(blocks) != 1 || blocks[0].Type != "text" || blocks[0].Text != "answer" {
285 t.Fatalf("unreplayable search was not projected to plain text: %#v", blocks)
286 }
287 }
288
289 func TestBuildRequestDeepSeekOrdersThinkingSearchTextAndClientTool(t *testing.T) {
290 c := &client{name: "deepseek", model: "deepseek-v4-flash", deepseek: true, thinking: "enabled", search: provider.SearchPolicy{NativeEnabled: true}}
291 r := c.buildRequest(context.Background(), provider.Request{Messages: []provider.Message{{
292 Role: provider.RoleAssistant, Content: "checking", ReasoningContent: "use both",
293 ServerSearch: []provider.ServerSearchCall{{ID: "s1", Query: "latest", Raw: json.RawMessage(`[]`)}},
294 ToolCalls: []provider.ToolCall{{ID: "t1", Name: "read_file", Arguments: `{"path":"main.go"}`}},
295 }}})
296 blocks := r.Messages[0].Content
297 want := []string{"thinking", "server_tool_use", "web_search_tool_result", "text", "tool_use"}
298 if len(blocks) != len(want) {
299 t.Fatalf("blocks = %#v", blocks)
300 }
301 for i, typ := range want {
302 if blocks[i].Type != typ {
303 t.Fatalf("block[%d].type = %q, want %q; blocks=%#v", i, blocks[i].Type, typ, blocks)
304 }
305 }
306 }
307
307 lines GO