返回 DeepSeek-Reasonix
errmsg.go
根目录 / internal / control / errmsg.go
1 package control
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "regexp"
8 "strings"
9 "time"
10
11 "reasonix/internal/i18n"
12 "reasonix/internal/provider"
13 "reasonix/internal/secrets"
14 "reasonix/internal/turnevent"
15 )
16
17 // explainError maps a provider HTTP failure to an actionable, localized message
18 // so the turn-done error the UI shows is never a bare status code or silent
19 // failure. Unknown errors (and nil) pass through unchanged.
20 func explainError(err error) error {
21 if err == nil {
22 return nil
23 }
24 // Filesystem errors can satisfy net.Error on some platforms. Preserve the
25 // storage sentinel before provider retry classification so a poisoned WAL
26 // is never reported as a model-stream disconnect.
27 if errors.Is(err, turnevent.ErrTurnLedgerUnavailable) {
28 return err
29 }
30 // The exhausted wait wraps its transport cause; explain the wait itself
31 // before the connect/status branches below explain that cause instead.
32 if wait := provider.AsRecoveryWaitExhausted(err); wait != nil {
33 return &explainedError{msg: explainRecoveryWait(wait), cause: err}
34 }
35 if provider.IsStreamInterrupted(err) {
36 return &explainedError{msg: fmt.Sprintf("model stream interrupted after recovery attempts: %s. The partial response was kept; retry or ask Reasonix to continue", err.Error()), cause: err}
37 }
38 if provider.IsConnReset(err) {
39 return &explainedError{msg: fmt.Sprintf("model stream disconnected before completion after retry attempts: %s. Check the provider/proxy connection, then retry or ask Reasonix to continue", err.Error()), cause: err}
40 }
41 // An overflow without token numbers has nothing to quote; the generic 400
42 // branch below keeps the provider's own reason instead of zeros.
43 if limit := provider.AsContextLimitError(err); limit != nil && limit.WindowTokens > 0 {
44 msg := fmt.Sprintf(i18n.M.ProviderErrContextOverflowFmt, limit.PromptTokens, limit.CompletionTokens, limit.RequestedTokens, limit.WindowTokens)
45 if reason := apiErrorReason(limit.APIError); reason != "" {
46 msg = fmt.Sprintf("%s\n%s", msg, reason)
47 }
48 label := ""
49 if limit.APIError != nil {
50 label = provider.ProviderDisplayLabel(limit.APIError.Provider, limit.APIError.ProviderDisplayName, limit.APIError.Protocol)
51 }
52 return &explainedError{msg: providerFailureMessage(label, limit.APIError, msg), cause: err}
53 }
54 if quota := provider.AsQuotaError(err); quota != nil {
55 label := provider.ProviderDisplayLabel(quota.Provider, quota.ProviderDisplayName, quota.Protocol)
56 return &explainedError{msg: fmt.Sprintf(i18n.M.ProviderErrQuotaExhaustedFmt, label, quota.Status), cause: err}
57 }
58 var apiErr *provider.APIError
59 if errors.As(err, &apiErr) {
60 label := provider.ProviderDisplayLabel(apiErr.Provider, apiErr.ProviderDisplayName, apiErr.Protocol)
61 if provider.IsOpaqueBadRequest(err) {
62 if trace := provider.DiagnoseFailure(err).TraceID; trace != "" {
63 return &explainedError{msg: providerFailureMessage(label, apiErr, fmt.Sprintf("%s\nTrace ID: %s", i18n.M.ProviderErrReasonMissing, trace)), cause: err}
64 }
65 return &explainedError{msg: providerFailureMessage(label, apiErr, i18n.M.ProviderErrReasonMissing), cause: err}
66 }
67 if msg := providerContentSafetyMessage(apiErr); msg != "" {
68 if reason := apiErrorReason(apiErr); reason != "" {
69 msg = fmt.Sprintf("%s\n%s", msg, reason)
70 }
71 return &explainedError{msg: providerFailureMessage(label, apiErr, msg), cause: err}
72 }
73 msg := i18n.M.ProviderStatusMessage(apiErr.Status)
74 if msg == "" {
75 return err
76 }
77 if reason := apiErrorReason(apiErr); reason != "" {
78 msg = fmt.Sprintf("%s\n%s", msg, reason)
79 }
80 return &explainedError{msg: providerFailureMessage(label, apiErr, msg), cause: err}
81 }
82 var authErr *provider.AuthError
83 if errors.As(err, &authErr) {
84 label := provider.ProviderDisplayLabel(authErr.Provider, authErr.ProviderDisplayName, authErr.Protocol)
85 reason := redactAuthReason(providerBodyReason(authErr.Body))
86 if modelFormatMismatchReason(reason) {
87 details := []string{i18n.M.ProviderErrModelFormatMismatch}
88 lower := strings.ToLower(reason)
89 isOpenCodeGo := strings.Contains(strings.ToLower(authErr.Provider), "opencode-go") || strings.EqualFold(authErr.KeyEnv, "OPENCODE_GO_API_KEY")
90 if isOpenCodeGo && strings.Contains(lower, "grok-4.5") && strings.Contains(lower, "format anthropic") {
91 details = append(details, i18n.M.ProviderErrOpenCodeGoGrokRoute)
92 }
93 if reason != "" {
94 details = append(details, reason)
95 }
96 return &explainedError{msg: providerFailureMessage(label, authErr, strings.Join(details, "\n")), cause: err}
97 }
98 msg := i18n.M.ProviderErrAuth
99 if authErr.HasKey {
100 msg = i18n.M.ProviderErrAuthRejected
101 }
102 switch {
103 case authErr.KeyEnv != "" && authErr.KeySource != "":
104 msg = fmt.Sprintf("%s (%s from %s)", msg, authErr.KeyEnv, authErr.KeySource)
105 case authErr.KeyEnv != "":
106 msg = fmt.Sprintf("%s (%s)", msg, authErr.KeyEnv)
107 }
108 // Relays explain *why* auth failed in the body ("token expired", key
109 // not entitled to the model) — as diagnostic here as on APIError, but
110 // auth bodies also echo credentials, so scrub key material first.
111 if reason != "" {
112 msg = fmt.Sprintf("%s\n%s", msg, reason)
113 }
114 return &explainedError{msg: providerFailureMessage(label, authErr, msg), cause: err}
115 }
116 return err
117 }
118
119 func providerFailureMessage(label string, source any, message string) string {
120 hasDisplayIdentity := false
121 switch value := source.(type) {
122 case *provider.APIError:
123 hasDisplayIdentity = value != nil && (strings.TrimSpace(value.ProviderDisplayName) != "" || strings.TrimSpace(value.Protocol) != "")
124 case *provider.AuthError:
125 hasDisplayIdentity = value != nil && (strings.TrimSpace(value.ProviderDisplayName) != "" || strings.TrimSpace(value.Protocol) != "")
126 }
127 if !hasDisplayIdentity || strings.TrimSpace(label) == "" {
128 return message
129 }
130 return label + ": " + message
131 }
132
133 // explainedError shows the localized message while keeping the typed cause
134 // reachable, so DiagnoseFailure on the TurnDone error still classifies it.
135 type explainedError struct {
136 msg string
137 cause error
138 }
139
140 func (e *explainedError) Error() string { return e.msg }
141 func (e *explainedError) Unwrap() error { return e.cause }
142
143 func explainRecoveryWait(wait *provider.RecoveryWaitExhaustedError) string {
144 lines := []string{fmt.Sprintf(i18n.M.ProviderErrWaitExhaustedFmt, wait.Waited.Round(time.Second))}
145 var apiErr *provider.APIError
146 switch {
147 case errors.As(wait.Cause, &apiErr):
148 lines = append(lines, fmt.Sprintf("HTTP %d", apiErr.Status))
149 if reason := apiErrorReason(apiErr); reason != "" {
150 lines = append(lines, reason)
151 }
152 case wait.Cause != nil:
153 lines = append(lines, wait.Cause.Error())
154 }
155 return strings.Join(lines, "\n")
156 }
157
158 func modelFormatMismatchReason(reason string) bool {
159 lower := strings.ToLower(strings.TrimSpace(reason))
160 return strings.Contains(lower, "model") && strings.Contains(lower, "not supported for format")
161 }
162
163 // apiErrorReason returns the provider's verbatim reason for a failed request —
164 // the localized line names the category, the body names the actual cause
165 // (context-length exceeded, unpaired tool_calls, a relay's "no available
166 // channel"). Every mapped status surfaces its body, not just the
167 // request-shaped 4xx: relay gateways wrap the real failure — dead upstream
168 // channel, unsupported tools, exhausted quota — in a 402/429/5xx body, and
169 // without it those errors are undiagnosable from the category line alone.
170 func apiErrorReason(e *provider.APIError) string {
171 details := make([]string, 0, 3)
172 if reason := providerBodyReason(e.Body); reason != "" {
173 details = append(details, reason)
174 }
175 if traceID := strings.TrimSpace(e.TraceID); traceID != "" {
176 details = append(details, "Trace ID: "+clampRunes(traceID, 200))
177 }
178 if e.ToolContext != "" {
179 details = append(details, e.ToolContext)
180 }
181 return strings.Join(details, "\n")
182 }
183
184 var (
185 miniMax1026CodeRe = regexp.MustCompile(`(^|[^0-9])1026([^0-9]|$)`)
186 miniMax1027CodeRe = regexp.MustCompile(`(^|[^0-9])1027([^0-9]|$)`)
187 )
188
189 // providerContentSafetyMessage recognizes MiniMax's provider-specific content
190 // review failures before the generic HTTP 422 mapping calls them invalid
191 // parameters. A custom-named MiniMax provider is still recognized by the
192 // documented status text; numeric-only errors require a MiniMax provider name
193 // so another OpenAI-compatible API cannot accidentally inherit this meaning.
194 func providerContentSafetyMessage(e *provider.APIError) string {
195 if e == nil || e.Status != 422 {
196 return ""
197 }
198 body := strings.ToLower(e.Body)
199 providerName := strings.ToLower(e.Provider)
200 isMiniMax := strings.Contains(providerName, "minimax")
201 switch {
202 case strings.Contains(body, "input new_sensitive") || isMiniMax && miniMax1026CodeRe.MatchString(body):
203 return i18n.M.ProviderErrInputSensitive
204 case strings.Contains(body, "output new_sensitive") || isMiniMax && miniMax1027CodeRe.MatchString(body):
205 return i18n.M.ProviderErrOutputSensitive
206 default:
207 return ""
208 }
209 }
210
211 // redactAuthReason scrubs key material from an auth-failure reason before
212 // display. Deliberately applied only to 401/403 bodies: other statuses don't
213 // carry credentials, and 400 schema errors legitimately contain long
214 // identifiers that this stronger scrub would mangle.
215 func redactAuthReason(s string) string {
216 return secrets.RedactCredentials(s)
217 }
218
219 // providerBodyReason pulls the human reason from an OpenAI/Anthropic-shaped
220 // error body ({"error":{"message":…}}) or MiniMax's base_resp envelope,
221 // falling back to the trimmed raw body.
222 func providerBodyReason(body string) string {
223 if body == "" {
224 return ""
225 }
226 var parsed struct {
227 Error struct {
228 Message string `json:"message"`
229 } `json:"error"`
230 BaseResp struct {
231 StatusMsg string `json:"status_msg"`
232 } `json:"base_resp"`
233 }
234 if json.Unmarshal([]byte(body), &parsed) == nil {
235 switch {
236 case parsed.Error.Message != "":
237 return clampRunes(parsed.Error.Message, 800)
238 case parsed.BaseResp.StatusMsg != "":
239 return clampRunes(parsed.BaseResp.StatusMsg, 800)
240 }
241 }
242 return clampRunes(body, 800)
243 }
244
245 func clampRunes(s string, max int) string {
246 r := []rune(s)
247 if len(r) <= max {
248 return s
249 }
250 return string(r[:max]) + "…"
251 }
252
252 lines GO