Files
llm-testbench/internal/llm/openai_test.go
T
lukaszraczylo ace50757e4 fix: floor token budgets and surface truncation, add live progress
Reasoning models burn thousands of completion tokens before the
answer. Small per-test caps caused silent truncation and zero scores.
The runner now floors max_tokens at the config default (12000). The
client parses finish_reason. Reports mark truncated cells and the
JSON format carries response text for debugging. A stderr progress
reporter prints one line per completed request; --quiet disables it.
2026-08-17 01:55:14 +01:00

281 lines
10 KiB
Go

package llm
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// newTestServer spins up a local httptest server; this is not the live
// endpoint and is safe for offline unit tests.
func newTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return srv
}
// testChoice builds one response choice with a non-null content string, the
// common case. Its return type is exactly chatCompletionResponse.Choices's
// element type, so callers can append() it without repeating the anonymous
// struct type at each call site.
func testChoice(content, finishReason string) struct {
Message responseMsg `json:"message"`
FinishReason string `json:"finish_reason"`
} {
return struct {
Message responseMsg `json:"message"`
FinishReason string `json:"finish_reason"`
}{
Message: responseMsg{Role: "assistant", Content: &content},
FinishReason: finishReason,
}
}
func TestOpenAIClient_Complete_Success(t *testing.T) {
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
var body chatCompletionRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if body.Model != "test-model" {
t.Errorf("model = %q, want test-model", body.Model)
}
resp := chatCompletionResponse{}
resp.Choices = append(resp.Choices, testChoice("hello there", "stop"))
resp.Usage.PromptTokens = 12
resp.Usage.CompletionTokens = 3
_ = json.NewEncoder(w).Encode(resp)
})
client := NewOpenAIClient(srv.URL, "", time.Second)
resp, err := client.Complete(context.Background(), Request{
Model: "test-model",
Messages: []Message{{Role: "user", Content: "hi"}},
})
if err != nil {
t.Fatalf("Complete() error = %v", err)
}
if resp.Text != "hello there" {
t.Errorf("Text = %q, want %q", resp.Text, "hello there")
}
if resp.FinishReason != "stop" {
t.Errorf("FinishReason = %q, want stop", resp.FinishReason)
}
if resp.PromptTokens != 12 || resp.CompletionTokens != 3 {
t.Errorf("tokens = %d/%d, want 12/3", resp.PromptTokens, resp.CompletionTokens)
}
}
// TestOpenAIClient_Complete_ParsesFinishReasonLength verifies
// choices[0].finish_reason lands in Response.FinishReason == "length", the
// signal that generation was cut off by the token budget (root cause of the
// live-run incident: a truncated reasoning-model response silently scored
// 0 with no visible signal that it was ever cut off).
func TestOpenAIClient_Complete_ParsesFinishReasonLength(t *testing.T) {
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
resp := chatCompletionResponse{}
resp.Choices = append(resp.Choices, testChoice("partial ans", FinishReasonLength))
_ = json.NewEncoder(w).Encode(resp)
})
client := NewOpenAIClient(srv.URL, "", time.Second)
resp, err := client.Complete(context.Background(), Request{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
if err != nil {
t.Fatalf("Complete() error = %v", err)
}
if resp.FinishReason != FinishReasonLength {
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, FinishReasonLength)
}
}
// TestOpenAIClient_Complete_NullContentIsEmptyString verifies a backend
// sending "content": null (a reasoning model that spent its whole token
// budget on reasoning_content and never wrote to content) decodes to
// Response.Text == "", not a decode error or a panic.
func TestOpenAIClient_Complete_NullContentIsEmptyString(t *testing.T) {
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null},"finish_reason":"length"}],"usage":{"prompt_tokens":50,"completion_tokens":6000}}`))
})
client := NewOpenAIClient(srv.URL, "", time.Second)
resp, err := client.Complete(context.Background(), Request{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
if err != nil {
t.Fatalf("Complete() error = %v", err)
}
if resp.Text != "" {
t.Errorf("Text = %q, want empty string for null content", resp.Text)
}
if resp.FinishReason != FinishReasonLength {
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, FinishReasonLength)
}
if resp.CompletionTokens != 6000 {
t.Errorf("CompletionTokens = %d, want 6000 (tokens were spent on reasoning_content, not content)", resp.CompletionTokens)
}
}
// TestOpenAIClient_Complete_SendsTemperatureZero verifies Request.Temperature
// (PLAN.md pins this to 0 for deterministic scoring, wired from main.go's
// requestTemperature constant) actually lands on the wire as a JSON
// "temperature":0 field, not silently omitted (chatCompletionRequest has no
// "omitempty" on Temperature specifically so a real zero is distinguishable
// from an absent field) (N7).
func TestOpenAIClient_Complete_SendsTemperatureZero(t *testing.T) {
var rawBody []byte
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
var err error
rawBody, err = io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
_ = json.NewEncoder(w).Encode(chatCompletionResponse{})
})
client := NewOpenAIClient(srv.URL, "", time.Second)
_, err := client.Complete(context.Background(), Request{
Model: "m",
Messages: []Message{{Role: "user", Content: "x"}},
Temperature: 0,
})
if err != nil {
t.Fatalf("Complete() error = %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(rawBody, &decoded); err != nil {
t.Fatalf("json.Unmarshal(rawBody) error = %v; body: %s", err, rawBody)
}
got, present := decoded["temperature"]
if !present {
t.Fatalf("request body has no \"temperature\" key at all: %s", rawBody)
}
if got != float64(0) {
t.Errorf("temperature = %v, want 0", got)
}
}
func TestOpenAIClient_Complete_RetriesOn5xxThenSucceeds(t *testing.T) {
var calls int32
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&calls, 1)
if n < 3 {
w.WriteHeader(http.StatusInternalServerError)
return
}
resp := chatCompletionResponse{}
resp.Choices = append(resp.Choices, testChoice("ok", "stop"))
_ = json.NewEncoder(w).Encode(resp)
})
client := NewOpenAIClient(srv.URL, "", time.Second)
resp, err := client.Complete(context.Background(), Request{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
if err != nil {
t.Fatalf("Complete() error = %v", err)
}
if resp.Text != "ok" {
t.Errorf("Text = %q, want ok", resp.Text)
}
// S8: Latency must reflect the successful attempt's own generation
// time, not the cumulative wait across the two failed attempts plus
// their backoff delays (which total 250ms+500ms=750ms for attempts 1
// and 2 - see retryBaseDelay/maxRetries).
if resp.Latency > 200*time.Millisecond {
t.Errorf("Latency = %v, want well under the 750ms of retry backoff (per-attempt measurement, S8)", resp.Latency)
}
if got := atomic.LoadInt32(&calls); got != 3 {
t.Errorf("calls = %d, want 3 (1 initial + 2 retries)", got)
}
}
func TestOpenAIClient_Complete_ExhaustsRetriesOn5xx(t *testing.T) {
var calls int32
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
w.WriteHeader(http.StatusServiceUnavailable)
})
client := NewOpenAIClient(srv.URL, "", time.Second)
_, err := client.Complete(context.Background(), Request{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
if err == nil {
t.Fatal("Complete() error = nil, want error after exhausting retries")
}
if got := atomic.LoadInt32(&calls); got != maxRetries+1 {
t.Errorf("calls = %d, want %d", got, maxRetries+1)
}
}
func TestOpenAIClient_Complete_NoRetryOn4xx(t *testing.T) {
var calls int32
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":{"message":"bad request"}}`))
})
client := NewOpenAIClient(srv.URL, "", time.Second)
_, err := client.Complete(context.Background(), Request{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
if err == nil {
t.Fatal("Complete() error = nil, want error")
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("calls = %d, want 1 (no retry on 4xx)", got)
}
if !strings.Contains(err.Error(), "400") {
t.Errorf("error = %v, want mention of 400", err)
}
}
func TestOpenAIClient_Complete_APIErrorField(t *testing.T) {
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"error":{"message":"model overloaded"}}`))
})
client := NewOpenAIClient(srv.URL, "", time.Second)
_, err := client.Complete(context.Background(), Request{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
if err == nil {
t.Fatal("Complete() error = nil, want error")
}
if !strings.Contains(err.Error(), "model overloaded") {
t.Errorf("error = %v, want mention of model overloaded", err)
}
}
func TestOpenAIClient_Complete_ContextCanceled(t *testing.T) {
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
client := NewOpenAIClient(srv.URL, "", time.Second)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := client.Complete(ctx, Request{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
if err == nil {
t.Fatal("Complete() error = nil, want error for canceled context")
}
}
func TestOpenAIClient_Complete_SendsAPIKey(t *testing.T) {
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer secret-key" {
t.Errorf("Authorization = %q, want Bearer secret-key", got)
}
_ = json.NewEncoder(w).Encode(chatCompletionResponse{})
})
client := NewOpenAIClient(srv.URL, "secret-key", time.Second)
_, err := client.Complete(context.Background(), Request{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
if err != nil {
t.Fatalf("Complete() error = %v", err)
}
}