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