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") } }