Files
2026-07-13 13:00:08 +08:00

152 lines
5.7 KiB
Go

package control
import (
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"reasonix/internal/i18n"
"reasonix/internal/provider"
"reasonix/internal/secrets"
)
// explainError maps a provider HTTP failure to an actionable, localized message
// so the turn-done error the UI shows is never a bare status code or silent
// failure. Unknown errors (and nil) pass through unchanged.
func explainError(err error) error {
if err == nil {
return nil
}
if provider.IsStreamInterrupted(err) {
return fmt.Errorf("model stream interrupted after recovery attempts: %s. The partial response was kept; retry or ask Reasonix to continue", err.Error())
}
if provider.IsConnReset(err) {
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())
}
var apiErr *provider.APIError
if errors.As(err, &apiErr) {
msg := i18n.M.ProviderStatusMessage(apiErr.Status)
if msg == "" {
return err
}
if reason := apiErrorReason(apiErr); reason != "" {
return fmt.Errorf("%s\n%s", msg, reason)
}
return errors.New(msg)
}
var authErr *provider.AuthError
if errors.As(err, &authErr) {
msg := i18n.M.ProviderErrAuth
if authErr.HasKey {
msg = i18n.M.ProviderErrAuthRejected
}
switch {
case authErr.KeyEnv != "" && authErr.KeySource != "":
msg = fmt.Sprintf("%s (%s from %s)", msg, authErr.KeyEnv, authErr.KeySource)
case authErr.KeyEnv != "":
msg = fmt.Sprintf("%s (%s)", msg, authErr.KeyEnv)
}
// Relays explain *why* auth failed in the body ("token expired", key
// not entitled to the model) — as diagnostic here as on APIError, but
// auth bodies also echo credentials, so scrub key material first.
if reason := redactAuthReason(providerBodyReason(authErr.Body)); reason != "" {
return fmt.Errorf("%s\n%s", msg, reason)
}
return errors.New(msg)
}
return err
}
// apiErrorReason returns the provider's verbatim reason for a failed request —
// the localized line names the category, the body names the actual cause
// (context-length exceeded, unpaired tool_calls, a relay's "no available
// channel"). Every mapped status surfaces its body, not just the
// request-shaped 4xx: relay gateways wrap the real failure — dead upstream
// channel, unsupported tools, exhausted quota — in a 402/429/5xx body, and
// without it those errors are undiagnosable from the category line alone.
func apiErrorReason(e *provider.APIError) string {
reason := providerBodyReason(e.Body)
if e.ToolContext == "" {
return reason
}
if reason == "" {
return e.ToolContext
}
return reason + "\n" + e.ToolContext
}
// Auth failure bodies are where servers echo credentials: providers include a
// masked tail ("Your api key: ****ae54 is invalid") and a sloppy relay can
// reflect the full key it received. Both narrow or reveal the key, and the
// displayed turn error can travel further than the user's terminal (bot
// forwarding, shared screenshots).
var (
// maskedFragmentRe matches a partially masked credential and any visible
// prefix/suffix around the stars ("****ae54", "sk-ab****") — including the
// head/tail remnants secrets.Redact's own mask leaves behind. For auth
// display even the remnant narrows the key, so the whole run collapses.
maskedFragmentRe = regexp.MustCompile(`[A-Za-z0-9._-]*\*{2,}[A-Za-z0-9._-]*`)
// credContextRe masks any long value that follows a credential word.
// secrets.Redact's KEY=value rule only matches API_KEY/APIKEY-style names;
// providers write prose forms like "api key: <value>", where the value must
// go regardless of its composition.
credContextRe = regexp.MustCompile(`(?i)\b(api[ _-]?key|access[ _-]?key|secret|token|authorization|bearer|credential)s?\b(['"]?\s*[:=]?\s*['"]?)([A-Za-z0-9._~+/-]{12,})`)
// keyTokenRe matches key-shaped runs for the no-context fallback; a run is
// treated as a credential when it carries a digit or mixed case, so
// single-case digit-free identifiers like "invalid_authentication_token"
// stay readable.
keyTokenRe = regexp.MustCompile(`[A-Za-z0-9_-]{16,}`)
digitRe = regexp.MustCompile(`[0-9]`)
)
// redactAuthReason scrubs key material from an auth-failure reason before
// display, in layers: secrets.Redact for known key shapes (sk-/rk- prefixes,
// Bearer, JWT, KEY=value forms), the credential-context rule for prose forms,
// full collapse of masked fragments, then the key-shaped-token fallback. The
// residual blind spot is a long single-case digit-free token with no context
// word — indistinguishable from an identifier. Deliberately applied only to
// 401/403 bodies: other statuses don't carry credentials, and 400 schema
// errors legitimately contain long identifiers that this scrub would mangle.
func redactAuthReason(s string) string {
if s == "" {
return s
}
s = secrets.Redact(s)
s = credContextRe.ReplaceAllString(s, "${1}${2}****")
s = maskedFragmentRe.ReplaceAllString(s, "****")
return keyTokenRe.ReplaceAllStringFunc(s, func(tok string) string {
mixedCase := strings.ToLower(tok) != tok && strings.ToUpper(tok) != tok
if digitRe.MatchString(tok) || mixedCase {
return "****"
}
return tok
})
}
// providerBodyReason pulls the human reason from an OpenAI/Anthropic-shaped error
// body ({"error":{"message":…}}), falling back to the trimmed raw body.
func providerBodyReason(body string) string {
if body == "" {
return ""
}
var parsed struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal([]byte(body), &parsed) == nil && parsed.Error.Message != "" {
return clampRunes(parsed.Error.Message, 800)
}
return clampRunes(body, 800)
}
func clampRunes(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max]) + "…"
}