Skip to content

Commit 440d665

Browse files
authored
Merge pull request #1075 from qs3c/fix/1068-html-response-error
fix(openai_compat): clarify HTML response parse errors
2 parents b8f8e3f + 53cba73 commit 440d665

File tree

2 files changed

+247
-8
lines changed

2 files changed

+247
-8
lines changed

pkg/providers/openai_compat/provider.go

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package openai_compat
22

33
import (
4+
"bufio"
45
"bytes"
56
"context"
67
"encoding/json"
@@ -183,19 +184,94 @@ func (p *Provider) Chat(
183184
}
184185
defer resp.Body.Close()
185186

186-
body, err := io.ReadAll(resp.Body)
187+
contentType := resp.Header.Get("Content-Type")
188+
189+
// Non-200: read a prefix to tell HTML error page apart from JSON error body.
190+
if resp.StatusCode != http.StatusOK {
191+
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256))
192+
if readErr != nil {
193+
return nil, fmt.Errorf("failed to read response: %w", readErr)
194+
}
195+
if looksLikeHTML(body, contentType) {
196+
return nil, wrapHTMLResponseError(resp.StatusCode, body, contentType, p.apiBase)
197+
}
198+
return nil, fmt.Errorf(
199+
"API request failed:\n Status: %d\n Body: %s",
200+
resp.StatusCode,
201+
responsePreview(body, 128),
202+
)
203+
}
204+
205+
// Peek without consuming so the full stream reaches the JSON decoder.
206+
reader := bufio.NewReader(resp.Body)
207+
prefix, err := reader.Peek(256) // io.EOF/ErrBufferFull are normal; only real errors abort
208+
if err != nil && err != io.EOF && err != bufio.ErrBufferFull {
209+
return nil, fmt.Errorf("failed to inspect response: %w", err)
210+
}
211+
if looksLikeHTML(prefix, contentType) {
212+
return nil, wrapHTMLResponseError(resp.StatusCode, prefix, contentType, p.apiBase)
213+
}
214+
215+
out, err := parseResponse(reader)
187216
if err != nil {
188-
return nil, fmt.Errorf("failed to read response: %w", err)
217+
return nil, fmt.Errorf("failed to parse JSON response: %w", err)
189218
}
190219

191-
if resp.StatusCode != http.StatusOK {
192-
return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body))
220+
return out, nil
221+
}
222+
223+
func wrapHTMLResponseError(statusCode int, body []byte, contentType, apiBase string) error {
224+
respPreview := responsePreview(body, 128)
225+
return fmt.Errorf(
226+
"API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n Body: %s",
227+
apiBase,
228+
contentType,
229+
statusCode,
230+
respPreview,
231+
)
232+
}
233+
234+
func looksLikeHTML(body []byte, contentType string) bool {
235+
contentType = strings.ToLower(strings.TrimSpace(contentType))
236+
if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml") {
237+
return true
238+
}
239+
prefix := bytes.ToLower(leadingTrimmedPrefix(body, 128))
240+
return bytes.HasPrefix(prefix, []byte("<!doctype html")) ||
241+
bytes.HasPrefix(prefix, []byte("<html")) ||
242+
bytes.HasPrefix(prefix, []byte("<head")) ||
243+
bytes.HasPrefix(prefix, []byte("<body"))
244+
}
245+
246+
func leadingTrimmedPrefix(body []byte, maxLen int) []byte {
247+
i := 0
248+
for i < len(body) {
249+
switch body[i] {
250+
case ' ', '\t', '\n', '\r', '\f', '\v':
251+
i++
252+
default:
253+
end := i + maxLen
254+
if end > len(body) {
255+
end = len(body)
256+
}
257+
return body[i:end]
258+
}
193259
}
260+
return nil
261+
}
194262

195-
return parseResponse(body)
263+
func responsePreview(body []byte, maxLen int) string {
264+
trimmed := bytes.TrimSpace(body)
265+
if len(trimmed) == 0 {
266+
return "<empty>"
267+
}
268+
if len(trimmed) <= maxLen {
269+
return string(trimmed)
270+
}
271+
return string(trimmed[:maxLen]) + "..."
196272
}
197273

198-
func parseResponse(body []byte) (*LLMResponse, error) {
274+
func parseResponse(body io.Reader) (*LLMResponse, error) {
199275
var apiResponse struct {
200276
Choices []struct {
201277
Message struct {
@@ -222,8 +298,8 @@ func parseResponse(body []byte) (*LLMResponse, error) {
222298
Usage *UsageInfo `json:"usage"`
223299
}
224300

225-
if err := json.Unmarshal(body, &apiResponse); err != nil {
226-
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
301+
if err := json.NewDecoder(body).Decode(&apiResponse); err != nil {
302+
return nil, fmt.Errorf("failed to decode response: %w", err)
227303
}
228304

229305
if len(apiResponse.Choices) == 0 {

pkg/providers/openai_compat/provider_test.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package openai_compat
22

33
import (
4+
"bytes"
45
"encoding/json"
6+
"fmt"
7+
"io"
58
"net/http"
69
"net/http/httptest"
710
"net/url"
@@ -212,6 +215,132 @@ func TestProviderChat_HTTPError(t *testing.T) {
212215
}
213216
}
214217

218+
func TestProviderChat_JSONHTTPErrorDoesNotReportHTML(t *testing.T) {
219+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
220+
w.Header().Set("Content-Type", "application/json")
221+
w.WriteHeader(http.StatusBadRequest)
222+
_, _ = w.Write([]byte(`{"error":"bad request"}`))
223+
}))
224+
defer server.Close()
225+
226+
p := NewProvider("key", server.URL, "")
227+
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
228+
if err == nil {
229+
t.Fatal("expected error, got nil")
230+
}
231+
if !strings.Contains(err.Error(), "Status: 400") {
232+
t.Fatalf("expected status code in error, got %v", err)
233+
}
234+
if strings.Contains(err.Error(), "returned HTML instead of JSON") {
235+
t.Fatalf("expected non-HTML http error, got %v", err)
236+
}
237+
}
238+
239+
func TestProviderChat_HTMLResponsesReturnHelpfulError(t *testing.T) {
240+
tests := []struct {
241+
name string
242+
contentType string
243+
statusCode int
244+
body string
245+
}{
246+
{
247+
name: "html success response",
248+
contentType: "text/html; charset=utf-8",
249+
statusCode: http.StatusOK,
250+
body: "<!DOCTYPE html><html><body>gateway login</body></html>",
251+
},
252+
{
253+
name: "html error response",
254+
contentType: "text/html; charset=utf-8",
255+
statusCode: http.StatusBadGateway,
256+
body: "<!DOCTYPE html><html><body>bad gateway</body></html>",
257+
},
258+
{
259+
name: "mislabeled html success response",
260+
contentType: "application/json",
261+
statusCode: http.StatusOK,
262+
body: " \r\n\t<!DOCTYPE html><html><body>gateway login</body></html>",
263+
},
264+
}
265+
266+
for _, tt := range tests {
267+
t.Run(tt.name, func(t *testing.T) {
268+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
269+
w.Header().Set("Content-Type", tt.contentType)
270+
w.WriteHeader(tt.statusCode)
271+
_, _ = w.Write([]byte(tt.body))
272+
}))
273+
defer server.Close()
274+
275+
p := NewProvider("key", server.URL, "")
276+
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
277+
if err == nil {
278+
t.Fatal("expected error, got nil")
279+
}
280+
if !strings.Contains(err.Error(), fmt.Sprintf("Status: %d", tt.statusCode)) {
281+
t.Fatalf("expected status code in error, got %v", err)
282+
}
283+
if !strings.Contains(err.Error(), "returned HTML instead of JSON") {
284+
t.Fatalf("expected helpful HTML error, got %v", err)
285+
}
286+
if !strings.Contains(err.Error(), "check api_base or proxy configuration") {
287+
t.Fatalf("expected configuration hint, got %v", err)
288+
}
289+
})
290+
}
291+
}
292+
293+
func TestProviderChat_SuccessResponseUsesStreamingDecoder(t *testing.T) {
294+
content := strings.Repeat("a", 1024)
295+
body := `{"choices":[{"message":{"content":"` + content + `"},"finish_reason":"stop"}]}`
296+
297+
p := NewProvider("key", "https://example.com/v1", "")
298+
p.httpClient = &http.Client{
299+
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
300+
return &http.Response{
301+
StatusCode: http.StatusOK,
302+
Header: http.Header{"Content-Type": []string{"application/json"}},
303+
Body: &errAfterDataReadCloser{
304+
data: []byte(body),
305+
chunkSize: 64,
306+
},
307+
}, nil
308+
}),
309+
}
310+
311+
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
312+
if err != nil {
313+
t.Fatalf("Chat() error = %v", err)
314+
}
315+
if out.Content != content {
316+
t.Fatalf("Content = %q, want %q", out.Content, content)
317+
}
318+
}
319+
320+
func TestProviderChat_LargeHTMLResponsePreviewIsTruncated(t *testing.T) {
321+
body := append([]byte("<!DOCTYPE html><html><body>"), bytes.Repeat([]byte("A"), 2048)...)
322+
body = append(body, []byte("</body></html>")...)
323+
324+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
325+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
326+
w.WriteHeader(http.StatusBadGateway)
327+
_, _ = w.Write(body)
328+
}))
329+
defer server.Close()
330+
331+
p := NewProvider("key", server.URL, "")
332+
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
333+
if err == nil {
334+
t.Fatal("expected error, got nil")
335+
}
336+
if !strings.Contains(err.Error(), "Body: <!DOCTYPE html><html><body>") {
337+
t.Fatalf("expected html preview in error, got %v", err)
338+
}
339+
if !strings.Contains(err.Error(), "...") {
340+
t.Fatalf("expected truncated preview, got %v", err)
341+
}
342+
}
343+
215344
func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) {
216345
var requestBody map[string]any
217346

@@ -399,6 +528,40 @@ func TestProvider_RequestTimeoutOverride(t *testing.T) {
399528
}
400529
}
401530

531+
type roundTripperFunc func(*http.Request) (*http.Response, error)
532+
533+
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
534+
return f(r)
535+
}
536+
537+
type errAfterDataReadCloser struct {
538+
data []byte
539+
chunkSize int
540+
offset int
541+
}
542+
543+
func (r *errAfterDataReadCloser) Read(p []byte) (int, error) {
544+
if r.offset >= len(r.data) {
545+
return 0, io.ErrUnexpectedEOF
546+
}
547+
548+
n := r.chunkSize
549+
if n <= 0 || n > len(p) {
550+
n = len(p)
551+
}
552+
remaining := len(r.data) - r.offset
553+
if n > remaining {
554+
n = remaining
555+
}
556+
copy(p, r.data[r.offset:r.offset+n])
557+
r.offset += n
558+
return n, nil
559+
}
560+
561+
func (r *errAfterDataReadCloser) Close() error {
562+
return nil
563+
}
564+
402565
func TestProvider_FunctionalOptionMaxTokensField(t *testing.T) {
403566
p := NewProvider("key", "https://example.com/v1", "", WithMaxTokensField("max_completion_tokens"))
404567
if p.maxTokensField != "max_completion_tokens" {

0 commit comments

Comments
 (0)