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