diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 8590192..89b1c82 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -434,7 +434,7 @@ func (p *Provider) probeModels(ctx context.Context) (bool, string) { case resp.StatusCode == 404 || resp.StatusCode == 405: return false, "" default: - return false, fmt.Sprintf("api error %d: %s", resp.StatusCode, truncate(string(raw), 300)) + return false, shortAPIError(resp.StatusCode, string(raw)) } } @@ -471,7 +471,7 @@ func (p *Provider) probeChat(ctx context.Context) (bool, string) { } else if status == 200 { ok = true } else { - msg = fmt.Sprintf("api error %d: %s", status, truncate(raw, 500)) + msg = shortAPIError(status, raw) } } else { msg = err.Error() @@ -717,7 +717,7 @@ func (p *Provider) Chat(ctx context.Context, req *types.ChatRequest) (*types.Uni } if status != 200 { p.ReportStatus(model, status) - return nil, fmt.Errorf("api error %d: %s", status, truncate(raw, 500)) + return nil, fmt.Errorf("%s", shortAPIError(status, raw)) } unified, err := p.vm.Transform(p.adapter, "transform_response", raw) if err != nil { @@ -793,7 +793,7 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch sel.resp.Body.Close() p.ReportStatus(model, sel.resp.StatusCode) p.Release() - return nil, fmt.Errorf("api error %d: %s", sel.resp.StatusCode, truncate(string(raw), 500)) + return nil, fmt.Errorf("%s", shortAPIError(sel.resp.StatusCode, string(raw))) } go func() { defer p.Release() @@ -928,6 +928,56 @@ func errorOnlyChunk(ck types.UnifiedChunk) bool { ck.ReasoningContent == "" && ck.Usage == nil } +// shortAPIError condenses an upstream error response into its human reason: +// JSON envelopes contribute their error/message field, HTML pages (WAF +// blocklists) collapse to a marker, anything else is capped as-is. Raw +// bodies must not leak through errors to clients. +func shortAPIError(status int, body string) string { + b := strings.TrimSpace(body) + low := strings.ToLower(b) + if strings.HasPrefix(low, " 0: + var es string + if json.Unmarshal(env.Error, &es) == nil { + msg = es + } else { + var obj struct { + Message string `json:"message"` + } + if json.Unmarshal(env.Error, &obj) == nil { + msg = obj.Message + } + } + case env.Message != "": + msg = env.Message + case env.Msg != "": + msg = env.Msg + } + if msg != "" { + return fmt.Sprintf("api error %d: %s", status, oneLineStr(msg, 160)) + } + } + return fmt.Sprintf("api error %d: %s", status, oneLineStr(b, 160)) +} + +func oneLineStr(s string, n int) string { + s = strings.Join(strings.Fields(s), " ") + if len(s) > n { + s = s[:n] + "..." + } + return s +} + // Image generates images via /v1/images/generations. Same scheduling-state // accounting as Chat: fail fast on busy, record per (source, model). func (p *Provider) Image(ctx context.Context, req *types.ImageGenRequest) (*types.UnifiedResponse, error) { diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 66b9d66..d24a342 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -538,3 +538,30 @@ func TestStandardSSEChunkFinishReason(t *testing.T) { t.Fatalf("empty-string finish reason is not a finish signal: %s", out) } } + +func TestShortAPIError(t *testing.T) { + cases := []struct{ name, body, want string }{ + {"openai-style envelope", `{"error":{"message":"Allocated quota exceeded","type":"invalid_request_error","code":"insufficient_quota"}}`, "api error 429: Allocated quota exceeded"}, + {"nested console envelope", `{"error":{"type":"server_error","message":"Error from provider (Console): Upstream request failed: Endpoint is unavailable."}}`, "api error 503: Error from provider (Console): Upstream request failed: Endpoint is unavailable."}, + {"string error", `{"error":"boom"}`, "api error 500: boom"}, + {"html waf page", "405", "api error 405: upstream returned an HTML error page"}, + {"plain text body", "service unavailable", "api error 503: service unavailable"}, + } + for _, c := range cases { + if got := shortAPIError(statusFor(c.want), c.body); got != c.want { + t.Fatalf("%s: got %q want %q", c.name, got, c.want) + } + } +} + +func statusFor(want string) int { + switch { + case strings.Contains(want, "429"): + return 429 + case strings.Contains(want, "503"): + return 503 + case strings.Contains(want, "405"): + return 405 + } + return 500 +}