| 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 |