| 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 | // TestRealDeepSeekAnthropicToolLoop exercises the official Messages endpoint's |
| 17 | // unsigned thinking replay contract. It is build-tagged and credential-gated so |
| 18 | // ordinary CI remains deterministic and free of live API cost. |
| 19 | func TestRealDeepSeekAnthropicToolLoop(t *testing.T) { |
| 20 | key := os.Getenv("DEEPSEEK_API_KEY") |
| 21 | if key == "" { |
| 22 | t.Skip("DEEPSEEK_API_KEY not set — skipping live probe") |
| 23 | } |
| 24 | |
| 25 | p, err := New(provider.Config{ |
| 26 | Name: "deepseek-anthropic", |
| 27 | BaseURL: "https://api.deepseek.com/anthropic", |
| 28 | Model: "deepseek-v4-flash", |
| 29 | APIKey: key, |
| 30 | Extra: map[string]any{ |
| 31 | "api_key_env": "DEEPSEEK_API_KEY", |
| 32 | "thinking": "enabled", |
| 33 | "effort": "high", |
| 34 | }, |
| 35 | }) |
| 36 | if err != nil { |
| 37 | t.Fatalf("New: %v", err) |
| 38 | } |
| 39 | |
| 40 | tools := []provider.ToolSchema{{ |
| 41 | Name: "get_marker", |
| 42 | Description: "Return a fixed integration-test marker. Call this tool when the user asks for the marker.", |
| 43 | Parameters: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`), |
| 44 | }} |
| 45 | messages := []provider.Message{ |
| 46 | {Role: provider.RoleSystem, Content: "You are a concise tool-using assistant."}, |
| 47 | {Role: provider.RoleUser, Content: "Use get_marker to obtain the integration-test marker, then tell me the result."}, |
| 48 | } |
| 49 | |
| 50 | first := collectLiveDeepSeekTurn(t, p, provider.Request{Messages: messages, Tools: tools, MaxTokens: 512}) |
| 51 | if len(first.calls) == 0 { |
| 52 | t.Fatalf("first turn returned no tool call; text=%q reasoning_len=%d", first.text, len(first.reasoning)) |
| 53 | } |
| 54 | if strings.TrimSpace(first.reasoning) == "" { |
| 55 | t.Fatal("first tool-call turn returned no reasoning to replay") |
| 56 | } |
| 57 | |
| 58 | messages = append(messages, |
| 59 | provider.Message{ |
| 60 | Role: provider.RoleAssistant, |
| 61 | Content: first.text, |
| 62 | ReasoningContent: first.reasoning, |
| 63 | ToolCalls: first.calls, |
| 64 | }, |
| 65 | provider.Message{ |
| 66 | Role: provider.RoleTool, |
| 67 | ToolCallID: first.calls[0].ID, |
| 68 | Name: first.calls[0].Name, |
| 69 | Content: "protocol-round-trip-ok", |
| 70 | }, |
| 71 | ) |
| 72 | second := collectLiveDeepSeekTurn(t, p, provider.Request{Messages: messages, Tools: tools, MaxTokens: 512}) |
| 73 | if strings.TrimSpace(second.text) == "" { |
| 74 | t.Fatalf("second turn returned no visible answer; reasoning_len=%d calls=%d", len(second.reasoning), len(second.calls)) |
| 75 | } |
| 76 | t.Logf("first: reasoning=%d calls=%d prompt=%d cache_hit=%d; second: text=%d reasoning=%d prompt=%d cache_hit=%d", |
| 77 | len(first.reasoning), len(first.calls), first.promptTokens, first.cacheHitTokens, |
| 78 | len(second.text), len(second.reasoning), second.promptTokens, second.cacheHitTokens) |
| 79 | } |
| 80 | |
| 81 | type liveDeepSeekTurn struct { |
| 82 | text, reasoning string |
| 83 | calls []provider.ToolCall |
| 84 | promptTokens, cacheHitTokens int |
| 85 | } |
| 86 | |
| 87 | func collectLiveDeepSeekTurn(t *testing.T, p provider.Provider, req provider.Request) liveDeepSeekTurn { |
| 88 | t.Helper() |
| 89 | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) |
| 90 | defer cancel() |
| 91 | ch, err := p.Stream(ctx, req) |
| 92 | if err != nil { |
| 93 | t.Fatalf("Stream: %v", err) |
| 94 | } |
| 95 | var out liveDeepSeekTurn |
| 96 | var text, reasoning strings.Builder |
| 97 | for chunk := range ch { |
| 98 | switch chunk.Type { |
| 99 | case provider.ChunkText: |
| 100 | text.WriteString(chunk.Text) |
| 101 | case provider.ChunkReasoning: |
| 102 | reasoning.WriteString(chunk.Text) |
| 103 | case provider.ChunkToolCall: |
| 104 | if chunk.ToolCall != nil { |
| 105 | out.calls = append(out.calls, *chunk.ToolCall) |
| 106 | } |
| 107 | case provider.ChunkUsage: |
| 108 | if chunk.Usage != nil { |
| 109 | out.promptTokens = chunk.Usage.PromptTokens |
| 110 | out.cacheHitTokens = chunk.Usage.CacheHitTokens |
| 111 | } |
| 112 | case provider.ChunkError: |
| 113 | t.Fatalf("stream error: %v", chunk.Err) |
| 114 | } |
| 115 | } |
| 116 | out.text = text.String() |
| 117 | out.reasoning = reasoning.String() |
| 118 | return out |
| 119 | } |
| 120 |