| 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 |