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