fix(error): surface the provider's reason on 400/422

A 400/422 collapsed to one generic localized line, discarding the body the provider actually returned (context-length exceeded, unpaired tool_calls, …). Append that reason for the request-shaped 4xx so the failure is diagnosable; other statuses keep their actionable fix text.
This commit is contained in:
reasonix authored and YHH committed 2026-06-02 20:36:14 -07:00
1 parent 5711efc4ea
commit f920da106a
2 files changed
+58 -3

No files matched your search

+43 -3
View File
@@ -1,6 +1,7 @@
package control
import (
"encoding/json"
"errors"
"fmt"
@@ -17,10 +18,14 @@ func explainError(err error) error {
}
var apiErr *provider.APIError
if errors.As(err, &apiErr) {
if msg := i18n.M.ProviderStatusMessage(apiErr.Status); msg != "" {
return errors.New(msg)
msg := i18n.M.ProviderStatusMessage(apiErr.Status)
if msg == "" {
return err
}
return err
if reason := requestErrorReason(apiErr); reason != "" {
return fmt.Errorf("%s\n%s", msg, reason)
}
return errors.New(msg)
}
var authErr *provider.AuthError
if errors.As(err, &authErr) {
@@ -35,3 +40,38 @@ func explainError(err error) error {
}
return err
}
// requestErrorReason returns the provider's verbatim reason for request-shaped
// 4xx (400/422) — the localized line names the category, the body names the
// actual cause (context-length exceeded, unpaired tool_calls). Empty otherwise.
func requestErrorReason(e *provider.APIError) string {
if e.Status != 400 && e.Status != 422 {
return ""
}
return providerBodyReason(e.Body)
}
// 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]) + "…"
}
+15
View File
@@ -31,6 +31,21 @@ func TestExplainError(t *testing.T) {
}
}
jsonBody := explainError(&provider.APIError{Provider: "deepseek", Status: 400, Body: `{"error":{"message":"This model's maximum context length is 65536 tokens.","type":"invalid_request_error"}}`})
if !strings.Contains(jsonBody.Error(), i18n.M.ProviderErrBadRequest) || !strings.Contains(jsonBody.Error(), "maximum context length") {
t.Errorf("400 should append the provider reason from a JSON body, got %q", jsonBody.Error())
}
rawBody := explainError(&provider.APIError{Provider: "deepseek", Status: 422, Body: "some unparseable detail"})
if !strings.Contains(rawBody.Error(), "some unparseable detail") {
t.Errorf("422 should fall back to the raw body, got %q", rawBody.Error())
}
noLeak := explainError(&provider.APIError{Provider: "deepseek", Status: 429, Body: `{"error":{"message":"slow down"}}`})
if noLeak.Error() != i18n.M.ProviderErrRateLimited {
t.Errorf("429 body must not leak into the message, got %q", noLeak.Error())
}
plain := errors.New("some other failure")
if explainError(plain) != plain {
t.Error("unknown errors should pass through unchanged")