From 4946a8b449a963cbe04c1719f88f07d95f692f29 Mon Sep 17 00:00:00 2001 From: qs3c <2749950753@qq.com> Date: Wed, 4 Mar 2026 17:50:46 +0800 Subject: [PATCH 1/6] fix(openai_compat): clarify HTML response errors --- pkg/providers/openai_compat/provider.go | 48 +++++++++++++++++++- pkg/providers/openai_compat/provider_test.go | 21 +++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index ff9109e96..621e34a89 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -192,7 +192,53 @@ func (p *Provider) Chat( return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body)) } - return parseResponse(body) + out, err := parseResponse(body) + if err != nil { + return nil, wrapResponseParseError(err, body, resp.Header.Get("Content-Type"), p.apiBase) + } + + return out, nil +} + +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), + ) + } + return err +} + +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))) + return strings.HasPrefix(trimmed, "" + } + if len(preview) <= max { + return preview + } + return preview[:max] + "..." } 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 174bcf00d..244a20672 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -212,6 +212,27 @@ func TestProviderChat_HTTPError(t *testing.T) { } } +func TestProviderChat_HTMLSuccessResponseReturnsHelpfulError(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.StatusOK) + _, _ = w.Write([]byte("gateway login")) + })) + 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(), "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 TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) { var requestBody map[string]any From a305c0a4790d39590592f529a883386b9db83316 Mon Sep 17 00:00:00 2001 From: amagi <2749950753@qq.com> Date: Wed, 4 Mar 2026 23:57:26 +0800 Subject: [PATCH 2/6] fix(openai_compat): avoid predeclared identifier in preview --- pkg/providers/openai_compat/provider.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 621e34a89..e6ccbdefc 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -230,15 +230,15 @@ func looksLikeHTML(body []byte, contentType string) bool { strings.HasPrefix(trimmed, "" } - if len(preview) <= max { + if len(preview) <= maxLen { return preview } - return preview[:max] + "..." + return preview[:maxLen] + "..." } func parseResponse(body []byte) (*LLMResponse, error) { 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 3/6] 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 From c1a3876f7de9251412ddc71a42530482d80334b1 Mon Sep 17 00:00:00 2001 From: amagi <2749950753@qq.com> Date: Fri, 6 Mar 2026 01:51:24 +0800 Subject: [PATCH 4/6] fix: improve error handling for non-JSON responses by checking content type and using a streaming JSON parser. --- pkg/providers/openai_compat/provider.go | 89 ++++---------------- pkg/providers/openai_compat/provider_test.go | 46 +++++++++- 2 files changed, 58 insertions(+), 77 deletions(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 0422f0eb4..22d4da56c 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "io" "log" @@ -184,84 +183,28 @@ func (p *Provider) Chat( } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) + contentType := resp.Header.Get("Content-Type") + + // check if there is an HTTP error (caused by proxy or gateway) or if the response is HTML + if resp.StatusCode != http.StatusOK || strings.Contains(strings.ToLower(contentType), "text/html") { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) + return nil, wrapHTTPResponseError(resp.StatusCode, body, contentType, p.apiBase) } - if resp.StatusCode != http.StatusOK { - return nil, wrapHTTPResponseError(resp.StatusCode, body, resp.Header.Get("Content-Type"), p.apiBase) - } - - out, err := parseResponse(body) + // directly pass the stream (resp.Body) to the JSON parser without loading everything into memory + out, err := parseResponse(resp.Body) if err != nil { - return nil, wrapResponseParseError(err, body, resp.Header.Get("Content-Type"), p.apiBase) + // Note: if it fails here, we do not have the full body in memory for HTML inspection, + // but having already checked the Content-Type above, the error is genuinely related to JSON parsing. + return nil, fmt.Errorf("failed to parse JSON response: %w", err) } return out, nil } -func wrapResponseParseError(err error, body []byte, contentType, apiBase string) error { - 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(string(leadingTrimmedPrefix(body, 128))) - return strings.HasPrefix(trimmed, " len(body) { - end = len(body) - } - return body[i:end] - } - } - return nil + respPreview := responsePreview(body, 128) + return fmt.Errorf("API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n Body: %s", apiBase, contentType, statusCode, respPreview) } func responsePreview(body []byte, maxLen int) string { @@ -275,7 +218,7 @@ func responsePreview(body []byte, maxLen int) string { return string(trimmed[:maxLen]) + "..." } -func parseResponse(body []byte) (*LLMResponse, error) { +func parseResponse(body io.Reader) (*LLMResponse, error) { var apiResponse struct { Choices []struct { Message struct { @@ -302,8 +245,8 @@ func parseResponse(body []byte) (*LLMResponse, error) { Usage *UsageInfo `json:"usage"` } - if err := json.Unmarshal(body, &apiResponse); err != nil { - return nil, fmt.Errorf("failed to unmarshal response: %w", err) + if err := json.NewDecoder(body).Decode(&apiResponse); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) } if len(apiResponse.Choices) == 0 { diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 899d10c8d..84e6bbe3e 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -258,10 +258,48 @@ func TestProviderChat_HTMLErrorResponseReturnsHelpfulError(t *testing.T) { } } -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_MislabeledHTMLSuccessResponseReturnsHelpfulError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(" \r\n\tgateway login")) + })) + 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(), "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 TestProviderChat_LargeHTMLResponsePreviewIsTruncated(t *testing.T) { + body := append([]byte(""), bytes.Repeat([]byte("A"), 2048)...) + body = append(body, []byte("")...) + + 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(body) + })) + 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(), "Response preview: ") { + t.Fatalf("expected html preview in error, got %v", err) + } + if !strings.Contains(err.Error(), "...") { + t.Fatalf("expected truncated preview, got %v", err) } } From 6eaa49f7ab1f488ba8b3df39539cb191339b0799 Mon Sep 17 00:00:00 2001 From: amagi <2749950753@qq.com> Date: Sat, 7 Mar 2026 15:50:08 +0800 Subject: [PATCH 5/6] fix: improve openai compat HTML response handling --- pkg/providers/openai_compat/provider.go | 61 +++++++++++-- pkg/providers/openai_compat/provider_test.go | 91 +++++++++++++++++++- 2 files changed, 139 insertions(+), 13 deletions(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 22d4da56c..6b8f0181d 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -1,6 +1,7 @@ package openai_compat import ( + "bufio" "bytes" "context" "encoding/json" @@ -185,28 +186,70 @@ func (p *Provider) Chat( contentType := resp.Header.Get("Content-Type") - // check if there is an HTTP error (caused by proxy or gateway) or if the response is HTML - if resp.StatusCode != http.StatusOK || strings.Contains(strings.ToLower(contentType), "text/html") { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) - return nil, wrapHTTPResponseError(resp.StatusCode, body, contentType, p.apiBase) + // Non-200: read a prefix to tell HTML error page apart from JSON error body. + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(io.LimitReader(resp.Body, 256)) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if looksLikeHTML(body, contentType) { + return nil, wrapHTMLResponseError(resp.StatusCode, body, contentType, p.apiBase) + } + return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, responsePreview(body, 128)) } - // directly pass the stream (resp.Body) to the JSON parser without loading everything into memory - out, err := parseResponse(resp.Body) + // Peek without consuming so the full stream reaches the JSON decoder. + reader := bufio.NewReader(resp.Body) + prefix, err := reader.Peek(256) // io.EOF/ErrBufferFull are normal; only real errors abort + if err != nil && err != io.EOF && err != bufio.ErrBufferFull { + return nil, fmt.Errorf("failed to inspect response: %w", err) + } + if looksLikeHTML(prefix, contentType) { + return nil, wrapHTMLResponseError(resp.StatusCode, prefix, contentType, p.apiBase) + } + + out, err := parseResponse(reader) if err != nil { - // Note: if it fails here, we do not have the full body in memory for HTML inspection, - // but having already checked the Content-Type above, the error is genuinely related to JSON parsing. return nil, fmt.Errorf("failed to parse JSON response: %w", err) } return out, nil } -func wrapHTTPResponseError(statusCode int, body []byte, contentType, apiBase string) error { +func wrapHTMLResponseError(statusCode int, body []byte, contentType, apiBase string) error { respPreview := responsePreview(body, 128) return fmt.Errorf("API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n Body: %s", apiBase, contentType, statusCode, respPreview) } +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 + } + prefix := bytes.ToLower(leadingTrimmedPrefix(body, 128)) + return bytes.HasPrefix(prefix, []byte(" len(body) { + end = len(body) + } + return body[i:end] + } + } + return nil +} + func responsePreview(body []byte, maxLen int) string { trimmed := bytes.TrimSpace(body) if len(trimmed) == 0 { diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 84e6bbe3e..c729289d4 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -3,6 +3,7 @@ package openai_compat import ( "bytes" "encoding/json" + "io" "net/http" "net/http/httptest" "net/url" @@ -213,6 +214,27 @@ func TestProviderChat_HTTPError(t *testing.T) { } } +func TestProviderChat_JSONHTTPErrorDoesNotReportHTML(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"bad request"}`)) + })) + 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: 400") { + t.Fatalf("expected status code in error, got %v", err) + } + if strings.Contains(err.Error(), "returned HTML instead of JSON") { + t.Fatalf("expected non-HTML http error, got %v", err) + } +} + func TestProviderChat_HTMLSuccessResponseReturnsHelpfulError(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") @@ -226,7 +248,7 @@ func TestProviderChat_HTMLSuccessResponseReturnsHelpfulError(t *testing.T) { if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), "received HTML") { + if !strings.Contains(err.Error(), "returned HTML instead of JSON") { t.Fatalf("expected helpful HTML error, got %v", err) } if !strings.Contains(err.Error(), "check api_base or proxy configuration") { @@ -250,7 +272,7 @@ func TestProviderChat_HTMLErrorResponseReturnsHelpfulError(t *testing.T) { if !strings.Contains(err.Error(), "Status: 502") { t.Fatalf("expected status code in error, got %v", err) } - if !strings.Contains(err.Error(), "received HTML") { + if !strings.Contains(err.Error(), "returned HTML instead of JSON") { t.Fatalf("expected helpful HTML error, got %v", err) } if !strings.Contains(err.Error(), "check api_base or proxy configuration") { @@ -271,7 +293,7 @@ func TestProviderChat_MislabeledHTMLSuccessResponseReturnsHelpfulError(t *testin if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), "received HTML") { + if !strings.Contains(err.Error(), "returned HTML instead of JSON") { t.Fatalf("expected helpful HTML error, got %v", err) } if !strings.Contains(err.Error(), "check api_base or proxy configuration") { @@ -279,6 +301,33 @@ func TestProviderChat_MislabeledHTMLSuccessResponseReturnsHelpfulError(t *testin } } +func TestProviderChat_SuccessResponseUsesStreamingDecoder(t *testing.T) { + content := strings.Repeat("a", 1024) + body := `{"choices":[{"message":{"content":"` + content + `"},"finish_reason":"stop"}]}` + + p := NewProvider("key", "https://example.com/v1", "") + p.httpClient = &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: &errAfterDataReadCloser{ + data: []byte(body), + chunkSize: 64, + }, + }, nil + }), + } + + out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if out.Content != content { + t.Fatalf("Content = %q, want %q", out.Content, content) + } +} + func TestProviderChat_LargeHTMLResponsePreviewIsTruncated(t *testing.T) { body := append([]byte(""), bytes.Repeat([]byte("A"), 2048)...) body = append(body, []byte("")...) @@ -295,7 +344,7 @@ func TestProviderChat_LargeHTMLResponsePreviewIsTruncated(t *testing.T) { if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), "Response preview: ") { + if !strings.Contains(err.Error(), "Body: ") { t.Fatalf("expected html preview in error, got %v", err) } if !strings.Contains(err.Error(), "...") { @@ -490,6 +539,40 @@ func TestProvider_RequestTimeoutOverride(t *testing.T) { } } +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} + +type errAfterDataReadCloser struct { + data []byte + chunkSize int + offset int +} + +func (r *errAfterDataReadCloser) Read(p []byte) (int, error) { + if r.offset >= len(r.data) { + return 0, io.ErrUnexpectedEOF + } + + n := r.chunkSize + if n <= 0 || n > len(p) { + n = len(p) + } + remaining := len(r.data) - r.offset + if n > remaining { + n = remaining + } + copy(p, r.data[r.offset:r.offset+n]) + r.offset += n + return n, nil +} + +func (r *errAfterDataReadCloser) Close() error { + return nil +} + func TestProvider_FunctionalOptionMaxTokensField(t *testing.T) { p := NewProvider("key", "https://example.com/v1", "", WithMaxTokensField("max_completion_tokens")) if p.maxTokensField != "max_completion_tokens" { From 53cba73283e53e1bf6933cf01a42de3b696bc298 Mon Sep 17 00:00:00 2001 From: amagi <2749950753@qq.com> Date: Sat, 7 Mar 2026 16:12:23 +0800 Subject: [PATCH 6/6] fix: resolve openai compat lint issues --- pkg/providers/openai_compat/provider.go | 20 +++- pkg/providers/openai_compat/provider_test.go | 111 +++++++++---------- 2 files changed, 65 insertions(+), 66 deletions(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 6b8f0181d..83966180a 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -188,14 +188,18 @@ func (p *Provider) Chat( // Non-200: read a prefix to tell HTML error page apart from JSON error body. if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(io.LimitReader(resp.Body, 256)) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256)) + if readErr != nil { + return nil, fmt.Errorf("failed to read response: %w", readErr) } if looksLikeHTML(body, contentType) { return nil, wrapHTMLResponseError(resp.StatusCode, body, contentType, p.apiBase) } - return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, responsePreview(body, 128)) + return nil, fmt.Errorf( + "API request failed:\n Status: %d\n Body: %s", + resp.StatusCode, + responsePreview(body, 128), + ) } // Peek without consuming so the full stream reaches the JSON decoder. @@ -218,7 +222,13 @@ func (p *Provider) Chat( func wrapHTMLResponseError(statusCode int, body []byte, contentType, apiBase string) error { respPreview := responsePreview(body, 128) - return fmt.Errorf("API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n Body: %s", apiBase, contentType, statusCode, respPreview) + return fmt.Errorf( + "API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n Body: %s", + apiBase, + contentType, + statusCode, + respPreview, + ) } func looksLikeHTML(body []byte, contentType string) bool { diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index c729289d4..5c4dcd1b0 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -3,6 +3,7 @@ package openai_compat import ( "bytes" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -235,69 +236,57 @@ func TestProviderChat_JSONHTTPErrorDoesNotReportHTML(t *testing.T) { } } -func TestProviderChat_HTMLSuccessResponseReturnsHelpfulError(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.StatusOK) - _, _ = w.Write([]byte("gateway login")) - })) - defer server.Close() +func TestProviderChat_HTMLResponsesReturnHelpfulError(t *testing.T) { + tests := []struct { + name string + contentType string + statusCode int + body string + }{ + { + name: "html success response", + contentType: "text/html; charset=utf-8", + statusCode: http.StatusOK, + body: "gateway login", + }, + { + name: "html error response", + contentType: "text/html; charset=utf-8", + statusCode: http.StatusBadGateway, + body: "bad gateway", + }, + { + name: "mislabeled html success response", + contentType: "application/json", + statusCode: http.StatusOK, + body: " \r\n\tgateway login", + }, + } - 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(), "returned HTML instead of JSON") { - 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) - } -} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", tt.contentType) + w.WriteHeader(tt.statusCode) + _, _ = w.Write([]byte(tt.body)) + })) + defer server.Close() -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(), "returned HTML instead of JSON") { - 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 TestProviderChat_MislabeledHTMLSuccessResponseReturnsHelpfulError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(" \r\n\tgateway login")) - })) - 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(), "returned HTML instead of JSON") { - 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) + 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(), fmt.Sprintf("Status: %d", tt.statusCode)) { + t.Fatalf("expected status code in error, got %v", err) + } + if !strings.Contains(err.Error(), "returned HTML instead of JSON") { + 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) + } + }) } }