fix(openai_compat): handle html error bodies and reduce allocations

This commit is contained in:
qs3c 2026-03-05 19:42:58 +08:00
parent 58c70fe4c0
commit fd0018d84f
2 changed files with 85 additions and 19 deletions

View file

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@ -189,7 +190,7 @@ func (p *Provider) Chat(
} }
if resp.StatusCode != http.StatusOK { 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) out, err := parseResponse(body)
@ -201,44 +202,77 @@ func (p *Provider) Chat(
} }
func wrapResponseParseError(err error, body []byte, contentType, apiBase string) error { func wrapResponseParseError(err error, body []byte, contentType, apiBase string) error {
trimmedContentType := strings.TrimSpace(contentType) if message, ok := htmlResponseMessage(body, contentType, apiBase); ok {
if looksLikeHTML(body, trimmedContentType) { return errors.New(message)
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 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 { func looksLikeHTML(body []byte, contentType string) bool {
contentType = strings.ToLower(strings.TrimSpace(contentType)) contentType = strings.ToLower(strings.TrimSpace(contentType))
if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml") { if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml") {
return true return true
} }
trimmed := strings.ToLower(strings.TrimSpace(string(body))) trimmed := strings.ToLower(string(leadingTrimmedPrefix(body, 128)))
return strings.HasPrefix(trimmed, "<!doctype html") || return strings.HasPrefix(trimmed, "<!doctype html") ||
strings.HasPrefix(trimmed, "<html") || strings.HasPrefix(trimmed, "<html") ||
strings.HasPrefix(trimmed, "<head") || strings.HasPrefix(trimmed, "<head") ||
strings.HasPrefix(trimmed, "<body") strings.HasPrefix(trimmed, "<body")
} }
func leadingTrimmedPrefix(body []byte, maxLen int) []byte {
i := 0
for i < len(body) {
switch body[i] {
case ' ', '\t', '\n', '\r', '\f', '\v':
i++
default:
end := i + maxLen
if end > len(body) {
end = len(body)
}
return body[i:end]
}
}
return nil
}
func responsePreview(body []byte, maxLen int) string { func responsePreview(body []byte, maxLen int) string {
preview := strings.TrimSpace(string(body)) trimmed := bytes.TrimSpace(body)
if preview == "" { if len(trimmed) == 0 {
return "<empty>" return "<empty>"
} }
if len(preview) <= maxLen { if len(trimmed) <= maxLen {
return preview return string(trimmed)
} }
return preview[:maxLen] + "..." return string(trimmed[:maxLen]) + "..."
} }
func parseResponse(body []byte) (*LLMResponse, error) { func parseResponse(body []byte) (*LLMResponse, error) {

View file

@ -1,6 +1,7 @@
package openai_compat package openai_compat
import ( import (
"bytes"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "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("<!DOCTYPE html><html><body>bad gateway</body></html>"))
}))
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\t<!DOCTYPE html><html><body>x</body></html>"), bytes.Repeat([]byte("A"), 1024*1024)...)
if !looksLikeHTML(body, "") {
t.Fatal("expected looksLikeHTML to detect html prefix")
}
}
func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) { func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) {
var requestBody map[string]any var requestBody map[string]any