| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "io" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "reasonix/internal/i18n" |
| 12 | "reasonix/internal/provider" |
| 13 | "reasonix/internal/turnevent" |
| 14 | ) |
| 15 | |
| 16 | func TestExplainError(t *testing.T) { |
| 17 | if explainError(nil) != nil { |
| 18 | t.Error("nil should stay nil") |
| 19 | } |
| 20 | |
| 21 | bal := explainError(&provider.APIError{Provider: "deepseek", Status: 402, Body: "Insufficient Balance"}) |
| 22 | if bal.Error() != fmt.Sprintf(i18n.M.ProviderErrQuotaExhaustedFmt, "deepseek", 402) { |
| 23 | t.Errorf("402 = %q, want the localized quota message with actual HTTP status", bal.Error()) |
| 24 | } |
| 25 | |
| 26 | auth := explainError(&provider.AuthError{Provider: "deepseek", KeyEnv: "DEEPSEEK_API_KEY", Status: 401}) |
| 27 | if !strings.Contains(auth.Error(), "DEEPSEEK_API_KEY") { |
| 28 | t.Errorf("401 should name the key env: %q", auth.Error()) |
| 29 | } |
| 30 | if !strings.Contains(auth.Error(), i18n.M.ProviderErrAuth) { |
| 31 | t.Errorf("401 without a key should use the missing-key message: %q", auth.Error()) |
| 32 | } |
| 33 | |
| 34 | rejected := explainError(&provider.AuthError{Provider: "mimo", KeyEnv: "MIMO_API_KEY", Status: 401, HasKey: true}) |
| 35 | if !strings.Contains(rejected.Error(), i18n.M.ProviderErrAuthRejected) { |
| 36 | t.Errorf("401 with a key present should use the server-rejected message: %q", rejected.Error()) |
| 37 | } |
| 38 | if !strings.Contains(rejected.Error(), "MIMO_API_KEY") { |
| 39 | t.Errorf("401 should still name the key env: %q", rejected.Error()) |
| 40 | } |
| 41 | |
| 42 | sourced := explainError(&provider.AuthError{Provider: "deepseek", KeyEnv: "DEEPSEEK_API_KEY", KeySource: "project .env", Status: 401, HasKey: true}) |
| 43 | if !strings.Contains(sourced.Error(), "DEEPSEEK_API_KEY from project .env") { |
| 44 | t.Errorf("401 should name the key source: %q", sourced.Error()) |
| 45 | } |
| 46 | |
| 47 | authBody := explainError(&provider.AuthError{Provider: "relay", KeyEnv: "RELAY_API_KEY", Status: 401, HasKey: true, Body: `{"error":{"message":"令牌已过期","type":"new_api_error"}}`}) |
| 48 | for _, want := range []string{i18n.M.ProviderErrAuthRejected, "RELAY_API_KEY", "令牌已过期"} { |
| 49 | if !strings.Contains(authBody.Error(), want) { |
| 50 | t.Errorf("401 with a body = %q, want it to contain %q", authBody.Error(), want) |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | formatMismatch := explainError(&provider.AuthError{ |
| 55 | Provider: "opencode-go-anthropic", KeyEnv: "OPENCODE_GO_API_KEY", Status: 401, HasKey: true, |
| 56 | Body: `{"error":{"message":"Model grok-4.5 is not supported for format anthropic"}}`, |
| 57 | }) |
| 58 | for _, want := range []string{i18n.M.ProviderErrModelFormatMismatch, i18n.M.ProviderErrOpenCodeGoGrokRoute, "not supported for format anthropic"} { |
| 59 | if !strings.Contains(formatMismatch.Error(), want) { |
| 60 | t.Errorf("format mismatch = %q, want it to contain %q", formatMismatch.Error(), want) |
| 61 | } |
| 62 | } |
| 63 | if strings.Contains(formatMismatch.Error(), i18n.M.ProviderErrAuthRejected) { |
| 64 | t.Errorf("format mismatch must not be classified as a rejected API key: %q", formatMismatch.Error()) |
| 65 | } |
| 66 | |
| 67 | authEcho := explainError(&provider.AuthError{Provider: "deepseek", KeyEnv: "DEEPSEEK_API_KEY", Status: 401, HasKey: true, Body: `{"error":{"message":"Authentication Fails, Your api key: ****ae54 is invalid"}}`}) |
| 68 | if !strings.Contains(authEcho.Error(), "Authentication Fails") { |
| 69 | t.Errorf("401 should keep the readable reason, got %q", authEcho.Error()) |
| 70 | } |
| 71 | if strings.Contains(authEcho.Error(), "ae54") { |
| 72 | t.Errorf("401 must not surface the masked key tail, got %q", authEcho.Error()) |
| 73 | } |
| 74 | |
| 75 | for _, status := range []int{400, 422, 429, 500, 503} { |
| 76 | got := explainError(&provider.APIError{Provider: "p", Status: status}) |
| 77 | if got.Error() == "" || got.Error() == (&provider.APIError{Provider: "p", Status: status}).Error() { |
| 78 | t.Errorf("status %d should map to a localized message, got %q", status, got.Error()) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | notFoundCause := &provider.APIError{Provider: "deepseek-anthropic", ProviderDisplayName: "Deepseek2", Protocol: "openai", Status: 404} |
| 83 | notFound := explainError(notFoundCause) |
| 84 | for _, want := range []string{"Deepseek2 · Chat Completions", i18n.M.ProviderErrNotFound} { |
| 85 | if !strings.Contains(notFound.Error(), want) { |
| 86 | t.Errorf("404 = %q, want %q", notFound.Error(), want) |
| 87 | } |
| 88 | } |
| 89 | if d := provider.DiagnoseFailure(notFound); d.ProviderID != "deepseek-anthropic" || d.ProviderDisplayName != "Deepseek2" || d.Protocol != "openai" || d.Status != 404 { |
| 90 | t.Fatalf("explained 404 diagnostic = %+v", d) |
| 91 | } |
| 92 | |
| 93 | jsonBody := explainError(&provider.APIError{Provider: "deepseek", Status: 400, Body: `{"error":{"message":"This model's maximum context length is 65536 tokens.","type":"invalid_request_error"}}`}) |
| 94 | if !strings.Contains(jsonBody.Error(), i18n.M.ProviderErrBadRequest) || !strings.Contains(jsonBody.Error(), "maximum context length") { |
| 95 | t.Errorf("400 should append the provider reason from a JSON body, got %q", jsonBody.Error()) |
| 96 | } |
| 97 | |
| 98 | limit := explainError(&provider.ContextLimitError{ |
| 99 | APIError: &provider.APIError{Provider: "deepseek", Status: 400, Body: `{"error":{"message":"This model's maximum context length is 1048576 tokens. However, you requested 1165351 tokens (810882 in the messages, 354469 in the completion)."}}`}, |
| 100 | WindowTokens: 1_048_576, |
| 101 | RequestedTokens: 1_165_351, |
| 102 | PromptTokens: 810_882, |
| 103 | CompletionTokens: 354_469, |
| 104 | }) |
| 105 | if !strings.Contains(limit.Error(), "810882") || !strings.Contains(limit.Error(), "1048576") || !strings.Contains(limit.Error(), "Compact") { |
| 106 | t.Errorf("context overflow should name numbers and recovery, got %q", limit.Error()) |
| 107 | } |
| 108 | |
| 109 | unnumbered := explainError(&provider.ContextLimitError{ |
| 110 | APIError: &provider.APIError{Provider: "glm", Status: 400, Body: `{"error":{"code":"1261","message":"Prompt exceeds max length"}}`}, |
| 111 | }) |
| 112 | if strings.Contains(unnumbered.Error(), fmt.Sprintf(i18n.M.ProviderErrContextOverflowFmt, 0, 0, 0, 0)) { |
| 113 | t.Errorf("an overflow without token numbers must not quote zeros, got %q", unnumbered.Error()) |
| 114 | } |
| 115 | if !strings.Contains(unnumbered.Error(), i18n.M.ProviderErrBadRequest) || !strings.Contains(unnumbered.Error(), "Prompt exceeds max length") { |
| 116 | t.Errorf("an overflow without token numbers should keep the provider reason, got %q", unnumbered.Error()) |
| 117 | } |
| 118 | |
| 119 | toolSchema := explainError(&provider.APIError{ |
| 120 | Provider: "mimo", |
| 121 | Status: 400, |
| 122 | Body: `{"error":{"message":"Tool 197 function has invalid 'parameters' schema"}}`, |
| 123 | ToolContext: `Provider tool 197 maps to Reasonix tool "mcp__files__search" (MCP server "files", tool "search").`, |
| 124 | }) |
| 125 | for _, want := range []string{"invalid 'parameters' schema", `MCP server "files"`} { |
| 126 | if !strings.Contains(toolSchema.Error(), want) { |
| 127 | t.Errorf("400 tool schema error = %q, want %q", toolSchema.Error(), want) |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | rawBody := explainError(&provider.APIError{Provider: "deepseek", Status: 422, Body: "some unparseable detail"}) |
| 132 | if !strings.Contains(rawBody.Error(), "some unparseable detail") { |
| 133 | t.Errorf("422 should fall back to the raw body, got %q", rawBody.Error()) |
| 134 | } |
| 135 | |
| 136 | miniMaxInput := explainError(&provider.APIError{ |
| 137 | Provider: "custom-m3", |
| 138 | Status: 422, |
| 139 | Body: `{"error":{"message":"input new_sensitive (1026)","code":"1026"}}`, |
| 140 | TraceID: "minimax-trace-123", |
| 141 | }) |
| 142 | for _, want := range []string{i18n.M.ProviderErrInputSensitive, "input new_sensitive", "Trace ID: minimax-trace-123"} { |
| 143 | if !strings.Contains(miniMaxInput.Error(), want) { |
| 144 | t.Errorf("MiniMax 1026 = %q, want %q", miniMaxInput.Error(), want) |
| 145 | } |
| 146 | } |
| 147 | if strings.Contains(miniMaxInput.Error(), i18n.M.ProviderErrUnprocessable) { |
| 148 | t.Errorf("MiniMax 1026 must not use the generic 422 message: %q", miniMaxInput.Error()) |
| 149 | } |
| 150 | |
| 151 | miniMaxOutput := explainError(&provider.APIError{ |
| 152 | Provider: "minimax-cn-api", |
| 153 | Status: 422, |
| 154 | Body: `{"base_resp":{"status_code":1027,"status_msg":"output new_sensitive"}}`, |
| 155 | }) |
| 156 | for _, want := range []string{i18n.M.ProviderErrOutputSensitive, "output new_sensitive"} { |
| 157 | if !strings.Contains(miniMaxOutput.Error(), want) { |
| 158 | t.Errorf("MiniMax 1027 = %q, want %q", miniMaxOutput.Error(), want) |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | unrelated1026 := explainError(&provider.APIError{Provider: "other", Status: 422, Body: `{"code":1026,"message":"other meaning"}`}) |
| 163 | if !strings.Contains(unrelated1026.Error(), i18n.M.ProviderErrUnprocessable) { |
| 164 | t.Errorf("another provider's numeric code 1026 must remain generic: %q", unrelated1026.Error()) |
| 165 | } |
| 166 | |
| 167 | rate := explainError(&provider.APIError{Provider: "deepseek", Status: 429, Body: `{"error":{"message":"slow down"}}`}) |
| 168 | if !strings.Contains(rate.Error(), i18n.M.ProviderErrRateLimited) || !strings.Contains(rate.Error(), "slow down") { |
| 169 | t.Errorf("429 should append the provider reason, got %q", rate.Error()) |
| 170 | } |
| 171 | |
| 172 | // Relay gateways (one-api/new-api style) wrap the real failure — dead |
| 173 | // upstream channel, unsupported tools, exhausted quota — in a 5xx JSON |
| 174 | // body; the category line alone made those undiagnosable. |
| 175 | relay := explainError(&provider.APIError{Provider: "relay", Status: 500, Body: `{"error":{"message":"no available channel for model claude-fable-5 in group default","type":"new_api_error"}}`}) |
| 176 | if !strings.Contains(relay.Error(), i18n.M.ProviderErrServer) || !strings.Contains(relay.Error(), "no available channel") { |
| 177 | t.Errorf("500 should append the provider reason from a JSON body, got %q", relay.Error()) |
| 178 | } |
| 179 | |
| 180 | busy := explainError(&provider.APIError{Provider: "relay", Status: 503, Body: "upstream unavailable"}) |
| 181 | if !strings.Contains(busy.Error(), i18n.M.ProviderErrServerBusy) || !strings.Contains(busy.Error(), "upstream unavailable") { |
| 182 | t.Errorf("503 should fall back to the raw body, got %q", busy.Error()) |
| 183 | } |
| 184 | |
| 185 | bare := explainError(&provider.APIError{Provider: "relay", Status: 500}) |
| 186 | if bare.Error() != i18n.M.ProviderErrServer { |
| 187 | t.Errorf("500 without a body = %q, want exactly the localized message", bare.Error()) |
| 188 | } |
| 189 | |
| 190 | interrupted := explainError(&provider.StreamInterruptedError{Err: io.ErrUnexpectedEOF}) |
| 191 | if !strings.Contains(interrupted.Error(), "model stream interrupted") || !strings.Contains(interrupted.Error(), "continue") { |
| 192 | t.Errorf("stream interruption should be actionable, got %q", interrupted.Error()) |
| 193 | } |
| 194 | |
| 195 | disconnected := explainError(io.ErrUnexpectedEOF) |
| 196 | if !strings.Contains(disconnected.Error(), "model stream disconnected") || !strings.Contains(disconnected.Error(), "retry") { |
| 197 | t.Errorf("connection reset should be actionable, got %q", disconnected.Error()) |
| 198 | } |
| 199 | |
| 200 | plain := errors.New("some other failure") |
| 201 | //nolint:errorlint // identity check: explainError must return the same error, unwrapped. |
| 202 | if explainError(plain) != plain { |
| 203 | t.Error("unknown errors should pass through unchanged") |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | func TestExplainRecoveryWaitExhaustedKeepsTypeAndCause(t *testing.T) { |
| 208 | cause := &provider.APIError{Provider: "deepseek", Status: 503, Body: `{"error":{"message":"upstream overloaded"}}`} |
| 209 | got := explainError(&provider.RecoveryWaitExhaustedError{Phase: "headers", Status: 503, Waited: 9*time.Minute + 33*time.Second + 400*time.Millisecond, Attempts: 13, Cause: cause}) |
| 210 | for _, want := range []string{fmt.Sprintf(i18n.M.ProviderErrWaitExhaustedFmt, "9m33s"), "HTTP 503", "upstream overloaded"} { |
| 211 | if !strings.Contains(got.Error(), want) { |
| 212 | t.Errorf("explanation = %q, want it to contain %q", got.Error(), want) |
| 213 | } |
| 214 | } |
| 215 | if strings.Contains(got.Error(), i18n.M.ProviderErrServerBusy) || strings.Contains(got.Error(), "provider unreachable for") { |
| 216 | t.Errorf("explanation must describe the exhausted wait, not the last status: %q", got.Error()) |
| 217 | } |
| 218 | if d := provider.DiagnoseFailure(got); d.Kind != "recovery_wait_exhausted" || d.Status != 503 { |
| 219 | t.Errorf("diagnostic = %+v", d) |
| 220 | } |
| 221 | if turnOutcome(got) != "" { |
| 222 | t.Errorf("an exhausted wait is an ordinary failure, got outcome %q", turnOutcome(got)) |
| 223 | } |
| 224 | connect := explainError(&provider.RecoveryWaitExhaustedError{Phase: "connect", Waited: 10 * time.Minute, Attempts: 12, Cause: io.ErrUnexpectedEOF}) |
| 225 | if !strings.Contains(connect.Error(), fmt.Sprintf(i18n.M.ProviderErrWaitExhaustedFmt, "10m0s")) || !strings.Contains(connect.Error(), io.ErrUnexpectedEOF.Error()) { |
| 226 | t.Errorf("connect explanation = %q", connect.Error()) |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | func TestExplainErrorPreservesTurnLedgerFailure(t *testing.T) { |
| 231 | storageErr := fmt.Errorf("persist turn admission: %w", turnevent.ErrTurnLedgerUnavailable) |
| 232 | got := explainError(storageErr) |
| 233 | if !errors.Is(got, turnevent.ErrTurnLedgerUnavailable) { |
| 234 | t.Fatalf("explainError(%v) = %v, want storage sentinel preserved", storageErr, got) |
| 235 | } |
| 236 | if strings.Contains(got.Error(), "model stream") { |
| 237 | t.Fatalf("storage failure was misclassified as provider failure: %v", got) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | func TestRedactAuthReason(t *testing.T) { |
| 242 | cases := []struct{ name, in, want string }{ |
| 243 | {"masked tail", "Your api key: ****ae54 is invalid", "Your api key: **** is invalid"}, |
| 244 | {"masked prefix form", "key sk-ab**** was rejected", "key **** was rejected"}, |
| 245 | {"full key echoed by a relay", "Invalid key sk-proj-abc123def456ghi789 provided", "Invalid key **** provided"}, |
| 246 | {"digit-free sk key via secrets.Redact", "api key: sk-proj-abcdefghijklmnop is invalid", "api key: **** is invalid"}, |
| 247 | {"digit-free value after credential word", "api key: relaykey_abcdefghijklmn rejected", "api key: **** rejected"}, |
| 248 | {"bearer value collapses fully", "Bearer abc.def-ghijklmnopqrs rejected", "Bearer **** rejected"}, |
| 249 | {"mixed-case token without context", "rejected AbCdEfGhIjKlMnOpQr", "rejected ****"}, |
| 250 | {"digit-free identifier survives", "code: invalid_authentication_token", "code: invalid_authentication_token"}, |
| 251 | {"all-caps code survives", "code INVALID_AUTHENTICATION_TOKEN", "code INVALID_AUTHENTICATION_TOKEN"}, |
| 252 | {"short tokens survive", "token expired at gateway", "token expired at gateway"}, |
| 253 | {"empty", "", ""}, |
| 254 | } |
| 255 | for _, c := range cases { |
| 256 | if got := redactAuthReason(c.in); got != c.want { |
| 257 | t.Errorf("%s: redactAuthReason(%q) = %q, want %q", c.name, c.in, got, c.want) |
| 258 | } |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | func TestExplainObservedQuota401DoesNotAskToReplaceKey(t *testing.T) { |
| 263 | err := explainError(&provider.AuthError{Provider: "opencode-go", Status: 401, HasKey: true, Body: `{"error":{"type":"CreditsError","message":"Insufficient balance. https://example.test/private-billing"}}`}) |
| 264 | if err == nil || strings.Contains(err.Error(), "private-billing") || strings.Contains(err.Error(), "invalid") || !strings.Contains(err.Error(), "401") { |
| 265 | t.Fatalf("misleading quota explanation: %v", err) |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | func TestOpaqueFailureExplainsWithoutGuessingAndUsesSafeTrace(t *testing.T) { |
| 270 | got := explainError(&provider.APIError{Status: 400, Body: `{"model":"deepseek"}`, TraceID: "trace-123"}).Error() |
| 271 | if !strings.Contains(got, i18n.M.ProviderErrReasonMissing) || !strings.Contains(got, "trace-123") || strings.Contains(got, "thinking") { |
| 272 | t.Fatalf("opaque explanation=%s", got) |
| 273 | } |
| 274 | got = explainError(&provider.APIError{Status: 400, Body: `{"model":"deepseek"}`, TraceID: "https://private.invalid/billing"}).Error() |
| 275 | if strings.Contains(got, "private.invalid") { |
| 276 | t.Fatal("unsafe trace escaped") |
| 277 | } |
| 278 | } |
| 279 |