fix(openai_compat): handle html error bodies and reduce allocations
This commit is contained in:
parent
58c70fe4c0
commit
fd0018d84f
2 changed files with 85 additions and 19 deletions
|
|
@ -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,20 +202,36 @@ func (p *Provider) Chat(
|
||||||
}
|
}
|
||||||
|
|
||||||
func wrapResponseParseError(err error, body []byte, contentType, apiBase string) error {
|
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)
|
trimmedContentType := strings.TrimSpace(contentType)
|
||||||
if looksLikeHTML(body, trimmedContentType) {
|
if !looksLikeHTML(body, trimmedContentType) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
contentTypeHint := ""
|
contentTypeHint := ""
|
||||||
if trimmedContentType != "" {
|
if trimmedContentType != "" {
|
||||||
contentTypeHint = fmt.Sprintf(" (content-type: %s)", trimmedContentType)
|
contentTypeHint = fmt.Sprintf(" (content-type: %s)", trimmedContentType)
|
||||||
}
|
}
|
||||||
return fmt.Errorf(
|
|
||||||
|
return fmt.Sprintf(
|
||||||
"expected JSON response from %s/chat/completions, but received HTML%s; check api_base or proxy configuration. Response preview: %s",
|
"expected JSON response from %s/chat/completions, but received HTML%s; check api_base or proxy configuration. Response preview: %s",
|
||||||
apiBase,
|
apiBase,
|
||||||
contentTypeHint,
|
contentTypeHint,
|
||||||
responsePreview(body, 160),
|
responsePreview(body, 160),
|
||||||
)
|
), true
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func looksLikeHTML(body []byte, contentType string) bool {
|
func looksLikeHTML(body []byte, contentType string) bool {
|
||||||
|
|
@ -223,22 +240,39 @@ func looksLikeHTML(body []byte, contentType string) bool {
|
||||||
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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue