| 1 | //go:build live |
| 2 | |
| 3 | package boot |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "encoding/base64" |
| 9 | "encoding/json" |
| 10 | "image" |
| 11 | "image/color" |
| 12 | "image/png" |
| 13 | "os" |
| 14 | "strings" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | "reasonix/internal/config" |
| 19 | "reasonix/internal/netclient" |
| 20 | "reasonix/internal/provider" |
| 21 | "reasonix/internal/websearch" |
| 22 | ) |
| 23 | |
| 24 | // Opt-in release acceptance: 11 logical requests, at most 512 output tokens |
| 25 | // per request. No user history is sent. The credential exists only in the |
| 26 | // process environment and neither prompts nor reasoning are logged. |
| 27 | func TestLiveOpenCodeGoV10Acceptance(t *testing.T) { |
| 28 | key := os.Getenv("OPENCODE_GO_API_KEY") |
| 29 | if key == "" { |
| 30 | t.Skip("OPENCODE_GO_API_KEY is required") |
| 31 | } |
| 32 | requests, attempts, input, output := 0, 0, 0, 0 |
| 33 | t.Cleanup(func() { |
| 34 | t.Logf("logical requests=%d actual HTTP attempts=%d input_tokens=%d output_tokens=%d", requests, attempts, input, output) |
| 35 | }) |
| 36 | newModel := func(t *testing.T, kind, model, effort string, search bool) provider.Provider { |
| 37 | t.Helper() |
| 38 | base := "https://opencode.ai/zen/go/v1" |
| 39 | endpoint := map[string]string{"openai": "chat/completions", "anthropic": "messages", "responses": "responses"}[kind] |
| 40 | entry := config.ProviderEntry{Name: "opencode-v10-acceptance", Kind: kind, BaseURL: base, |
| 41 | RequestURL: base + "/" + endpoint, Model: model, APIKeyEnv: "OPENCODE_GO_API_KEY", Effort: effort, |
| 42 | Thinking: "enabled", MaxOutputTokens: 512, WebSearch: &search, ResponsesMode: "stateless"} |
| 43 | if !provider.OpenCodeGoDeepSeekModel(model) { |
| 44 | entry.Thinking = "adaptive" |
| 45 | } |
| 46 | entry.ResolveAPIKeyFromProcessEnvForProbe() |
| 47 | p, err := newProviderWithSearchMode(&entry, netclient.ProxySpec{Mode: netclient.ModeAuto}, nil, !search) |
| 48 | if err != nil { |
| 49 | t.Fatal(strings.ReplaceAll(err.Error(), key, "[redacted]")) |
| 50 | } |
| 51 | t.Cleanup(func() { |
| 52 | if closer, ok := p.(interface{ CloseIdleConnections() }); ok { |
| 53 | closer.CloseIdleConnections() |
| 54 | } |
| 55 | }) |
| 56 | return p |
| 57 | } |
| 58 | collect := func(t *testing.T, p provider.Provider, req provider.Request) provider.Message { |
| 59 | t.Helper() |
| 60 | requests++ |
| 61 | ctx, cancel := context.WithTimeout(provider.WithIndependentRequestAttemptCounter(provider.WithCacheSession(context.Background(), "opencode-v10-release-acceptance")), 90*time.Second) |
| 62 | defer cancel() |
| 63 | defer func() { |
| 64 | if u := provider.UsageWithRequestAttemptCount(ctx, nil); u != nil { |
| 65 | attempts += u.RequestCount |
| 66 | } |
| 67 | }() |
| 68 | ch, err := p.Stream(ctx, req) |
| 69 | if err != nil { |
| 70 | t.Fatal(strings.ReplaceAll(err.Error(), key, "[redacted]")) |
| 71 | } |
| 72 | message := provider.Message{Role: provider.RoleAssistant} |
| 73 | var usage provider.Usage |
| 74 | for chunk := range ch { |
| 75 | switch chunk.Type { |
| 76 | case provider.ChunkText: |
| 77 | message.Content += chunk.Text |
| 78 | case provider.ChunkReasoning: |
| 79 | message.ReasoningContent += chunk.Text |
| 80 | case provider.ChunkToolCall: |
| 81 | if chunk.ToolCall != nil { |
| 82 | message.ToolCalls = append(message.ToolCalls, *chunk.ToolCall) |
| 83 | } |
| 84 | case provider.ChunkUsage: |
| 85 | if chunk.Usage != nil { |
| 86 | usage = *chunk.Usage |
| 87 | } |
| 88 | case provider.ChunkServerSearch: |
| 89 | if chunk.ServerSearch != nil { |
| 90 | message.ServerSearch = append(message.ServerSearch, *chunk.ServerSearch) |
| 91 | } |
| 92 | case provider.ChunkResponsesItem: |
| 93 | message.ResponsesItems = append(message.ResponsesItems, chunk.ResponsesItem) |
| 94 | case provider.ChunkError: |
| 95 | if chunk.Err != nil { |
| 96 | t.Error(strings.ReplaceAll(chunk.Err.Error(), key, "[redacted]")) |
| 97 | } |
| 98 | } |
| 99 | } |
| 100 | input += usage.PromptTokens |
| 101 | output += usage.CompletionTokens |
| 102 | t.Logf("request=%d prompt=%d completion=%d text_bytes=%d reasoning_bytes=%d tool_calls=%d", requests, usage.PromptTokens, usage.CompletionTokens, len(message.Content), len(message.ReasoningContent), len(message.ToolCalls)) |
| 103 | if len(message.Content) == 0 && len(message.ToolCalls) == 0 { |
| 104 | t.Fatal("no visible answer or tool call") |
| 105 | } |
| 106 | return message |
| 107 | } |
| 108 | for _, test := range []struct{ kind, model string }{ |
| 109 | {"openai", "deepseek-v4-flash"}, {"openai", "deepseek-v4-pro"}, {"openai", "deepseek-v4-flash-vision-exp"}, |
| 110 | {"anthropic", "minimax-m3"}, {"responses", "grok-4.6"}, |
| 111 | } { |
| 112 | t.Run(test.kind+"/"+test.model, func(t *testing.T) { |
| 113 | effort := "high" |
| 114 | if provider.OpenCodeGoDeepSeekModel(test.model) { |
| 115 | effort = "max" |
| 116 | } else if test.model == "grok-4.6" { |
| 117 | // The current Responses route does not expose a local reasoning |
| 118 | // effort contract for Grok 4.6; leave it unset for the wire probe. |
| 119 | effort = "" |
| 120 | } |
| 121 | p := newModel(t, test.kind, test.model, effort, false) |
| 122 | collect(t, p, provider.Request{Messages: []provider.Message{{Role: provider.RoleSystem, Content: "You are Reasonix, a coding assistant running a small protocol integration test."}, {Role: provider.RoleUser, Content: "Reply with just OK."}}, MaxTokens: 128}) |
| 123 | }) |
| 124 | } |
| 125 | t.Run("tool-replay", func(t *testing.T) { |
| 126 | p := newModel(t, "openai", "deepseek-v4-flash", "max", false) |
| 127 | tools := []provider.ToolSchema{{Name: "get_marker", Description: "Return the integration-test marker. Call this tool before answering.", Parameters: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`)}} |
| 128 | messages := []provider.Message{{Role: provider.RoleSystem, Content: "You are Reasonix, a coding assistant. Call get_marker once, then report its result."}, {Role: provider.RoleUser, Content: "Call get_marker now."}} |
| 129 | first := collect(t, p, provider.Request{Messages: messages, Tools: tools, MaxTokens: 512}) |
| 130 | if len(first.ToolCalls) == 0 || first.ReasoningContent == "" { |
| 131 | t.Fatal("missing tool call or reasoning to replay") |
| 132 | } |
| 133 | messages = append(messages, first) |
| 134 | for _, call := range first.ToolCalls { |
| 135 | messages = append(messages, provider.Message{Role: provider.RoleTool, ToolCallID: call.ID, Name: call.Name, Content: "protocol-round-trip-ok"}) |
| 136 | } |
| 137 | second := collect(t, p, provider.Request{Messages: messages, Tools: tools, MaxTokens: 256}) |
| 138 | if !strings.Contains(second.Content, "protocol-round-trip-ok") { |
| 139 | t.Fatal("tool result was not retained") |
| 140 | } |
| 141 | messages = append(messages, second, provider.Message{Role: provider.RoleUser, Content: "Repeat only the marker from the previous tool result."}) |
| 142 | third := collect(t, p, provider.Request{Messages: messages, Tools: tools, MaxTokens: 128}) |
| 143 | if !strings.Contains(third.Content, "protocol-round-trip-ok") { |
| 144 | t.Fatal("multi-turn history lost the tool result") |
| 145 | } |
| 146 | }) |
| 147 | t.Run("vision", func(t *testing.T) { |
| 148 | p := newModel(t, "openai", "deepseek-v4-flash-vision-exp", "max", false) |
| 149 | img := image.NewRGBA(image.Rect(0, 0, 32, 32)) |
| 150 | for y := 0; y < 32; y++ { |
| 151 | for x := 0; x < 32; x++ { |
| 152 | img.SetRGBA(x, y, color.RGBA{R: 255, A: 255}) |
| 153 | } |
| 154 | } |
| 155 | var buf bytes.Buffer |
| 156 | if err := png.Encode(&buf, img); err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) |
| 160 | answer := collect(t, p, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "What is the main color of this image? Reply with one English color word.", Images: []string{dataURL}}}, MaxTokens: 128}) |
| 161 | if !strings.Contains(strings.ToLower(answer.Content), "red") { |
| 162 | t.Fatal("image content was not recognized") |
| 163 | } |
| 164 | }) |
| 165 | for _, kind := range []string{"anthropic", "responses"} { |
| 166 | t.Run("search/"+kind, func(t *testing.T) { |
| 167 | p := newModel(t, kind, "deepseek-v4-flash", "low", true) |
| 168 | requests++ |
| 169 | search := &websearch.Tool{Factory: func() (provider.Provider, error) { return boundedOpenCodeLiveProvider{p}, nil }, ReportUsage: func(u *provider.Usage) { |
| 170 | input += u.PromptTokens |
| 171 | output += u.CompletionTokens |
| 172 | attempts += u.RequestCount |
| 173 | t.Logf("search HTTP attempts=%d prompt=%d completion=%d", u.RequestCount, u.PromptTokens, u.CompletionTokens) |
| 174 | }} |
| 175 | result, err := search.Execute(context.Background(), json.RawMessage(`{"query":"Find the official OpenCode Go coding subscription documentation. Give one source URL and a short description."}`)) |
| 176 | if err != nil { |
| 177 | t.Fatal(strings.ReplaceAll(err.Error(), key, "[redacted]")) |
| 178 | } |
| 179 | var decoded websearch.Result |
| 180 | if err := json.Unmarshal([]byte(result), &decoded); err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | if decoded.Summary == "" || len(decoded.Sources) == 0 { |
| 184 | t.Fatal("search produced no summary and structured source evidence") |
| 185 | } |
| 186 | t.Logf("search sources=%d summary_bytes=%d", len(decoded.Sources), len(decoded.Summary)) |
| 187 | }) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | type boundedOpenCodeLiveProvider struct{ provider.Provider } |
| 192 | |
| 193 | func (p boundedOpenCodeLiveProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) { |
| 194 | if req.MaxTokens > 512 { |
| 195 | req.MaxTokens = 512 |
| 196 | } |
| 197 | return p.Provider.Stream(ctx, req) |
| 198 | } |
| 199 |