mirror of
https://github.com/lukaszraczylo/llm-testbench.git
synced 2026-10-12 00:45:19 +00:00
Retry with exponential backoff and jitter, and honour Retry-After. Retry empty-choice responses and HTTP 408. Add max_retries, retry_timeouts, model_timeouts and model_concurrency settings for slow local models. Add opt-in SSE streaming with an idle timeout. A hung server fails early, and a slow but active generation continues. Send an optional seed and record the backend fingerprint of each answer. Stamp saved artifacts with run metadata and write them atomically. Warn in compare when two runs differ. Save partial results on Ctrl-C. Turn an evaluator panic into an error row. Reject duplicate models and non-http endpoints in the config.
608 lines
21 KiB
Go
608 lines
21 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
|
|
}
|
|
|
|
func writeOK(w http.ResponseWriter, content string) {
|
|
resp := chatCompletionResponse{}
|
|
resp.Choices = append(resp.Choices, testChoice(content, "stop"))
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// 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 {
|
|
FinishReason string `json:"finish_reason"`
|
|
Message responseMsg `json:"message"`
|
|
} {
|
|
return struct {
|
|
FinishReason string `json:"finish_reason"`
|
|
Message responseMsg `json:"message"`
|
|
}{
|
|
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)
|
|
}
|
|
writeOK(w, "ok")
|
|
})
|
|
|
|
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) {
|
|
var calls int32
|
|
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&calls, 1)
|
|
_, _ = 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)
|
|
}
|
|
if got := atomic.LoadInt32(&calls); got != maxRetries+1 {
|
|
t.Errorf("calls = %d, want %d (2xx-with-error-body is retried like a 5xx)", got, maxRetries+1)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIClient_Complete_RetriesAPIErrorThenSucceeds(t *testing.T) {
|
|
var calls int32
|
|
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if atomic.AddInt32(&calls, 1) == 1 {
|
|
_, _ = w.Write([]byte(`{"error":{"message":"replica warming"}}`))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`))
|
|
})
|
|
|
|
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, want nil", err)
|
|
}
|
|
if resp.Text != "ok" {
|
|
t.Errorf("Text = %q, want %q", resp.Text, "ok")
|
|
}
|
|
if got := atomic.LoadInt32(&calls); got != 2 {
|
|
t.Errorf("calls = %d, want 2 (one error body, one success)", got)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIClient_Complete_RetriesOn429ThenSucceeds(t *testing.T) {
|
|
var calls int32
|
|
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if atomic.AddInt32(&calls, 1) == 1 {
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`))
|
|
})
|
|
|
|
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, want nil", err)
|
|
}
|
|
if resp.Text != "ok" {
|
|
t.Errorf("Text = %q, want %q", resp.Text, "ok")
|
|
}
|
|
if got := atomic.LoadInt32(&calls); got != 2 {
|
|
t.Errorf("calls = %d, want 2 (one 429, one success)", got)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
writeOK(w, "ok")
|
|
})
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIClient_Complete_ParsesToolCalls(t *testing.T) {
|
|
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
var body chatCompletionRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode request: %v", err)
|
|
}
|
|
// The client must advertise tools with tool_choice auto.
|
|
if len(body.Tools) != 1 || body.Tools[0].Function.Name != "get_weather" {
|
|
t.Errorf("tools not sent correctly: %+v", body.Tools)
|
|
}
|
|
if body.ToolChoice != "auto" {
|
|
t.Errorf("tool_choice = %q, want auto", body.ToolChoice)
|
|
}
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[` +
|
|
`{"type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"Paris\",\"units\":\"c\"}"}}` +
|
|
`]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":9}}`))
|
|
})
|
|
|
|
client := NewOpenAIClient(srv.URL, "", time.Second)
|
|
resp, err := client.Complete(context.Background(), Request{
|
|
Model: "m",
|
|
Messages: []Message{{Role: "user", Content: "weather in Paris?"}},
|
|
Tools: []Tool{{
|
|
Name: "get_weather",
|
|
Description: "Get weather for a city",
|
|
Parameters: map[string]any{"type": "object"},
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Complete() error = %v", err)
|
|
}
|
|
if len(resp.ToolCalls) != 1 {
|
|
t.Fatalf("got %d tool calls, want 1", len(resp.ToolCalls))
|
|
}
|
|
tc := resp.ToolCalls[0]
|
|
if tc.Name != "get_weather" || !tc.Decoded {
|
|
t.Errorf("tool call = %+v", tc)
|
|
}
|
|
if tc.Arguments["city"] != "Paris" || tc.Arguments["units"] != "c" {
|
|
t.Errorf("arguments = %v", tc.Arguments)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIClient_Complete_MalformedToolArgs(t *testing.T) {
|
|
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","tool_calls":[` +
|
|
`{"type":"function","function":{"name":"f","arguments":"{not valid json"}}` +
|
|
`]},"finish_reason":"tool_calls"}],"usage":{}}`))
|
|
})
|
|
client := NewOpenAIClient(srv.URL, "", time.Second)
|
|
resp, err := client.Complete(context.Background(), Request{
|
|
Model: "m", Messages: []Message{{Role: "user", Content: "x"}},
|
|
Tools: []Tool{{Name: "f"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Complete() error = %v", err)
|
|
}
|
|
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Decoded {
|
|
t.Fatalf("expected 1 undecoded tool call, got %+v", resp.ToolCalls)
|
|
}
|
|
if resp.ToolCalls[0].RawArguments != "{not valid json" {
|
|
t.Errorf("raw args = %q", resp.ToolCalls[0].RawArguments)
|
|
}
|
|
}
|
|
|
|
func fastClient(url string) *OpenAIClient {
|
|
c := NewOpenAIClient(url, "", time.Second)
|
|
c.baseDelay = time.Millisecond
|
|
return c
|
|
}
|
|
|
|
var oneMsg = Request{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}}
|
|
|
|
func TestOpenAIClient_Complete_SendsSeedAndEchoesFingerprint(t *testing.T) {
|
|
var decoded map[string]any
|
|
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(b, &decoded)
|
|
_, _ = w.Write([]byte(`{"model":"served-1","system_fingerprint":"fp-9","choices":[{"finish_reason":"stop","message":{"content":"hi"}}]}`))
|
|
})
|
|
seed := 42
|
|
req := oneMsg
|
|
req.Seed = &seed
|
|
resp, err := fastClient(srv.URL).Complete(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Complete() error = %v", err)
|
|
}
|
|
if decoded["seed"] != float64(42) {
|
|
t.Errorf("seed = %v, want 42", decoded["seed"])
|
|
}
|
|
if resp.Fingerprint != "fp-9" || resp.ServedModel != "served-1" {
|
|
t.Errorf("fingerprint/model = %q/%q, want fp-9/served-1", resp.Fingerprint, resp.ServedModel)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIClient_Complete_OmitsSeedWhenUnset(t *testing.T) {
|
|
var decoded map[string]any
|
|
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(b, &decoded)
|
|
writeOK(w, "ok")
|
|
})
|
|
if _, err := fastClient(srv.URL).Complete(context.Background(), oneMsg); err != nil {
|
|
t.Fatalf("Complete() error = %v", err)
|
|
}
|
|
if _, present := decoded["seed"]; present {
|
|
t.Errorf("seed present in body without Request.Seed: %v", decoded)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIClient_Complete_RetriesEmptyChoicesThenSucceeds(t *testing.T) {
|
|
var calls int32
|
|
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if atomic.AddInt32(&calls, 1) == 1 {
|
|
_, _ = w.Write([]byte(`{"choices":[]}`))
|
|
return
|
|
}
|
|
writeOK(w, "ok")
|
|
})
|
|
resp, err := fastClient(srv.URL).Complete(context.Background(), oneMsg)
|
|
if err != nil || resp.Text != "ok" {
|
|
t.Fatalf("Complete() = %q, %v; want ok, nil", resp.Text, err)
|
|
}
|
|
if got := atomic.LoadInt32(&calls); got != 2 {
|
|
t.Errorf("calls = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIClient_Complete_RetriesOn408(t *testing.T) {
|
|
var calls int32
|
|
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if atomic.AddInt32(&calls, 1) == 1 {
|
|
w.WriteHeader(http.StatusRequestTimeout)
|
|
return
|
|
}
|
|
writeOK(w, "ok")
|
|
})
|
|
if _, err := fastClient(srv.URL).Complete(context.Background(), oneMsg); err != nil {
|
|
t.Fatalf("Complete() error = %v", err)
|
|
}
|
|
if got := atomic.LoadInt32(&calls); got != 2 {
|
|
t.Errorf("calls = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIClient_WithRetries(t *testing.T) {
|
|
var calls int32
|
|
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&calls, 1)
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
})
|
|
if _, err := fastClient(srv.URL).WithRetries(5).Complete(context.Background(), oneMsg); err == nil {
|
|
t.Fatal("Complete() error = nil, want error")
|
|
}
|
|
if got := atomic.LoadInt32(&calls); got != 6 {
|
|
t.Errorf("calls = %d, want 6 (1 + 5 retries)", got)
|
|
}
|
|
|
|
atomic.StoreInt32(&calls, 0)
|
|
if _, err := fastClient(srv.URL).WithRetries(0).Complete(context.Background(), oneMsg); err == nil {
|
|
t.Fatal("Complete() error = nil, want error")
|
|
}
|
|
if got := atomic.LoadInt32(&calls); got != 1 {
|
|
t.Errorf("calls = %d, want 1 (retries disabled)", got)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIClient_Complete_HonorsRetryAfter(t *testing.T) {
|
|
var calls int32
|
|
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if atomic.AddInt32(&calls, 1) == 1 {
|
|
w.Header().Set("Retry-After", "1")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
writeOK(w, "ok")
|
|
})
|
|
start := time.Now()
|
|
if _, err := fastClient(srv.URL).Complete(context.Background(), oneMsg); err != nil {
|
|
t.Fatalf("Complete() error = %v", err)
|
|
}
|
|
if elapsed := time.Since(start); elapsed < 900*time.Millisecond {
|
|
t.Errorf("elapsed = %v, want >= ~1s from Retry-After", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIClient_backoff(t *testing.T) {
|
|
c := NewOpenAIClient("http://x", "", time.Second)
|
|
for attempt, base := range map[int]time.Duration{1: 250 * time.Millisecond, 2: 500 * time.Millisecond, 3: time.Second} {
|
|
got := c.backoff(attempt, 0)
|
|
if got < base || got > base+base/3 {
|
|
t.Errorf("backoff(%d) = %v, want in [%v, %v]", attempt, got, base, base+base/3)
|
|
}
|
|
}
|
|
if got := c.backoff(30, 0); got > maxRetryDelay*5/4 {
|
|
t.Errorf("backoff(30) = %v, want capped near %v", got, maxRetryDelay)
|
|
}
|
|
if got := c.backoff(1, time.Hour); got != maxRetryDelay {
|
|
t.Errorf("backoff with huge Retry-After = %v, want cap %v", got, maxRetryDelay)
|
|
}
|
|
}
|
|
|
|
func TestNewOpenAIClient_TrimsTrailingSlash(t *testing.T) {
|
|
var path string
|
|
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
path = r.URL.Path
|
|
writeOK(w, "ok")
|
|
})
|
|
if _, err := fastClient(srv.URL+"/v1/").Complete(context.Background(), oneMsg); err != nil {
|
|
t.Fatalf("Complete() error = %v", err)
|
|
}
|
|
if path != "/v1/chat/completions" {
|
|
t.Errorf("path = %q, want /v1/chat/completions", path)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIClient_Complete_TimeoutNotRetriedWhenDisabled(t *testing.T) {
|
|
var calls int32
|
|
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&calls, 1)
|
|
time.Sleep(300 * time.Millisecond)
|
|
writeOK(w, "late")
|
|
})
|
|
c := NewOpenAIClient(srv.URL, "", 50*time.Millisecond).WithRetryTimeouts(false)
|
|
c.baseDelay = time.Millisecond
|
|
_, err := c.Complete(context.Background(), oneMsg)
|
|
if err == nil || !strings.Contains(err.Error(), "not retried") {
|
|
t.Fatalf("err = %v, want a timeout not retried", err)
|
|
}
|
|
if got := atomic.LoadInt32(&calls); got != 1 {
|
|
t.Errorf("calls = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIClient_Complete_TimeoutRetriedByDefault(t *testing.T) {
|
|
var calls int32
|
|
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if atomic.AddInt32(&calls, 1) == 1 {
|
|
time.Sleep(300 * time.Millisecond)
|
|
}
|
|
writeOK(w, "ok")
|
|
})
|
|
c := NewOpenAIClient(srv.URL, "", 100*time.Millisecond)
|
|
c.baseDelay = time.Millisecond
|
|
if _, err := c.Complete(context.Background(), oneMsg); err != nil {
|
|
t.Fatalf("Complete() error = %v", err)
|
|
}
|
|
if got := atomic.LoadInt32(&calls); got != 2 {
|
|
t.Errorf("calls = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIClient_Complete_PerModelTimeout(t *testing.T) {
|
|
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(200 * time.Millisecond)
|
|
writeOK(w, "slow but fine")
|
|
})
|
|
c := NewOpenAIClient(srv.URL, "", 50*time.Millisecond).
|
|
WithRetryTimeouts(false).
|
|
WithModelTimeouts(map[string]time.Duration{"slow-local": 2 * time.Second})
|
|
req := oneMsg
|
|
req.Model = "slow-local"
|
|
if resp, err := c.Complete(context.Background(), req); err != nil || resp.Text != "slow but fine" {
|
|
t.Fatalf("slow-local = %q, %v; want success under its own timeout", resp.Text, err)
|
|
}
|
|
if _, err := c.Complete(context.Background(), oneMsg); err == nil {
|
|
t.Error("default-timeout model succeeded, want timeout")
|
|
}
|
|
}
|