返回 DeepSeek-Reasonix
errmsg_test.go
根目录 / internal / control / errmsg_test.go
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
279 lines GO