db479d0ff4
CI / test (pull_request) Successful in 15s
CI / review (/openai/v1, gpt-4.1, gpt41, openai, GPT_REVIEW_TOKEN) (pull_request) Successful in 25s
CI / review (/openai/v1, gpt-4.1-mini, gpt41-mini, openai, GPT_REVIEW_TOKEN) (pull_request) Successful in 29s
CI / review (/anthropic/v1, claude-sonnet-4-6, sonnet, anthropic, SONNET_REVIEW_TOKEN) (pull_request) Successful in 49s
CI / review (/openai/v1, gpt-5, security, openai, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 50s
CI / review (/openai/v1, gpt-5, gpt, openai, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m15s
CI / review (/openai/v1, gpt-5-mini, gpt5-mini, openai, GPT_REVIEW_TOKEN) (pull_request) Successful in 52s
Addresses intermittent 'unexpected end of JSON input' failures where the LLM response body is truncated in transit between the proxy and client. Root cause: network-level truncation where io.ReadAll returns partial data (observed in 3/50 CI runs through HAI proxy). The response body reading was already using io.ReadAll correctly, but transient network issues between the proxy and client can still cause partial reads. Changes: - Add Content-Length validation in doRequest: detect when fewer bytes arrive than the server declared, triggering a retry - Add retry logic in Complete: retries once on retryable errors (body read failures, content-length mismatches) with a 500ms backoff - Add parse-level retry in main: if ParseResponse fails, re-requests from the LLM once before giving up (defensive, since retries always succeed per issue evidence) - Improve ParseResponse error diagnostics: log raw vs cleaned lengths and a preview of the cleaned content to aid future debugging Does NOT retry on API errors (4xx/5xx) or structural issues — only transient body read problems. Closes #47
427 lines
13 KiB
Go
427 lines
13 KiB
Go
package llm
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestComplete_Success(t *testing.T) {
|
|
resp := ChatResponse{
|
|
Choices: []struct {
|
|
Message struct {
|
|
Content string `json:"content"`
|
|
} `json:"message"`
|
|
}{
|
|
{Message: struct {
|
|
Content string `json:"content"`
|
|
}{Content: "Hello, world!"}},
|
|
},
|
|
}
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/chat/completions" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
if r.Method != "POST" {
|
|
t.Errorf("expected POST, got %s", r.Method)
|
|
}
|
|
if r.Header.Get("Authorization") != "Bearer test-key" {
|
|
t.Errorf("unexpected auth: %s", r.Header.Get("Authorization"))
|
|
}
|
|
if r.Header.Get("Content-Type") != "application/json" {
|
|
t.Errorf("unexpected content type: %s", r.Header.Get("Content-Type"))
|
|
}
|
|
|
|
var req ChatRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
t.Fatalf("decode request: %v", err)
|
|
}
|
|
if req.Model != "gpt-4" {
|
|
t.Errorf("expected model %q, got %q", "gpt-4", req.Model)
|
|
}
|
|
if len(req.Messages) != 1 {
|
|
t.Errorf("expected 1 message, got %d", len(req.Messages))
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-key", "gpt-4")
|
|
got, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != "Hello, world!" {
|
|
t.Errorf("expected %q, got %q", "Hello, world!", got)
|
|
}
|
|
}
|
|
|
|
func TestComplete_APIError(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
w.Write([]byte(`{"error":"rate limited"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-key", "gpt-4")
|
|
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
|
if err == nil {
|
|
t.Fatal("expected error for 429, got nil")
|
|
}
|
|
}
|
|
|
|
func TestComplete_NoChoices(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"choices":[]}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-key", "gpt-4")
|
|
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
|
if err == nil {
|
|
t.Fatal("expected error for no choices, got nil")
|
|
}
|
|
}
|
|
|
|
func TestComplete_BadJSON(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte(`not json at all`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-key", "gpt-4")
|
|
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
|
if err == nil {
|
|
t.Fatal("expected error for bad JSON, got nil")
|
|
}
|
|
}
|
|
|
|
func TestComplete_ServerDown(t *testing.T) {
|
|
client := NewClient("http://127.0.0.1:1", "test-key", "gpt-4")
|
|
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
|
if err == nil {
|
|
t.Fatal("expected error for connection refused, got nil")
|
|
}
|
|
}
|
|
|
|
func TestWithTemperature(t *testing.T) {
|
|
client := NewClient("http://example.com", "key", "model")
|
|
if client.temperature != 0 {
|
|
t.Errorf("expected initial temperature 0, got %f", client.temperature)
|
|
}
|
|
|
|
result := client.WithTemperature(0.7)
|
|
if result != client {
|
|
t.Error("WithTemperature should return the same client for chaining")
|
|
}
|
|
if client.temperature != 0.7 {
|
|
t.Errorf("expected temperature 0.7, got %f", client.temperature)
|
|
}
|
|
}
|
|
|
|
func TestComplete_TemperatureOmittedWhenZero(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var req map[string]interface{}
|
|
json.NewDecoder(r.Body).Decode(&req)
|
|
|
|
if _, exists := req["temperature"]; exists {
|
|
t.Error("temperature should be omitted when zero (server default)")
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(ChatResponse{
|
|
Choices: []struct {
|
|
Message struct {
|
|
Content string `json:"content"`
|
|
} `json:"message"`
|
|
}{{Message: struct {
|
|
Content string `json:"content"`
|
|
}{Content: "ok"}}},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "key", "model")
|
|
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestComplete_TemperatureIncludedWhenSet(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var req map[string]interface{}
|
|
json.NewDecoder(r.Body).Decode(&req)
|
|
|
|
temp, exists := req["temperature"]
|
|
if !exists {
|
|
t.Error("temperature should be included when set")
|
|
}
|
|
if temp != 0.7 {
|
|
t.Errorf("expected temperature 0.7, got %v", temp)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(ChatResponse{
|
|
Choices: []struct {
|
|
Message struct {
|
|
Content string `json:"content"`
|
|
} `json:"message"`
|
|
}{{Message: struct {
|
|
Content string `json:"content"`
|
|
}{Content: "ok"}}},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "key", "model").WithTemperature(0.7)
|
|
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWithTimeout(t *testing.T) {
|
|
client := NewClient("http://example.com", "key", "model")
|
|
result := client.WithTimeout(10 * time.Second)
|
|
if result != client {
|
|
t.Error("WithTimeout should return the same client for chaining")
|
|
}
|
|
// Verify timeout causes failure on slow server
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(200 * time.Millisecond)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
shortClient := NewClient(server.URL, "key", "model").WithTimeout(50 * time.Millisecond)
|
|
_, err := shortClient.Complete(context.Background(), []Message{{Role: "user", Content: "hi"}})
|
|
if err == nil {
|
|
t.Error("expected timeout error with 50ms timeout and 200ms server delay")
|
|
}
|
|
}
|
|
|
|
|
|
func TestComplete_Anthropic_Success(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/messages" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
if r.Header.Get("x-api-key") != "test-key" {
|
|
t.Errorf("expected x-api-key header, got %q", r.Header.Get("x-api-key"))
|
|
}
|
|
if r.Header.Get("anthropic-version") != "2023-06-01" {
|
|
t.Errorf("expected anthropic-version header, got %q", r.Header.Get("anthropic-version"))
|
|
}
|
|
|
|
var req map[string]interface{}
|
|
json.NewDecoder(r.Body).Decode(&req)
|
|
|
|
if req["system"] != "You are helpful" {
|
|
t.Errorf("expected system prompt, got %v", req["system"])
|
|
}
|
|
msgs := req["messages"].([]interface{})
|
|
if len(msgs) != 1 {
|
|
t.Errorf("expected 1 user message, got %d", len(msgs))
|
|
}
|
|
if req["max_tokens"] != float64(8192) {
|
|
t.Errorf("expected max_tokens 8192, got %v", req["max_tokens"])
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"content":[{"type":"text","text":"Hello from Claude!"}]}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-key", "claude-sonnet").WithProvider(ProviderAnthropic)
|
|
got, err := client.Complete(context.Background(), []Message{
|
|
{Role: "system", Content: "You are helpful"},
|
|
{Role: "user", Content: "Hi"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != "Hello from Claude!" {
|
|
t.Errorf("expected %q, got %q", "Hello from Claude!", got)
|
|
}
|
|
}
|
|
|
|
func TestComplete_Anthropic_NoContent(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"content":[]}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-key", "claude-sonnet").WithProvider(ProviderAnthropic)
|
|
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
|
if err == nil {
|
|
t.Fatal("expected error for empty content, got nil")
|
|
}
|
|
}
|
|
|
|
func TestComplete_Anthropic_APIError(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte(`{"error":{"message":"invalid request"}}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-key", "claude-sonnet").WithProvider(ProviderAnthropic)
|
|
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
|
if err == nil {
|
|
t.Fatal("expected error for 400, got nil")
|
|
}
|
|
}
|
|
|
|
func TestWithProvider(t *testing.T) {
|
|
client := NewClient("http://example.com", "key", "model")
|
|
if client.provider != ProviderOpenAI {
|
|
t.Errorf("expected default provider openai, got %s", client.provider)
|
|
}
|
|
result := client.WithProvider(ProviderAnthropic)
|
|
if result != client {
|
|
t.Error("WithProvider should return the same client for chaining")
|
|
}
|
|
if client.provider != ProviderAnthropic {
|
|
t.Errorf("expected provider anthropic, got %s", client.provider)
|
|
}
|
|
}
|
|
|
|
func TestComplete_RetryOnBodyReadError(t *testing.T) {
|
|
attempts := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attempts++
|
|
if attempts == 1 {
|
|
// First attempt: send headers then close connection abruptly
|
|
// Simulate by writing partial response and flushing with wrong Content-Length
|
|
w.Header().Set("Content-Length", "1000")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"choices":[{"message":{"con`))
|
|
// The test HTTP server will close the connection after handler returns,
|
|
// but Content-Length mismatch means client gets fewer bytes than expected
|
|
return
|
|
}
|
|
// Second attempt: succeed
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(ChatResponse{
|
|
Choices: []struct {
|
|
Message struct {
|
|
Content string `json:"content"`
|
|
} `json:"message"`
|
|
}{{Message: struct {
|
|
Content string `json:"content"`
|
|
}{Content: "success"}}},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "key", "model")
|
|
got, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
|
if err != nil {
|
|
t.Fatalf("expected retry to succeed, got error: %v", err)
|
|
}
|
|
if got != "success" {
|
|
t.Errorf("expected %q, got %q", "success", got)
|
|
}
|
|
if attempts != 2 {
|
|
t.Errorf("expected 2 attempts, got %d", attempts)
|
|
}
|
|
}
|
|
|
|
func TestComplete_ContentLengthMismatch(t *testing.T) {
|
|
attempts := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attempts++
|
|
if attempts == 1 {
|
|
// Claim Content-Length is larger than actual body
|
|
w.Header().Set("Content-Length", "500")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
// Write less than 500 bytes
|
|
w.Write([]byte(`{"choices":[{"message":{"content":"partial"}}]}`))
|
|
return
|
|
}
|
|
// Second attempt succeeds
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(ChatResponse{
|
|
Choices: []struct {
|
|
Message struct {
|
|
Content string `json:"content"`
|
|
} `json:"message"`
|
|
}{{Message: struct {
|
|
Content string `json:"content"`
|
|
}{Content: "complete"}}},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "key", "model")
|
|
got, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
|
if err != nil {
|
|
t.Fatalf("expected retry to succeed on content-length mismatch, got: %v", err)
|
|
}
|
|
if got != "complete" {
|
|
t.Errorf("expected %q, got %q", "complete", got)
|
|
}
|
|
}
|
|
|
|
func TestComplete_NoRetryOnAPIError(t *testing.T) {
|
|
attempts := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attempts++
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte(`{"error":"bad request"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "key", "model")
|
|
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
|
if err == nil {
|
|
t.Fatal("expected error for 400, got nil")
|
|
}
|
|
if attempts != 1 {
|
|
t.Errorf("should not retry on API errors, got %d attempts", attempts)
|
|
}
|
|
}
|
|
|
|
func TestIsRetryableError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err string
|
|
expected bool
|
|
}{
|
|
{"nil formatted", "", false},
|
|
{"read response error", "read response: unexpected EOF", true},
|
|
{"body length mismatch", "body length mismatch: Content-Length=1000, received=500", true},
|
|
{"API error", "LLM API error (status 400): bad request", false},
|
|
{"parse error", "parse response: unexpected end of JSON input", false},
|
|
{"request error", "LLM request: connection refused", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if tt.err == "" {
|
|
if isRetryableError(nil) {
|
|
t.Error("nil error should not be retryable")
|
|
}
|
|
return
|
|
}
|
|
err := fmt.Errorf("%s", tt.err)
|
|
got := isRetryableError(err)
|
|
if got != tt.expected {
|
|
t.Errorf("isRetryableError(%q) = %v, want %v", tt.err, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|