返回 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
10 "reasonix/internal/i18n"
11 "reasonix/internal/provider"
12 "reasonix/internal/secrets"
13 )
14
15 // explainError maps a provider HTTP failure to an actionable, localized message
16 // so the turn-done error the UI shows is never a bare status code or silent
17 // failure. Unknown errors (and nil) pass through unchanged.
18 func explainError(err error) error {
19 if err == nil {
20 return nil
21 }
22 if provider.IsStreamInterrupted(err) {
23 return fmt.Errorf("model stream interrupted after recovery attempts: %s. The partial response was kept; retry or ask Reasonix to continue", err.Error())
24 }
25 if provider.IsConnReset(err) {
26 return fmt.Errorf("model stream disconnected before completion after retry attempts: %s. Check the provider/proxy connection, then retry or ask Reasonix to continue", err.Error())
27 }
28 var apiErr *provider.APIError
29 if errors.As(err, &apiErr) {
30 if msg := providerContentSafetyMessage(apiErr); msg != "" {
31 if reason := apiErrorReason(apiErr); reason != "" {
32 return fmt.Errorf("%s\n%s", msg, reason)
33 }
34 return errors.New(msg)
35 }
36 msg := i18n.M.ProviderStatusMessage(apiErr.Status)
37 if msg == "" {
38 return err
39 }
40 if reason := apiErrorReason(apiErr); reason != "" {
41 return fmt.Errorf("%s\n%s", msg, reason)
42 }
43 return errors.New(msg)
44 }
45 var authErr *provider.AuthError
46 if errors.As(err, &authErr) {
47 msg := i18n.M.ProviderErrAuth
48 if authErr.HasKey {
49 msg = i18n.M.ProviderErrAuthRejected
50 }
51 switch {
52 case authErr.KeyEnv != "" && authErr.KeySource != "":
53 msg = fmt.Sprintf("%s (%s from %s)", msg, authErr.KeyEnv, authErr.KeySource)
54 case authErr.KeyEnv != "":
55 msg = fmt.Sprintf("%s (%s)", msg, authErr.KeyEnv)
56 }
57 // Relays explain *why* auth failed in the body ("token expired", key
58 // not entitled to the model) — as diagnostic here as on APIError, but
59 // auth bodies also echo credentials, so scrub key material first.
60 if reason := redactAuthReason(providerBodyReason(authErr.Body)); reason != "" {
61 return fmt.Errorf("%s\n%s", msg, reason)
62 }
63 return errors.New(msg)
64 }
65 return err
66 }
67
68 // apiErrorReason returns the provider's verbatim reason for a failed request —
69 // the localized line names the category, the body names the actual cause
70 // (context-length exceeded, unpaired tool_calls, a relay's "no available
71 // channel"). Every mapped status surfaces its body, not just the
72 // request-shaped 4xx: relay gateways wrap the real failure — dead upstream
73 // channel, unsupported tools, exhausted quota — in a 402/429/5xx body, and
74 // without it those errors are undiagnosable from the category line alone.
75 func apiErrorReason(e *provider.APIError) string {
76 details := make([]string, 0, 3)
77 if reason := providerBodyReason(e.Body); reason != "" {
78 details = append(details, reason)
79 }
80 if traceID := strings.TrimSpace(e.TraceID); traceID != "" {
81 details = append(details, "Trace ID: "+clampRunes(traceID, 200))
82 }
83 if e.ToolContext != "" {
84 details = append(details, e.ToolContext)
85 }
86 return strings.Join(details, "\n")
87 }
88
89 var (
90 miniMax1026CodeRe = regexp.MustCompile(`(^|[^0-9])1026([^0-9]|$)`)
91 miniMax1027CodeRe = regexp.MustCompile(`(^|[^0-9])1027([^0-9]|$)`)
92 )
93
94 // providerContentSafetyMessage recognizes MiniMax's provider-specific content
95 // review failures before the generic HTTP 422 mapping calls them invalid
96 // parameters. A custom-named MiniMax provider is still recognized by the
97 // documented status text; numeric-only errors require a MiniMax provider name
98 // so another OpenAI-compatible API cannot accidentally inherit this meaning.
99 func providerContentSafetyMessage(e *provider.APIError) string {
100 if e == nil || e.Status != 422 {
101 return ""
102 }
103 body := strings.ToLower(e.Body)
104 providerName := strings.ToLower(e.Provider)
105 isMiniMax := strings.Contains(providerName, "minimax")
106 switch {
107 case strings.Contains(body, "input new_sensitive") || isMiniMax && miniMax1026CodeRe.MatchString(body):
108 return i18n.M.ProviderErrInputSensitive
109 case strings.Contains(body, "output new_sensitive") || isMiniMax && miniMax1027CodeRe.MatchString(body):
110 return i18n.M.ProviderErrOutputSensitive
111 default:
112 return ""
113 }
114 }
115
116 // redactAuthReason scrubs key material from an auth-failure reason before
117 // display. Deliberately applied only to 401/403 bodies: other statuses don't
118 // carry credentials, and 400 schema errors legitimately contain long
119 // identifiers that this stronger scrub would mangle.
120 func redactAuthReason(s string) string {
121 return secrets.RedactCredentials(s)
122 }
123
124 // providerBodyReason pulls the human reason from an OpenAI/Anthropic-shaped
125 // error body ({"error":{"message":…}}) or MiniMax's base_resp envelope,
126 // falling back to the trimmed raw body.
127 func providerBodyReason(body string) string {
128 if body == "" {
129 return ""
130 }
131 var parsed struct {
132 Error struct {
133 Message string `json:"message"`
134 } `json:"error"`
135 BaseResp struct {
136 StatusMsg string `json:"status_msg"`
137 } `json:"base_resp"`
138 }
139 if json.Unmarshal([]byte(body), &parsed) == nil {
140 switch {
141 case parsed.Error.Message != "":
142 return clampRunes(parsed.Error.Message, 800)
143 case parsed.BaseResp.StatusMsg != "":
144 return clampRunes(parsed.BaseResp.StatusMsg, 800)
145 }
146 }
147 return clampRunes(body, 800)
148 }
149
150 func clampRunes(s string, max int) string {
151 r := []rune(s)
152 if len(r) <= max {
153 return s
154 }
155 return string(r[:max]) + "…"
156 }
157
157 lines GO