返回 DeepSeek-Reasonix
realdeepseek_test.go
根目录 / internal / provider / anthropic / realdeepseek_test.go
1 //go:build live
2
3 package anthropic
4
5 import (
6 "context"
7 "encoding/json"
8 "os"
9 "strings"
10 "testing"
11 "time"
12
13 "reasonix/internal/provider"
14 )
15
16 // TestRealOpenCodeGoDeepSeekAnthropicWebSearch exercises Reasonix's complete
17 // Messages serialization and server-side web search parser against the OpenCode
18 // Go DeepSeek Flash route. The key stays process-local; ordinary CI never runs it.
19 func TestRealOpenCodeGoDeepSeekAnthropicWebSearch(t *testing.T) {
20 key := os.Getenv("OPENCODE_GO_API_KEY")
21 if key == "" {
22 t.Skip("OPENCODE_GO_API_KEY not set — skipping live probe")
23 }
24
25 p, err := New(provider.Config{
26 Name: "opencode-go-deepseek-anthropic",
27 BaseURL: "https://opencode.ai/zen/go",
28 Model: "deepseek-v4-flash",
29 APIKey: key,
30 Extra: map[string]any{
31 "api_key_env": "OPENCODE_GO_API_KEY",
32 "reasoning_protocol": "deepseek",
33 "thinking": "adaptive",
34 "effort": "high",
35 "web_search": true,
36 },
37 })
38 if err != nil {
39 t.Fatalf("New: %v", err)
40 }
41
42 turn := collectLiveDeepSeekTurn(t, p, provider.Request{Messages: []provider.Message{{
43 Role: provider.RoleUser, Content: "Search the web for the OpenCode Go documentation and reply with one source URL.",
44 }}, MaxTokens: 768})
45 if strings.TrimSpace(turn.text) == "" {
46 t.Fatalf("OpenCode Go Anthropic web_search returned no assistant text; reasoning_len=%d", len(turn.reasoning))
47 }
48 t.Logf("opencode-go-deepseek-anthropic web_search: text=%d reasoning=%d prompt=%d", len(turn.text), len(turn.reasoning), turn.promptTokens)
49 }
50
51 // TestRealOpenCodeGoDeepSeekAnthropicToolLoop verifies that the gateway accepts
52 // an assistant tool call and the corresponding tool result on the next request.
53 func TestRealOpenCodeGoDeepSeekAnthropicToolLoop(t *testing.T) {
54 key := os.Getenv("OPENCODE_GO_API_KEY")
55 if key == "" {
56 t.Skip("OPENCODE_GO_API_KEY not set — skipping live probe")
57 }
58 p, err := New(provider.Config{
59 Name: "opencode-go-deepseek-anthropic", BaseURL: "https://opencode.ai/zen/go", Model: "deepseek-v4-flash", APIKey: key,
60 Extra: map[string]any{
61 "api_key_env": "OPENCODE_GO_API_KEY", "reasoning_protocol": "deepseek",
62 "thinking": "adaptive", "effort": "high",
63 },
64 })
65 if err != nil {
66 t.Fatalf("New: %v", err)
67 }
68 tools := []provider.ToolSchema{{
69 Name: "get_marker", Description: "Return a fixed integration-test marker. Always call this tool when the user asks for the marker.",
70 Parameters: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`),
71 }}
72 messages := []provider.Message{
73 {Role: provider.RoleSystem, Content: "You are a concise tool-using assistant. Call the requested tool before answering."},
74 {Role: provider.RoleUser, Content: "Call get_marker, then report its result."},
75 }
76 first := collectLiveDeepSeekTurn(t, p, provider.Request{Messages: messages, Tools: tools, MaxTokens: 512})
77 if len(first.calls) == 0 {
78 t.Fatalf("OpenCode Go Anthropic returned no tool call; text_len=%d reasoning_len=%d", len(first.text), len(first.reasoning))
79 }
80 messages = append(messages,
81 provider.Message{Role: provider.RoleAssistant, Content: first.text, ReasoningContent: first.reasoning, ReasoningSignature: first.signature, ToolCalls: first.calls},
82 provider.Message{Role: provider.RoleTool, ToolCallID: first.calls[0].ID, Name: first.calls[0].Name, Content: "protocol-round-trip-ok"},
83 )
84 second := collectLiveDeepSeekTurn(t, p, provider.Request{Messages: messages, Tools: tools, MaxTokens: 512})
85 if strings.TrimSpace(second.text) == "" {
86 t.Fatalf("OpenCode Go Anthropic tool follow-up returned no text; reasoning_len=%d calls=%d", len(second.reasoning), len(second.calls))
87 }
88 t.Logf("opencode-go-deepseek-anthropic tool loop: calls=%d reasoning=%d signature=%d second_text=%d", len(first.calls), len(first.reasoning), len(first.signature), len(second.text))
89 }
90
91 // TestRealDeepSeekAnthropicToolLoop exercises the official Messages endpoint's
92 // unsigned thinking replay contract. It is build-tagged and credential-gated so
93 // ordinary CI remains deterministic and free of live API cost.
94 func TestRealDeepSeekAnthropicToolLoop(t *testing.T) {
95 key := os.Getenv("DEEPSEEK_API_KEY")
96 if key == "" {
97 t.Skip("DEEPSEEK_API_KEY not set — skipping live probe")
98 }
99
100 p, err := New(provider.Config{
101 Name: "deepseek-anthropic",
102 BaseURL: "https://api.deepseek.com/anthropic",
103 Model: "deepseek-v4-flash",
104 APIKey: key,
105 Extra: map[string]any{
106 "api_key_env": "DEEPSEEK_API_KEY",
107 "thinking": "enabled",
108 "effort": "high",
109 },
110 })
111 if err != nil {
112 t.Fatalf("New: %v", err)
113 }
114
115 tools := []provider.ToolSchema{{
116 Name: "get_marker",
117 Description: "Return a fixed integration-test marker. Call this tool when the user asks for the marker.",
118 Parameters: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`),
119 }}
120 messages := []provider.Message{
121 {Role: provider.RoleSystem, Content: "You are a concise tool-using assistant."},
122 {Role: provider.RoleUser, Content: "Use get_marker to obtain the integration-test marker, then tell me the result."},
123 }
124
125 first := collectLiveDeepSeekTurn(t, p, provider.Request{Messages: messages, Tools: tools, MaxTokens: 512})
126 if len(first.calls) == 0 {
127 t.Fatalf("first turn returned no tool call; text=%q reasoning_len=%d", first.text, len(first.reasoning))
128 }
129 if strings.TrimSpace(first.reasoning) == "" {
130 t.Fatal("first tool-call turn returned no reasoning to replay")
131 }
132
133 messages = append(messages,
134 provider.Message{
135 Role: provider.RoleAssistant,
136 Content: first.text,
137 ReasoningContent: first.reasoning,
138 ReasoningSignature: first.signature,
139 ToolCalls: first.calls,
140 },
141 provider.Message{
142 Role: provider.RoleTool,
143 ToolCallID: first.calls[0].ID,
144 Name: first.calls[0].Name,
145 Content: "protocol-round-trip-ok",
146 },
147 )
148 second := collectLiveDeepSeekTurn(t, p, provider.Request{Messages: messages, Tools: tools, MaxTokens: 512})
149 if strings.TrimSpace(second.text) == "" {
150 t.Fatalf("second turn returned no visible answer; reasoning_len=%d calls=%d", len(second.reasoning), len(second.calls))
151 }
152 t.Logf("first: reasoning=%d calls=%d prompt=%d cache_hit=%d; second: text=%d reasoning=%d prompt=%d cache_hit=%d",
153 len(first.reasoning), len(first.calls), first.promptTokens, first.cacheHitTokens,
154 len(second.text), len(second.reasoning), second.promptTokens, second.cacheHitTokens)
155 }
156
157 // TestRealDeepSeekAnthropicProjectsMissingThinkingHistory reproduces the
158 // malformed persisted-history shape behind DeepSeek's
159 // "content[].thinking must be passed back" HTTP 400. The provider boundary
160 // must project the unreplayable tool activity away before sending the request,
161 // while retaining the surrounding visible conversation.
162 func TestRealDeepSeekAnthropicProjectsMissingThinkingHistory(t *testing.T) {
163 key := os.Getenv("DEEPSEEK_API_KEY")
164 if key == "" {
165 t.Skip("DEEPSEEK_API_KEY not set — skipping live probe")
166 }
167
168 p, err := New(provider.Config{
169 Name: "deepseek-anthropic",
170 BaseURL: "https://api.deepseek.com/anthropic",
171 Model: "deepseek-v4-flash",
172 APIKey: key,
173 Extra: map[string]any{
174 "api_key_env": "DEEPSEEK_API_KEY",
175 "thinking": "enabled",
176 "effort": "high",
177 },
178 })
179 if err != nil {
180 t.Fatalf("New: %v", err)
181 }
182
183 turn := collectLiveDeepSeekTurn(t, p, provider.Request{
184 Messages: []provider.Message{
185 {Role: provider.RoleUser, Content: "Inspect the marker using the tool."},
186 {
187 Role: provider.RoleAssistant, Content: "I inspected the marker.",
188 ToolCalls: []provider.ToolCall{{ID: "legacy-call", Name: "get_marker", Arguments: `{}`}},
189 },
190 {Role: provider.RoleTool, ToolCallID: "legacy-call", Name: "get_marker", Content: "legacy-result"},
191 {Role: provider.RoleUser, Content: "Reply with the single word: recovered."},
192 },
193 MaxTokens: 256,
194 })
195 if strings.TrimSpace(turn.text) == "" {
196 t.Fatalf("projected missing-thinking history returned no assistant text")
197 }
198 t.Logf("missing-thinking projection: text=%d reasoning=%d prompt=%d", len(turn.text), len(turn.reasoning), turn.promptTokens)
199 }
200
201 // TestRealDeepSeekAnthropicWebSearch verifies that the official Anthropic
202 // compatibility endpoint accepts the server-side web_search tool and returns a
203 // normal assistant completion. It is intentionally separate from the tool-loop
204 // test so a provider-side search regression is visible on its own.
205 func TestRealDeepSeekAnthropicWebSearch(t *testing.T) {
206 key := os.Getenv("DEEPSEEK_API_KEY")
207 if key == "" {
208 t.Skip("DEEPSEEK_API_KEY not set — skipping live probe")
209 }
210
211 p, err := New(provider.Config{
212 Name: "deepseek-anthropic",
213 BaseURL: "https://api.deepseek.com/anthropic",
214 Model: "deepseek-v4-flash",
215 APIKey: key,
216 Extra: map[string]any{
217 "api_key_env": "DEEPSEEK_API_KEY",
218 "thinking": "enabled",
219 "effort": "high",
220 "web_search": true,
221 },
222 })
223 if err != nil {
224 t.Fatalf("New: %v", err)
225 }
226
227 turn := collectLiveDeepSeekTurn(t, p, provider.Request{
228 Messages: []provider.Message{{
229 Role: provider.RoleUser,
230 Content: "Search the web for the latest DeepSeek API documentation update and reply with one source URL.",
231 }},
232 MaxTokens: 256,
233 })
234 if strings.TrimSpace(turn.text) == "" {
235 t.Fatalf("web_search returned no assistant text (reasoning=%d)", len(turn.reasoning))
236 }
237 if strings.TrimSpace(turn.reasoning) == "" || len(turn.searches) == 0 {
238 t.Fatalf("web_search did not return replayable reasoning/search blocks: reasoning=%d searches=%d", len(turn.reasoning), len(turn.searches))
239 }
240 continued := collectLiveDeepSeekTurn(t, p, provider.Request{Messages: []provider.Message{
241 {Role: provider.RoleUser, Content: "Search the web for the latest DeepSeek API documentation update and reply with one source URL."},
242 {Role: provider.RoleAssistant, Content: turn.text, ReasoningContent: turn.reasoning, ServerSearch: turn.searches},
243 {Role: provider.RoleUser, Content: "Without searching again, reply with the hostname of that source."},
244 }, MaxTokens: 256})
245 if strings.TrimSpace(continued.text) == "" {
246 t.Fatalf("web_search continuation returned no assistant text (reasoning=%d searches=%d)", len(continued.reasoning), len(continued.searches))
247 }
248 t.Logf("web_search replay: text=%d reasoning=%d searches=%d prompt=%d cache_hit=%d continuation_text=%d",
249 len(turn.text), len(turn.reasoning), len(turn.searches), turn.promptTokens, turn.cacheHitTokens, len(continued.text))
250 }
251
252 // TestRealDeepSeekAnthropicIgnoresImages confirms the text-only official
253 // endpoint still completes when stale vision metadata accompanies a user turn.
254 // The provider-boundary filter is what prevents the historical image_url 400.
255 func TestRealDeepSeekAnthropicIgnoresImages(t *testing.T) {
256 key := os.Getenv("DEEPSEEK_API_KEY")
257 if key == "" {
258 t.Skip("DEEPSEEK_API_KEY not set — skipping live probe")
259 }
260
261 p, err := New(provider.Config{
262 Name: "deepseek-anthropic",
263 BaseURL: "https://api.deepseek.com/anthropic",
264 Model: "deepseek-v4-flash",
265 APIKey: key,
266 Extra: map[string]any{
267 "api_key_env": "DEEPSEEK_API_KEY",
268 "vision": true,
269 "thinking": "disabled",
270 },
271 })
272 if err != nil {
273 t.Fatalf("New: %v", err)
274 }
275
276 turn := collectLiveDeepSeekTurn(t, p, provider.Request{Messages: []provider.Message{{
277 Role: provider.RoleUser,
278 Content: "Reply with the single word: ok.",
279 Images: []string{"data:image/png;base64,AAAA"},
280 }}, MaxTokens: 64})
281 if strings.TrimSpace(turn.text) == "" {
282 t.Fatalf("text-only image-filter smoke returned no assistant text")
283 }
284 t.Logf("image-filter: text=%d prompt=%d cache_hit=%d", len(turn.text), turn.promptTokens, turn.cacheHitTokens)
285 }
286
287 type liveDeepSeekTurn struct {
288 text, reasoning, signature string
289 calls []provider.ToolCall
290 searches []provider.ServerSearchCall
291 promptTokens, cacheHitTokens int
292 }
293
294 func collectLiveDeepSeekTurn(t *testing.T, p provider.Provider, req provider.Request) liveDeepSeekTurn {
295 return collectLiveDeepSeekTurnContext(t, context.Background(), p, req)
296 }
297
298 func collectLiveDeepSeekTurnContext(t *testing.T, parent context.Context, p provider.Provider, req provider.Request) liveDeepSeekTurn {
299 t.Helper()
300 ctx, cancel := context.WithTimeout(parent, 60*time.Second)
301 defer cancel()
302 ch, err := p.Stream(ctx, req)
303 if err != nil {
304 t.Fatalf("Stream: %v", err)
305 }
306 var out liveDeepSeekTurn
307 var text, reasoning strings.Builder
308 for chunk := range ch {
309 switch chunk.Type {
310 case provider.ChunkText:
311 text.WriteString(chunk.Text)
312 case provider.ChunkReasoning:
313 reasoning.WriteString(chunk.Text)
314 if chunk.Signature != "" {
315 out.signature = chunk.Signature
316 }
317 case provider.ChunkToolCall:
318 if chunk.ToolCall != nil {
319 out.calls = append(out.calls, *chunk.ToolCall)
320 }
321 case provider.ChunkServerSearch:
322 if chunk.ServerSearch != nil {
323 out.searches = provider.MergeServerSearch(out.searches, *chunk.ServerSearch)
324 }
325 case provider.ChunkUsage:
326 if chunk.Usage != nil {
327 out.promptTokens = chunk.Usage.PromptTokens
328 out.cacheHitTokens = chunk.Usage.CacheHitTokens
329 }
330 case provider.ChunkError:
331 t.Fatalf("stream error: %v", chunk.Err)
332 }
333 }
334 out.text = text.String()
335 out.reasoning = reasoning.String()
336 return out
337 }
338
338 lines GO