From 9216cd14b5a7bc39b92eb94011cddb4386834f83 Mon Sep 17 00:00:00 2001 From: qs3c <2749950753@qq.com> Date: Thu, 5 Mar 2026 19:42:58 +0800 Subject: [PATCH] fix(openai_compat): handle html error bodies and reduce allocations --- pkg/providers/openai_compat/provider.go | 72 ++++++++++++++------ pkg/providers/openai_compat/provider_test.go | 32 +++++++++ 2 files changed, 85 insertions(+), 19 deletions(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index e6ccbdefc..0422f0eb4 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "log" @@ -189,7 +190,7 @@ func (p *Provider) Chat( } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body)) + return nil, wrapHTTPResponseError(resp.StatusCode, body, resp.Header.Get("Content-Type"), p.apiBase) } out, err := parseResponse(body) @@ -201,44 +202,77 @@ func (p *Provider) Chat( } func wrapResponseParseError(err error, body []byte, contentType, apiBase string) error { - trimmedContentType := strings.TrimSpace(contentType) - if looksLikeHTML(body, trimmedContentType) { - contentTypeHint := "" - if trimmedContentType != "" { - contentTypeHint = fmt.Sprintf(" (content-type: %s)", trimmedContentType) - } - return fmt.Errorf( - "expected JSON response from %s/chat/completions, but received HTML%s; check api_base or proxy configuration. Response preview: %s", - apiBase, - contentTypeHint, - responsePreview(body, 160), - ) + if message, ok := htmlResponseMessage(body, contentType, apiBase); ok { + return errors.New(message) } return err } +func wrapHTTPResponseError(statusCode int, body []byte, contentType, apiBase string) error { + if message, ok := htmlResponseMessage(body, contentType, apiBase); ok { + return fmt.Errorf("API request failed:\n Status: %d\n Detail: %s", statusCode, message) + } + return fmt.Errorf("API request failed:\n Status: %d\n Body: %s", statusCode, string(body)) +} + +func htmlResponseMessage(body []byte, contentType, apiBase string) (string, bool) { + trimmedContentType := strings.TrimSpace(contentType) + if !looksLikeHTML(body, trimmedContentType) { + return "", false + } + + contentTypeHint := "" + if trimmedContentType != "" { + contentTypeHint = fmt.Sprintf(" (content-type: %s)", trimmedContentType) + } + + return fmt.Sprintf( + "expected JSON response from %s/chat/completions, but received HTML%s; check api_base or proxy configuration. Response preview: %s", + apiBase, + contentTypeHint, + responsePreview(body, 160), + ), true +} + func looksLikeHTML(body []byte, contentType string) bool { contentType = strings.ToLower(strings.TrimSpace(contentType)) if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml") { return true } - trimmed := strings.ToLower(strings.TrimSpace(string(body))) + trimmed := strings.ToLower(string(leadingTrimmedPrefix(body, 128))) return strings.HasPrefix(trimmed, " len(body) { + end = len(body) + } + return body[i:end] + } + } + return nil +} + func responsePreview(body []byte, maxLen int) string { - preview := strings.TrimSpace(string(body)) - if preview == "" { + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 { return "" } - if len(preview) <= maxLen { - return preview + if len(trimmed) <= maxLen { + return string(trimmed) } - return preview[:maxLen] + "..." + return string(trimmed[:maxLen]) + "..." } func parseResponse(body []byte) (*LLMResponse, error) { diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 244a20672..899d10c8d 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -1,6 +1,7 @@ package openai_compat import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" @@ -233,6 +234,37 @@ func TestProviderChat_HTMLSuccessResponseReturnsHelpfulError(t *testing.T) { } } +func TestProviderChat_HTMLErrorResponseReturnsHelpfulError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("bad gateway")) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "Status: 502") { + t.Fatalf("expected status code in error, got %v", err) + } + if !strings.Contains(err.Error(), "received HTML") { + t.Fatalf("expected helpful HTML error, got %v", err) + } + if !strings.Contains(err.Error(), "check api_base or proxy configuration") { + t.Fatalf("expected configuration hint, got %v", err) + } +} + +func TestLooksLikeHTML_SniffsPrefixWithLargeBody(t *testing.T) { + body := append([]byte(" \r\n\tx"), bytes.Repeat([]byte("A"), 1024*1024)...) + if !looksLikeHTML(body, "") { + t.Fatal("expected looksLikeHTML to detect html prefix") + } +} + func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) { var requestBody map[string]any