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