Files
MealQuest/internal/ai/llm/openai/openai_test.go
T
dev_agent 9ace3aba9f
CI / ci (push) Successful in 1m28s
Provider presets and factory (#44)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-04 09:18:34 -04:00

1270 lines
47 KiB
Go

package openai
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
const testKey = "sk-test-9f8e7d6c5b4a39281706f5e4d3c2b1a0"
// recorded is one request the test server received.
type recorded struct {
method string
path string
header http.Header
body []byte
}
// reply is the response the test server replays.
type reply struct {
status int
header map[string]string
fixture string // testdata file; {{KEY}} is replaced with testKey
delay time.Duration
}
// vendorServer replays one recorded payload and records the request.
type vendorServer struct {
*httptest.Server
mu sync.Mutex
got []recorded
}
func newVendorServer(t *testing.T, r reply) *vendorServer {
t.Helper()
var payload []byte
if r.fixture != "" {
raw, err := os.ReadFile(filepath.Join("testdata", r.fixture))
if err != nil {
t.Fatal(err)
}
payload = bytes.ReplaceAll(raw, []byte("{{KEY}}"), []byte(testKey))
}
vs := &vendorServer{}
vs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
body, _ := io.ReadAll(req.Body)
vs.mu.Lock()
vs.got = append(vs.got, recorded{req.Method, req.URL.Path, req.Header.Clone(), body})
vs.mu.Unlock()
if r.delay > 0 {
select {
case <-time.After(r.delay):
case <-req.Context().Done():
return
}
}
for k, v := range r.header {
w.Header().Set(k, v)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(r.status)
w.Write(payload)
}))
t.Cleanup(vs.Close)
return vs
}
func (vs *vendorServer) only(t *testing.T) recorded {
t.Helper()
vs.mu.Lock()
defer vs.mu.Unlock()
if len(vs.got) != 1 {
t.Fatalf("server got %d requests, want 1", len(vs.got))
}
return vs.got[0]
}
func newProvider(t *testing.T, base string, mode StructuredOutput, headers map[string]string) *Provider {
t.Helper()
p, err := New(Config{BaseURL: base, APIKey: testKey, Model: "test-model", Headers: headers, StructuredOutput: mode})
if err != nil {
t.Fatal(err)
}
return p
}
var dinnersSchema = json.RawMessage(`{"type":"object","properties":{"dinners":{"type":"array"}},"required":["dinners"],"additionalProperties":false}`)
func schemaRequest() llm.Request {
temp := 0.0
return llm.Request{
System: "You plan dinners for Our kitchen.",
Messages: []llm.Message{{Role: llm.RoleUser, Content: "Suggest a Side quest."}},
Temperature: &temp,
MaxTokens: 400,
Schema: &llm.JSONSchema{Name: "side_quests", Schema: dinnersSchema},
}
}
// sentBody decodes the chat request body into a generic map.
func sentBody(t *testing.T, r recorded) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(r.body, &m); err != nil {
t.Fatalf("request body %q: %v", r.body, err)
}
return m
}
func assertJSON(t *testing.T, got json.RawMessage, want string) {
t.Helper()
var g, w any
if err := json.Unmarshal(got, &g); err != nil {
t.Fatalf("Response.JSON %q: %v", got, err)
}
if err := json.Unmarshal([]byte(want), &w); err != nil {
t.Fatal(err)
}
gb, _ := json.Marshal(g)
wb, _ := json.Marshal(w)
if !bytes.Equal(gb, wb) {
t.Fatalf("Response.JSON = %s, want %s", gb, wb)
}
}
func TestGenerateJSONSchemaOpenAI(t *testing.T) {
vs := newVendorServer(t, reply{status: 200, fixture: "openai_chat_json_schema.json"})
p := newProvider(t, vs.URL+"/v1/", JSONSchema, nil)
resp, err := p.Generate(context.Background(), schemaRequest())
if err != nil {
t.Fatal(err)
}
assertJSON(t, resp.JSON, `{"dinners":[{"name":"Sheet-pan gnocchi","why":"Like your Regular tray bake, but quicker."}]}`)
if resp.Text != string(resp.JSON) {
t.Errorf("Text = %q, want the raw reply", resp.Text)
}
if resp.Usage != (llm.Usage{InputTokens: 1117, OutputTokens: 46}) || resp.Model != "gpt-4o-2024-08-06" || resp.FinishReason != "stop" {
t.Errorf("Usage, Model, FinishReason = %+v, %q, %q", resp.Usage, resp.Model, resp.FinishReason)
}
got := vs.only(t)
if got.method != http.MethodPost || got.path != "/v1/chat/completions" {
t.Errorf("request = %s %s", got.method, got.path)
}
if h := got.header.Get("Authorization"); h != "Bearer "+testKey {
t.Errorf("Authorization = %q", h)
}
if h := got.header.Get("Content-Type"); h != "application/json" {
t.Errorf("Content-Type = %q", h)
}
body := sentBody(t, got)
if body["model"] != "test-model" || body["temperature"] != 0.0 || body["max_tokens"] != 400.0 {
t.Errorf("model, temperature, max_tokens = %v, %v, %v", body["model"], body["temperature"], body["max_tokens"])
}
msgs := body["messages"].([]any)
if len(msgs) != 2 {
t.Fatalf("messages = %v", msgs)
}
system := msgs[0].(map[string]any)
if system["role"] != "system" || system["content"] != "You plan dinners for Our kitchen." {
t.Errorf("system message = %v (the schema must not be in the prompt in json_schema mode)", system)
}
if user := msgs[1].(map[string]any); user["role"] != "user" || user["content"] != "Suggest a Side quest." {
t.Errorf("user message = %v", user)
}
rf := body["response_format"].(map[string]any)
js := rf["json_schema"].(map[string]any)
if rf["type"] != "json_schema" || js["name"] != "side_quests" || js["strict"] != true {
t.Errorf("response_format = %v", rf)
}
schema, _ := json.Marshal(js["schema"])
var want any
json.Unmarshal(dinnersSchema, &want)
wantB, _ := json.Marshal(want)
if !bytes.Equal(schema, wantB) {
t.Errorf("response_format schema = %s", schema)
}
}
func TestGenerateJSONObjectOpenRouter(t *testing.T) {
vs := newVendorServer(t, reply{status: 200, fixture: "openrouter_chat_json_object.json"})
p := newProvider(t, vs.URL+"/api/v1", JSONObject, map[string]string{"http-referer": "https://mealquest.example", "X-Title": "MealQuest"})
req := schemaRequest()
req.Temperature = nil
req.MaxTokens = 0
resp, err := p.Generate(context.Background(), req)
if err != nil {
t.Fatal(err)
}
assertJSON(t, resp.JSON, `{"dinners":[{"name":"Chicken tinga tacos","why":"Close to your taco-night Regular."}]}`)
if resp.Usage != (llm.Usage{InputTokens: 842, OutputTokens: 31}) || resp.Model != "openai/gpt-4o-mini" || resp.FinishReason != "stop" {
t.Errorf("Usage, Model, FinishReason = %+v, %q, %q", resp.Usage, resp.Model, resp.FinishReason)
}
got := vs.only(t)
if got.path != "/api/v1/chat/completions" {
t.Errorf("path = %s", got.path)
}
if got.header.Get("HTTP-Referer") != "https://mealquest.example" || got.header.Get("X-Title") != "MealQuest" {
t.Errorf("extra headers = %v", got.header)
}
body := sentBody(t, got)
if _, ok := body["temperature"]; ok {
t.Error("temperature sent although Request.Temperature is nil")
}
if _, ok := body["max_tokens"]; ok {
t.Error("max_tokens sent although Request.MaxTokens is 0")
}
if rf := body["response_format"].(map[string]any); rf["type"] != "json_object" || rf["json_schema"] != nil {
t.Errorf("response_format = %v", rf)
}
system := body["messages"].([]any)[0].(map[string]any)["content"].(string)
if !strings.HasPrefix(system, "You plan dinners for Our kitchen.\n\n"+schemaInstruction) || !strings.Contains(system, `"required":["dinners"]`) {
t.Errorf("system prompt lacks the schema instruction: %q", system)
}
}
func TestGeneratePromptOnlyOllama(t *testing.T) {
vs := newVendorServer(t, reply{status: 200, fixture: "ollama_chat_prompt_only.json"})
p, err := New(Config{BaseURL: vs.URL + "/v1", Model: "llama3.2", StructuredOutput: PromptOnly})
if err != nil {
t.Fatal(err)
}
req := schemaRequest()
req.System = ""
resp, err := p.Generate(context.Background(), req)
if err != nil {
t.Fatal(err)
}
// The reply has a non-JSON "{idea}" first and a second object after the
// fenced one; only the first complete object is taken.
assertJSON(t, resp.JSON, `{"dinners":[{"name":"Crock pot chili","why":"A Quick dinner like your {Regular} stew."}]}`)
if !strings.HasPrefix(resp.Text, "Sure! Here is") {
t.Errorf("Text = %q, want the whole reply", resp.Text)
}
if resp.Usage != (llm.Usage{InputTokens: 512, OutputTokens: 58}) || resp.Model != "llama3.2" || resp.FinishReason != "stop" {
t.Errorf("Usage, Model, FinishReason = %+v, %q, %q", resp.Usage, resp.Model, resp.FinishReason)
}
got := vs.only(t)
if _, ok := got.header["Authorization"]; ok {
t.Error("Authorization sent without an API key")
}
body := sentBody(t, got)
if _, ok := body["response_format"]; ok {
t.Error("response_format sent in prompt_only mode")
}
system := body["messages"].([]any)[0].(map[string]any)
if system["role"] != "system" || !strings.HasPrefix(system["content"].(string), schemaInstruction) {
t.Errorf("system message = %v", system)
}
}
func TestGenerateWithoutSchemaReturnsText(t *testing.T) {
vs := newVendorServer(t, reply{status: 200, fixture: "ollama_chat_text.json"})
p := newProvider(t, vs.URL+"/v1", PromptOnly, nil)
resp, err := p.Generate(context.Background(), llm.Request{Messages: []llm.Message{{Role: llm.RoleUser, Content: "Dinner?"}}})
if err != nil {
t.Fatal(err)
}
if resp.Text != "Try a lemon orzo bake tonight." || resp.JSON != nil {
t.Errorf("Text, JSON = %q, %q", resp.Text, resp.JSON)
}
body := sentBody(t, vs.only(t))
if msgs := body["messages"].([]any); len(msgs) != 1 {
t.Errorf("messages = %v, want only the user message", msgs)
}
if _, ok := body["response_format"]; ok {
t.Error("response_format sent without a schema")
}
}
func TestGenerateBadOutput(t *testing.T) {
cases := []struct {
name string
fixture string
mode StructuredOutput
}{
{"refusal", "openai_chat_refusal.json", JSONSchema},
{"truncated prompt_only", "ollama_chat_truncated.json", PromptOnly},
{"truncated json_object", "ollama_chat_truncated.json", JSONObject},
{"prose in json_object", "ollama_chat_text.json", JSONObject},
{"prose in prompt_only", "ollama_chat_text.json", PromptOnly},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
vs := newVendorServer(t, reply{status: 200, fixture: c.fixture})
p := newProvider(t, vs.URL, c.mode, nil)
_, err := p.Generate(context.Background(), schemaRequest())
if !errors.Is(err, llm.ErrBadOutput) {
t.Fatalf("err = %v, want ErrBadOutput", err)
}
})
}
}
func TestListModels(t *testing.T) {
cases := []struct {
fixture string
base string
want []llm.Model
}{
{"openai_models.json", "/v1", []llm.Model{
{ID: "gpt-4o-2024-08-06", DisplayName: "gpt-4o-2024-08-06"},
{ID: "gpt-4o-mini", DisplayName: "gpt-4o-mini"},
}},
{"openrouter_models.json", "/api/v1", []llm.Model{
{ID: "openai/gpt-4o-mini", DisplayName: "OpenAI: GPT-4o-mini"},
{ID: "anthropic/claude-sonnet-5", DisplayName: "Anthropic: Claude Sonnet 5"},
}},
{"ollama_models.json", "/v1", []llm.Model{
{ID: "llama3.2:latest", DisplayName: "llama3.2:latest"},
{ID: "qwen2.5:7b", DisplayName: "qwen2.5:7b"},
}},
}
for _, c := range cases {
t.Run(c.fixture, func(t *testing.T) {
vs := newVendorServer(t, reply{status: 200, fixture: c.fixture})
p := newProvider(t, vs.URL+c.base, JSONSchema, map[string]string{"X-Title": "MealQuest"})
models, err := p.ListModels(context.Background())
if err != nil {
t.Fatal(err)
}
if len(models) != len(c.want) {
t.Fatalf("models = %+v", models)
}
for i := range c.want {
if models[i] != c.want[i] {
t.Errorf("model %d = %+v, want %+v", i, models[i], c.want[i])
}
}
got := vs.only(t)
if got.method != http.MethodGet || got.path != c.base+"/models" {
t.Errorf("request = %s %s", got.method, got.path)
}
if got.header.Get("Authorization") != "Bearer "+testKey || got.header.Get("X-Title") != "MealQuest" {
t.Errorf("headers = %v", got.header)
}
})
}
}
func TestVerify(t *testing.T) {
ok := newVendorServer(t, reply{status: 200, fixture: "openai_models.json"})
if err := newProvider(t, ok.URL, JSONSchema, nil).Verify(context.Background()); err != nil {
t.Fatalf("Verify() = %v", err)
}
if got := ok.only(t); got.path != "/models" {
t.Errorf("Verify called %s", got.path)
}
bad := newVendorServer(t, reply{status: 401, fixture: "openai_error_invalid_key.json"})
if err := newProvider(t, bad.URL, JSONSchema, nil).Verify(context.Background()); !errors.Is(err, llm.ErrAuth) {
t.Fatalf("Verify() = %v, want ErrAuth", err)
}
}
// assertNoSecret fails if err's text holds the key or Authorization value.
func assertNoSecret(t *testing.T, err error) {
t.Helper()
for _, secret := range []string{testKey, "Bearer " + testKey, testKey[:12]} {
if strings.Contains(strings.ToLower(err.Error()), strings.ToLower(secret)) {
t.Fatalf("error leaks %q: %v", secret, err)
}
}
}
func TestErrorMapping(t *testing.T) {
cases := []struct {
name string
reply reply
want error
check func(t *testing.T, err error)
}{
{name: "401 OpenAI", reply: reply{status: 401, fixture: "openai_error_invalid_key.json"}, want: llm.ErrAuth},
{name: "403", reply: reply{status: 403, fixture: "openai_error_invalid_key.json"}, want: llm.ErrAuth},
{name: "429 seconds OpenRouter", reply: reply{status: 429, header: map[string]string{"Retry-After": "17"}, fixture: "openrouter_error_rate_limited.json"}, want: llm.ErrRateLimited,
check: func(t *testing.T, err error) {
var rl *llm.RateLimitedError
if !errors.As(err, &rl) || rl.RetryAfter != 17*time.Second {
t.Fatalf("RetryAfter from %v", err)
}
}},
{name: "429 HTTP-date", reply: reply{status: 429, header: map[string]string{"Retry-After": time.Now().Add(90 * time.Second).UTC().Format(http.TimeFormat)}, fixture: "openrouter_error_rate_limited.json"}, want: llm.ErrRateLimited,
check: func(t *testing.T, err error) {
var rl *llm.RateLimitedError
if !errors.As(err, &rl) || rl.RetryAfter < 85*time.Second || rl.RetryAfter > 91*time.Second {
t.Fatalf("RetryAfter from %v", err)
}
}},
{name: "429 no Retry-After", reply: reply{status: 429, fixture: "openrouter_error_rate_limited.json"}, want: llm.ErrRateLimited,
check: func(t *testing.T, err error) {
var rl *llm.RateLimitedError
if !errors.As(err, &rl) || rl.RetryAfter != 0 {
t.Fatalf("RetryAfter from %v", err)
}
}},
{name: "408", reply: reply{status: 408}, want: llm.ErrTimeout},
{name: "404 Ollama", reply: reply{status: 404, fixture: "ollama_error_not_found.json"}, want: llm.ErrProvider,
check: func(t *testing.T, err error) {
var pe *llm.ProviderError
if !errors.As(err, &pe) || pe.Status() != 404 || !strings.Contains(pe.Body(), `model \"llama9\" not found`) {
t.Fatalf("ProviderError from %v", err)
}
}},
{name: "500 echoing the key", reply: reply{status: 500, fixture: "openai_error_server.json"}, want: llm.ErrProvider,
check: func(t *testing.T, err error) {
var pe *llm.ProviderError
if !errors.As(err, &pe) || pe.Status() != 500 || !strings.Contains(pe.Body(), "Authorization: Bearer [REDACTED]") {
t.Fatalf("ProviderError from %v", err)
}
}},
{name: "400 echoing the key", reply: reply{status: 400, fixture: "openai_error_invalid_key.json"}, want: llm.ErrProvider},
{name: "200 OpenRouter upstream error", reply: reply{status: 200, fixture: "openrouter_chat_upstream_error.json"}, want: llm.ErrProvider,
check: func(t *testing.T, err error) {
var pe *llm.ProviderError
if !errors.As(err, &pe) || !strings.Contains(pe.Body(), "Upstream error from provider") {
t.Fatalf("ProviderError from %v", err)
}
}},
}
all := []error{llm.ErrAuth, llm.ErrRateLimited, llm.ErrTimeout, llm.ErrBadOutput, llm.ErrProvider}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
vs := newVendorServer(t, c.reply)
p := newProvider(t, vs.URL+"/v1", JSONSchema, nil)
_, genErr := p.Generate(context.Background(), schemaRequest())
errs := []error{genErr}
if c.reply.status != 200 {
_, listErr := p.ListModels(context.Background())
errs = append(errs, listErr)
}
for _, err := range errs {
for _, sentinel := range all {
if errors.Is(err, sentinel) != (sentinel == c.want) {
t.Fatalf("errors.Is(%v, %v) = %v", err, sentinel, !(sentinel == c.want))
}
}
assertNoSecret(t, err)
if c.check != nil {
c.check(t, err)
}
}
})
}
}
func TestTimeouts(t *testing.T) {
vs := newVendorServer(t, reply{status: 200, fixture: "openai_chat_json_schema.json", delay: 5 * time.Second})
p := newProvider(t, vs.URL, JSONSchema, nil)
req := schemaRequest()
req.Timeout = 50 * time.Millisecond
start := time.Now()
_, err := p.Generate(context.Background(), req)
if !errors.Is(err, llm.ErrTimeout) {
t.Fatalf("Request.Timeout: err = %v, want ErrTimeout", err)
}
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Fatalf("Request.Timeout not honoured: took %v", elapsed)
}
assertNoSecret(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if _, err := p.ListModels(ctx); !errors.Is(err, llm.ErrTimeout) {
t.Fatalf("context deadline: err = %v, want ErrTimeout", err)
}
ctx, cancel = context.WithCancel(context.Background())
cancel()
if _, err := p.Generate(ctx, schemaRequest()); !errors.Is(err, context.Canceled) || errors.Is(err, llm.ErrTimeout) {
t.Fatalf("cancelled context: err = %v, want context.Canceled only", err)
}
}
func TestTransportErrorHasNoSecret(t *testing.T) {
vs := newVendorServer(t, reply{status: 200})
base := vs.URL
vs.Close()
_, err := newProvider(t, base, JSONSchema, nil).Generate(context.Background(), schemaRequest())
if err == nil {
t.Fatal("Generate against a closed server succeeded")
}
assertNoSecret(t, err)
}
// TestTransportErrorWithKeyInBaseURLPath fails the exchange against a base
// URL whose path holds the key, which New accepts; net/http names the full
// request URL in its transport errors.
func TestTransportErrorWithKeyInBaseURLPath(t *testing.T) {
refused := newVendorServer(t, reply{status: 200})
refusedBase := refused.URL
refused.Close()
dropped := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Error(err)
return
}
conn.Close()
}))
t.Cleanup(dropped.Close)
for name, base := range map[string]string{
"connection refused": refusedBase + "/v1/" + testKey,
"connection dropped": dropped.URL + "/v1/" + testKey,
} {
p := newProvider(t, base, JSONSchema, nil)
_, genErr := p.Generate(context.Background(), schemaRequest())
_, listErr := p.ListModels(context.Background())
verifyErr := p.Verify(context.Background())
for _, err := range []error{genErr, listErr, verifyErr} {
if err == nil {
t.Fatalf("%s: nil error", name)
}
assertNoSecret(t, err)
var ue *url.Error
if errors.As(err, &ue) {
t.Fatalf("%s: error chain exposes the request URL: %v", name, ue)
}
if errors.Is(err, llm.ErrTimeout) || errors.Is(err, llm.ErrProvider) {
t.Fatalf("%s: err = %v, want an untyped transport failure", name, err)
}
}
}
}
// TestKeyedErrorsNeverLeakKey drives every error path of the keyed OpenAI
// and OpenRouter shapes with vendor bodies that echo the key.
func TestKeyedErrorsNeverLeakKey(t *testing.T) {
shapes := map[string]struct {
path string
headers map[string]string
}{
"OpenAI": {path: "/v1"},
"OpenRouter": {path: "/api/v1", headers: map[string]string{"HTTP-Referer": "https://mealquest.example", "X-Title": "MealQuest"}},
}
fixtures := []string{"openai_error_invalid_key.json", "openai_error_server.json", "openrouter_error_rate_limited.json"}
statuses := []int{400, 401, 403, 404, 408, 429, 500, 502, 503}
for name, shape := range shapes {
for _, fixture := range fixtures {
for _, status := range statuses {
vs := newVendorServer(t, reply{status: status, fixture: fixture})
p := newProvider(t, vs.URL+shape.path, JSONSchema, shape.headers)
_, genErr := p.Generate(context.Background(), schemaRequest())
_, listErr := p.ListModels(context.Background())
verifyErr := p.Verify(context.Background())
for _, err := range []error{genErr, listErr, verifyErr} {
if err == nil {
t.Fatalf("%s %s HTTP %d: nil error", name, fixture, status)
}
assertNoSecret(t, err)
var pe *llm.ProviderError
if errors.As(err, &pe) {
assertNoSecret(t, errors.New(pe.Body()))
}
}
}
}
}
}
func TestKeylessOllamaErrorKeepsExcerpt(t *testing.T) {
vs := newVendorServer(t, reply{status: 404, fixture: "ollama_error_not_found.json"})
p, err := New(Config{BaseURL: vs.URL + "/v1", Model: "llama9", StructuredOutput: PromptOnly})
if err != nil {
t.Fatal(err)
}
_, err = p.Generate(context.Background(), schemaRequest())
var pe *llm.ProviderError
if !errors.As(err, &pe) || pe.Status() != 404 {
t.Fatalf("err = %v, want a 404 ProviderError", err)
}
if !strings.Contains(pe.Body(), `model \"llama9\" not found`) || len(pe.Body()) > llm.MaxBodyExcerpt {
t.Fatalf("Body() = %q, want the bounded not-found explanation", pe.Body())
}
if !strings.Contains(err.Error(), pe.Body()) {
t.Fatalf("Error() = %q, want it to carry the excerpt", err)
}
assertNoSecret(t, err)
if got := vs.only(t).header.Get("Authorization"); got != "" {
t.Fatalf("keyless request sent Authorization %q", got)
}
}
func TestGenerateRejectsInvalidRequests(t *testing.T) {
vs := newVendorServer(t, reply{status: 200, fixture: "ollama_chat_text.json"})
p := newProvider(t, vs.URL, JSONSchema, nil)
if _, err := p.Generate(context.Background(), llm.Request{}); err == nil {
t.Error("Generate accepted a request with no messages")
}
noModel, err := New(Config{BaseURL: vs.URL, APIKey: testKey, StructuredOutput: JSONSchema})
if err != nil {
t.Fatal(err)
}
if _, err := noModel.Generate(context.Background(), schemaRequest()); err == nil {
t.Error("Generate succeeded with no model configured")
}
vs.mu.Lock()
defer vs.mu.Unlock()
if len(vs.got) != 0 {
t.Errorf("invalid requests reached the vendor %d times", len(vs.got))
}
}
func TestNewRejectsBadHeaderWithoutEchoingValue(t *testing.T) {
_, err := New(Config{BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"X-Title": testKey + "\u0019"}})
if err == nil {
t.Fatal("New() = nil error")
}
assertNoSecret(t, err)
}
func TestNewRejectsKeyWithControlCharacter(t *testing.T) {
for _, key := range []string{testKey + "\n", testKey + "\r\nX-Injected: 1", "\x00" + testKey, testKey + "\x7f"} {
_, err := New(Config{BaseURL: "https://example.com/v1", APIKey: key, StructuredOutput: JSONSchema})
if err == nil {
t.Errorf("New(%q) = nil error", key)
continue
}
assertNoSecret(t, err)
if strings.Contains(err.Error()+fmt.Sprintf("%#v", err), key) {
t.Errorf("error leaks the key: %v", err)
}
}
}
// TestNewConfigErrorsNeverLeakKey covers every New error that names
// caller-supplied text, each with the API key embedded in that text.
func TestNewConfigErrorsNeverLeakKey(t *testing.T) {
cases := map[string]Config{
"key in invalid header name": {Headers: map[string]string{"Bearer " + testKey: "a"}},
"key as invalid header name": {Headers: map[string]string{testKey + ":": "a"}},
"key in name, bad value": {Headers: map[string]string{"x-" + testKey: "a\nb"}},
"key in structured output": {StructuredOutput: StructuredOutput("Bearer " + testKey)},
"key with quote in mode": {StructuredOutput: StructuredOutput(testKey + "\"\x00")},
}
for name, cfg := range cases {
cfg.BaseURL = "https://example.com/v1"
cfg.APIKey = testKey
if cfg.StructuredOutput == "" {
cfg.StructuredOutput = JSONSchema
}
_, err := New(cfg)
if err == nil {
t.Errorf("%s: New() = nil error", name)
continue
}
assertNoSecret(t, err)
}
}
// TestNewManagedHeaderErrorNeverLeaksKey uses an API key that is itself a
// managed header name, so naming the rejected header would print the key.
func TestNewManagedHeaderErrorNeverLeaksKey(t *testing.T) {
for _, key := range []string{"Authorization", "Content-Type", "Host"} {
_, err := New(Config{
BaseURL: "https://example.com/v1",
APIKey: key,
StructuredOutput: JSONSchema,
Headers: map[string]string{key: "x"},
})
if err == nil {
t.Errorf("key %q: New() = nil error", key)
continue
}
for _, secret := range []string{key, "Bearer " + key} {
if strings.Contains(strings.ToLower(err.Error()), strings.ToLower(secret)) {
t.Errorf("key %q: error leaks %q: %v", key, secret, err)
}
}
}
}
// TestNewModeErrorNeverLeaksKeyFragment uses a structured output mode that is
// a fragment of the API key, which redacting the whole key would not catch.
func TestNewModeErrorNeverLeaksKeyFragment(t *testing.T) {
_, err := New(Config{BaseURL: "https://example.com/v1", APIKey: testKey, StructuredOutput: StructuredOutput(testKey[:20])})
if err == nil {
t.Fatal("New() = nil error")
}
assertNoSecret(t, err)
}
func TestAcceptHeader(t *testing.T) {
for name, tc := range map[string]struct {
headers map[string]string
want string
}{
"default": {nil, "application/json"},
"configured": {map[string]string{"accept": "application/json, text/event-stream"}, "application/json, text/event-stream"},
} {
vs := newVendorServer(t, reply{status: 200, fixture: "openai_models.json"})
p := newProvider(t, vs.URL+"/v1", JSONSchema, tc.headers)
if _, err := p.ListModels(context.Background()); err != nil {
t.Fatalf("%s: %v", name, err)
}
if got := vs.only(t).header.Values("Accept"); len(got) != 1 || got[0] != tc.want {
t.Errorf("%s: Accept = %q, want %q", name, got, tc.want)
}
}
}
func TestNewAcceptsValidHeaders(t *testing.T) {
headers := map[string]string{"X-Trace_ID.v2!#$%&'*+^`|~": "tab\tand obs-text \xe2\x9c\x93", "X-Empty": ""}
if _, err := New(Config{BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: headers}); err != nil {
t.Fatal(err)
}
}
func TestNewRejectsBadConfig(t *testing.T) {
cases := map[string]Config{
"empty base": {StructuredOutput: JSONSchema},
"relative base": {BaseURL: "api.openai.com/v1", StructuredOutput: JSONSchema},
"ftp base": {BaseURL: "ftp://example.com", StructuredOutput: JSONSchema},
"credentials in URL": {BaseURL: "https://user:" + testKey + "@example.com/v1", StructuredOutput: JSONSchema},
"key in query": {BaseURL: "https://example.com/v1?key=" + testKey, StructuredOutput: JSONSchema},
"key in fragment": {BaseURL: "https://example.com/v1#" + testKey, StructuredOutput: JSONSchema},
"key, relative base": {BaseURL: "example.com/v1?key=" + testKey, StructuredOutput: JSONSchema},
"key, ftp base": {BaseURL: "ftp://example.com/v1?key=" + testKey, StructuredOutput: JSONSchema},
"key, unparseable": {BaseURL: "https://example.com/%zz?key=" + testKey, StructuredOutput: JSONSchema},
"empty query": {BaseURL: "https://example.com/v1?", StructuredOutput: JSONSchema},
"empty fragment": {BaseURL: "https://example.com/v1#", StructuredOutput: JSONSchema},
"empty both": {BaseURL: "https://example.com/v1?#", StructuredOutput: JSONSchema},
"unknown mode": {BaseURL: "https://example.com/v1", StructuredOutput: "json"},
"no mode": {BaseURL: "https://example.com/v1"},
"authorization hdr": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"authorization": "Bearer x"}},
"content-type hdr": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"Content-Type": "text/plain"}},
"header newline": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"X-Title": "a\r\nb"}},
"header bad name": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"X Title": "a"}},
"header empty name": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"": "a"}},
"header bracket": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"X[Trace]": "a"}},
"header colon": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"X-Title:": "a"}},
"header non-ASCII": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"X-Tïtle": "a"}},
"header U+0019": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"X-Title": "a\u0019b"}},
"header NUL": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"X-Title": "a\x00b"}},
"header DEL": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"X-Title": "a\x7fb"}},
"host hdr": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"host": "other.example"}},
"content-length hdr": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"Content-Length": "1"}},
}
for name, cfg := range cases {
cfg.APIKey = testKey
_, err := New(cfg)
if err == nil {
t.Errorf("%s: New() = nil error", name)
continue
}
assertNoSecret(t, err)
}
}
func TestFirstJSONObject(t *testing.T) {
cases := map[string]string{
`{"a":1}`: `{"a":1}`,
"text {not json} then {\"b\":2}": `{"b":2}`,
"```json\n{\"c\":{\"d\":3}}\n```": `{"c":{"d":3}}`,
`{"a":1} {"b":2}`: `{"a":1}`,
"no object": ``,
`{"open":`: ``,
`[{"in":"array"}]`: `{"in":"array"}`,
}
for in, want := range cases {
if got := string(firstJSONObject(in)); got != want {
t.Errorf("firstJSONObject(%q) = %q, want %q", in, got, want)
}
}
}
func TestRetryAfter(t *testing.T) {
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
cases := map[string]time.Duration{
"": 0,
"30": 30 * time.Second,
" 5 ": 5 * time.Second,
"-3": 0,
"soon": 0,
"Sat, 03 Oct 2026 12:02:00 GMT": 2 * time.Minute,
"Sat, 03 Oct 2026 11:59:00 GMT": 0,
"9223372036854775807": time.Duration(maxRetryAfterSeconds) * time.Second,
"99999999999999999999": time.Duration(maxRetryAfterSeconds) * time.Second,
"-99999999999999999999": 0,
}
for in, want := range cases {
if got := retryAfter(in, now); got != want {
t.Errorf("retryAfter(%q) = %v, want %v", in, got, want)
}
}
}
// headerNameKey is an API key that is also a word callers and vendors put in
// text: an error that quotes such text prints the key.
const headerNameKey = "Authorization"
// assertNoKey fails if err's text holds key or "Bearer "+key in any case.
func assertNoKey(t *testing.T, err error, key string) {
t.Helper()
for _, secret := range []string{key, "Bearer " + key} {
if strings.Contains(err.Error(), secret) || strings.Contains(strings.ToLower(err.Error()), strings.ToLower(secret)) {
t.Fatalf("error leaks %q: %v", secret, err)
}
}
}
// keyServer replies to every request with status and body.
func keyServer(t *testing.T, status int, body string) *Provider {
t.Helper()
return keyServerWithKey(t, headerNameKey, status, body)
}
// keyServerWithKey is keyServer for a provider configured with key.
func keyServerWithKey(t *testing.T, key string, status int, body string) *Provider {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
io.WriteString(w, body)
}))
t.Cleanup(srv.Close)
p, err := New(Config{BaseURL: srv.URL + "/v1", APIKey: key, Model: "test-model", StructuredOutput: PromptOnly})
if err != nil {
t.Fatal(err)
}
return p
}
func TestGenerateInvalidRoleNeverLeaksKey(t *testing.T) {
p := keyServer(t, 200, `{}`)
_, err := p.Generate(context.Background(), llm.Request{Messages: []llm.Message{{Role: llm.Role(headerNameKey), Content: "hi"}}})
if err == nil {
t.Fatal("Generate accepted an unknown role")
}
assertNoKey(t, err, headerNameKey)
}
func TestGenerateInvalidSchemaNeverLeaksKey(t *testing.T) {
p := keyServer(t, 200, `{}`)
req := schemaRequest()
req.Schema = &llm.JSONSchema{Name: headerNameKey, Schema: json.RawMessage(`[]`)}
_, err := p.Generate(context.Background(), req)
if err == nil {
t.Fatal("Generate accepted a schema that is not a JSON object")
}
assertNoKey(t, err, headerNameKey)
}
func TestGenerateBadOutputNeverLeaksKey(t *testing.T) {
p := keyServer(t, 200, `{"model":"m","choices":[{"message":{"content":"no JSON here"},"finish_reason":"`+headerNameKey+`"}]}`)
resp, err := p.Generate(context.Background(), schemaRequest())
if !errors.Is(err, llm.ErrBadOutput) {
t.Fatalf("err = %v, want ErrBadOutput", err)
}
assertNoKey(t, err, headerNameKey)
if resp.FinishReason != headerNameKey {
t.Fatalf("FinishReason = %q, want it kept on the response", resp.FinishReason)
}
}
func TestProviderErrorBodyNeverLeaksKey(t *testing.T) {
p := keyServer(t, 500, `{"error":{"message":"rejected `+headerNameKey+`: Bearer `+headerNameKey+`"}}`)
_, genErr := p.Generate(context.Background(), schemaRequest())
_, listErr := p.ListModels(context.Background())
verifyErr := p.Verify(context.Background())
for _, err := range []error{genErr, listErr, verifyErr} {
var pe *llm.ProviderError
if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != 500 {
t.Fatalf("err = %v, want a 500 ProviderError", err)
}
assertNoKey(t, err, headerNameKey)
}
}
// TestRedactKeepsChainAndHidesKey checks the backstop every exported method
// applies: the key and its Bearer value leave the text, the chain stays.
func TestRedactKeepsChainAndHidesKey(t *testing.T) {
inner := fmt.Errorf("decode: body %q: %w", "Bearer "+testKey, llm.ErrBadOutput)
err := redact(inner, testKey)
assertChainNoKey(t, err, testKey)
if !errors.Is(err, llm.ErrBadOutput) {
t.Fatalf("redact broke the chain of %v", err)
}
if redact(inner, "") != inner {
t.Fatal("redact changed an error for the empty key")
}
clean := errors.New("openai: generate: no model configured")
if got := redact(clean, testKey); got.Error() != clean.Error() || !errors.Is(got, clean) {
t.Fatalf("redact(%v) = %v, want the same text and chain", clean, got)
}
// A key inside the redaction marker would survive replacement.
if got := redact(errors.New("x E y"), "E").Error(); strings.Contains(got, "E") {
t.Fatalf("redact kept the key: %q", got)
}
}
// assertChainNoKey fails if err, or any error reached from it by unwrapping,
// has text holding key or "Bearer "+key.
func assertChainNoKey(t *testing.T, err error, key string) {
t.Helper()
if err == nil {
return
}
assertNoKey(t, err, key)
switch u := err.(type) {
case interface{ Unwrap() error }:
assertChainNoKey(t, u.Unwrap(), key)
case interface{ Unwrap() []error }:
for _, c := range u.Unwrap() {
assertChainNoKey(t, c, key)
}
}
}
// TestGenerateInvalidJSONWithKeyNeverLeaksKeyByUnwrapping sends an invalid
// JSON body holding the key and walks the returned chain with errors.Unwrap.
func TestGenerateInvalidJSONWithKeyNeverLeaksKeyByUnwrapping(t *testing.T) {
p := keyServer(t, 200, `{"choices": Bearer `+headerNameKey+` `+headerNameKey+`}`)
_, err := p.Generate(context.Background(), schemaRequest())
if err == nil {
t.Fatal("Generate accepted an invalid JSON body")
}
var syntax *json.SyntaxError
if !errors.As(err, &syntax) {
t.Fatalf("err = %v, want a *json.SyntaxError in the chain", err)
}
for layer := err; layer != nil; layer = errors.Unwrap(layer) {
assertNoKey(t, layer, headerNameKey)
}
}
// TestRedactMakesEveryUnwrappedLayerSafe covers chains whose inner layers
// hold the key, including a layer whose own text does not show it and a
// layer with several causes, and checks Is and As still match.
func TestRedactMakesEveryUnwrappedLayerSafe(t *testing.T) {
pe := llm.NewProviderError(500, []byte(`{"error":"down"}`), testKey)
hidden := &opaqueError{err: fmt.Errorf("body Bearer %s: %w", testKey, llm.ErrAuth)}
inner := fmt.Errorf("decode %s: %w", testKey, llm.ErrBadOutput)
joined := errors.Join(inner, pe, context.Canceled, fmt.Errorf("limit: %w", llm.ErrRateLimited))
cases := []error{
fmt.Errorf("openai: generate: %w", inner),
fmt.Errorf("openai: generate: %w", hidden),
fmt.Errorf("openai: generate: %w", joined),
fmt.Errorf("openai: %w and %w", inner, &transportFailure{text: "llm: request timed out: transport: slow", kind: llm.ErrTimeout}),
}
for _, c := range cases {
err := redact(c, testKey)
if err == c {
t.Fatalf("redact left %v unchanged", c)
}
assertChainNoKey(t, err, testKey)
for layer := err; layer != nil; layer = errors.Unwrap(layer) {
assertNoKey(t, layer, testKey)
}
}
err := redact(fmt.Errorf("openai: %w", joined), testKey)
var got *llm.ProviderError
if !errors.As(err, &got) || got != pe {
t.Fatalf("errors.As lost the ProviderError in %v", err)
}
for _, target := range []error{llm.ErrBadOutput, llm.ErrProvider, llm.ErrRateLimited, context.Canceled} {
if !errors.Is(err, target) {
t.Fatalf("errors.Is(%v, %v) = false", err, target)
}
}
err = redact(fmt.Errorf("openai: %w", hidden), testKey)
if !errors.Is(err, llm.ErrAuth) {
t.Fatalf("errors.Is lost ErrAuth in %v", err)
}
err = redact(fmt.Errorf("openai %s: %w", testKey, &transportFailure{text: "llm: request timed out: transport: slow", kind: llm.ErrTimeout}), testKey)
if !errors.Is(err, llm.ErrTimeout) {
t.Fatalf("errors.Is lost ErrTimeout in %v", err)
}
}
// opaqueError hides its cause's text but unwraps to it.
type opaqueError struct{ err error }
func (e *opaqueError) Error() string { return "opaque" }
func (e *opaqueError) Unwrap() error { return e.err }
// assertChainNoExactKey fails if the text of err, of any error reached from
// it by unwrapping, or of a *llm.ProviderError found by errors.As holds key.
// It matches case-sensitively, so it suits keys too short for assertNoKey.
func assertChainNoExactKey(t *testing.T, err error, key string) {
t.Helper()
var walk func(error)
walk = func(e error) {
if e == nil {
return
}
if strings.Contains(e.Error(), key) {
t.Fatalf("error leaks %q: %q", key, e.Error())
}
switch u := e.(type) {
case interface{ Unwrap() error }:
walk(u.Unwrap())
case interface{ Unwrap() []error }:
for _, c := range u.Unwrap() {
walk(c)
}
}
}
walk(err)
var pe *llm.ProviderError
if errors.As(err, &pe) && (strings.Contains(pe.Body(), key) || strings.Contains(pe.Error(), key)) {
t.Fatalf("ProviderError leaks %q: body %q", key, pe.Body())
}
var syntax *json.SyntaxError
if errors.As(err, &syntax) && strings.Contains(syntax.Error(), key) {
t.Fatalf("errors.As reached a *json.SyntaxError leaking %q: %q", key, syntax.Error())
}
}
// TestProviderErrorBodyWithMarkerKeyNeverLeaksKey uses the redaction marker
// text as the API key, so replacing the key with the marker would keep it.
func TestProviderErrorBodyWithMarkerKeyNeverLeaksKey(t *testing.T) {
const key = "REDACTED"
p := keyServerWithKey(t, key, 500, `{"error":{"message":"rejected `+key+`: Bearer `+key+`"}}`)
_, err := p.Generate(context.Background(), schemaRequest())
var pe *llm.ProviderError
if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != 500 {
t.Fatalf("err = %v, want a 500 ProviderError", err)
}
if strings.Contains(pe.Body(), key) || strings.Contains(pe.Error(), key) {
t.Fatalf("ProviderError leaks the key: %q", pe.Error())
}
assertChainNoExactKey(t, err, key)
}
// TestGenerateMalformedJSONWithShortKeyNeverLeaksKey uses a one-letter key
// found in the redaction marker and in ordinary error prose.
func TestGenerateMalformedJSONWithShortKeyNeverLeaksKey(t *testing.T) {
const key = "A"
p := keyServerWithKey(t, key, 200, `{"choices": A Bearer A}`)
_, err := p.Generate(context.Background(), schemaRequest())
if err == nil {
t.Fatal("Generate accepted a malformed JSON body")
}
assertChainNoExactKey(t, err, key)
for layer := err; layer != nil; layer = errors.Unwrap(layer) {
if strings.Contains(layer.Error(), key) {
t.Fatalf("layer leaks %q: %q", key, layer.Error())
}
}
}
// assertLayersNoExactKey fails if the text of err or of any error reached
// from it by errors.Unwrap holds key, matched case-sensitively.
func assertLayersNoExactKey(t *testing.T, err error, key string) {
t.Helper()
for layer := err; layer != nil; layer = errors.Unwrap(layer) {
if strings.Contains(layer.Error(), key) {
t.Fatalf("layer leaks %q: %q", key, layer.Error())
}
}
}
// TestProviderErrorWithFixedWordKeyKeepsTypedMatch uses the fixed word
// "HTTP" of ProviderError's text as the API key: the text must drop it and
// errors.As must still reach the ProviderError.
func TestProviderErrorWithFixedWordKeyKeepsTypedMatch(t *testing.T) {
const key = "HTTP"
p := keyServerWithKey(t, key, 500, `{"error":"down"}`)
_, err := p.Generate(context.Background(), schemaRequest())
var pe *llm.ProviderError
if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != 500 {
t.Fatalf("err = %v, want a 500 ProviderError", err)
}
if strings.Contains(pe.Error(), key) || strings.Contains(pe.Body(), key) {
t.Fatalf("ProviderError leaks the key: Error %q, Body %q", pe.Error(), pe.Body())
}
assertLayersNoExactKey(t, err, key)
assertChainNoExactKey(t, err, key)
}
// TestRateLimitedErrorWithFixedWordKeyKeepsTypedMatch uses the fixed word
// "rate" of RateLimitedError's text as the API key.
func TestRateLimitedErrorWithFixedWordKeyKeepsTypedMatch(t *testing.T) {
const key = "rate"
srv := newVendorServer(t, reply{status: 429, header: map[string]string{"Retry-After": "17"}})
p, err := New(Config{BaseURL: srv.URL + "/v1", APIKey: key, Model: "test-model", StructuredOutput: PromptOnly})
if err != nil {
t.Fatal(err)
}
_, err = p.Generate(context.Background(), schemaRequest())
var rl *llm.RateLimitedError
if !errors.Is(err, llm.ErrRateLimited) || !errors.As(err, &rl) || rl.RetryAfter != 17*time.Second {
t.Fatalf("err = %v, want a RateLimitedError with RetryAfter 17s", err)
}
if strings.Contains(rl.Error(), key) {
t.Fatalf("RateLimitedError leaks the key: %q", rl.Error())
}
assertLayersNoExactKey(t, err, key)
assertChainNoExactKey(t, err, key)
}
// TestGenerateWithAdapterNameKeyNeverLeaksKey uses the fixed prefix
// "openai" of the adapter's errors as the API key.
func TestGenerateWithAdapterNameKeyNeverLeaksKey(t *testing.T) {
const key = "openai"
t.Run("401", func(t *testing.T) {
p := keyServerWithKey(t, key, 401, `{"error":{"message":"bad key"}}`)
_, err := p.Generate(context.Background(), schemaRequest())
if !errors.Is(err, llm.ErrAuth) {
t.Fatalf("err = %v, want ErrAuth", err)
}
assertLayersNoExactKey(t, err, key)
assertChainNoExactKey(t, err, key)
})
t.Run("no model", func(t *testing.T) {
p, err := New(Config{BaseURL: "http://127.0.0.1:1/v1", APIKey: key, StructuredOutput: PromptOnly})
if err != nil {
t.Fatal(err)
}
_, err = p.Generate(context.Background(), schemaRequest())
if err == nil {
t.Fatal("Generate succeeded without a model")
}
assertLayersNoExactKey(t, err, key)
assertChainNoExactKey(t, err, key)
})
}
// TestRedactedErrorsFormatWithoutKey formats every error type that holds the
// key, or a cause holding it, with %v, %+v and %#v; %#v prints GoString.
func TestRedactedErrorsFormatWithoutKey(t *testing.T) {
const key = "sk-distinctive-openai-format-7c1e"
single := redact(fmt.Errorf("openai %s: %w", key, llm.ErrBadOutput), key)
joined := redact(errors.Join(fmt.Errorf("decode %s: %w", key, llm.ErrAuth), llm.NewProviderError(500, []byte("down "+key), key)), key)
if _, ok := single.(*redactedError); !ok {
t.Fatalf("single = %T, want *redactedError", single)
}
if _, ok := joined.(*redactedJoin); !ok {
t.Fatalf("joined = %T, want *redactedJoin", joined)
}
p := &Provider{apiKey: key}
transport := p.transportError(fmt.Errorf("dial %s: %w", key, context.DeadlineExceeded))
for _, err := range []error{single, joined, transport} {
for _, verb := range []string{"%v", "%+v", "%#v"} {
if got := fmt.Sprintf(verb, err); strings.Contains(got, key) {
t.Fatalf("%s of %T leaks the key: %q", verb, err, got)
}
}
}
}
func TestRateLimitedHugeRetryAfterIsCapped(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Retry-After", "9223372036854775807")
w.WriteHeader(http.StatusTooManyRequests)
}))
t.Cleanup(srv.Close)
p, err := New(Config{BaseURL: srv.URL + "/v1", APIKey: testKey, Model: "test-model", Headers: map[string]string{"X-Title": testKey}, StructuredOutput: PromptOnly})
if err != nil {
t.Fatal(err)
}
err = p.Verify(context.Background())
var rl *llm.RateLimitedError
if !errors.Is(err, llm.ErrRateLimited) || !errors.As(err, &rl) || rl.RetryAfter != time.Duration(maxRetryAfterSeconds)*time.Second {
t.Fatalf("err = %v, want a RateLimitedError with RetryAfter capped to %v", err, time.Duration(maxRetryAfterSeconds)*time.Second)
}
}
func TestRedirectIsNotFollowed(t *testing.T) {
var mu sync.Mutex
var secondCalls int
var secondHeader http.Header
second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
mu.Lock()
secondCalls++
secondHeader = req.Header.Clone()
mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(second.Close)
first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, second.URL+req.URL.Path, http.StatusFound)
}))
t.Cleanup(first.Close)
p, err := New(Config{BaseURL: first.URL + "/v1", APIKey: testKey, Model: "test-model", Headers: map[string]string{"X-Title": testKey}, StructuredOutput: PromptOnly})
if err != nil {
t.Fatal(err)
}
err = p.Verify(context.Background())
var pe *llm.ProviderError
if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != http.StatusFound {
t.Fatalf("err = %v, want a ProviderError for HTTP 302", err)
}
mu.Lock()
defer mu.Unlock()
if secondCalls != 0 {
for name, values := range secondHeader {
for _, v := range values {
if strings.Contains(v, testKey) {
t.Errorf("redirect target saw the key in %s", name)
}
}
}
t.Fatalf("redirect target got %d requests, want 0", secondCalls)
}
}
// assertGoStringNoKey checks that %#v of err, of every layer reached by
// unwrapping and of the typed errors reached by errors.As holds no key.
func assertGoStringNoKey(t *testing.T, err error, key string) {
t.Helper()
var walk func(error)
walk = func(e error) {
if e == nil {
return
}
if got := fmt.Sprintf("%#v", e); strings.Contains(got, key) {
t.Fatalf("%%#v of %T leaks %q: %q", e, key, got)
}
switch u := e.(type) {
case interface{ Unwrap() error }:
walk(u.Unwrap())
case interface{ Unwrap() []error }:
for _, c := range u.Unwrap() {
walk(c)
}
}
}
walk(err)
var pe *llm.ProviderError
if errors.As(err, &pe) {
walk(pe)
}
var rl *llm.RateLimitedError
if errors.As(err, &rl) {
walk(rl)
}
}
// TestFieldNameKeyNeverLeaksThroughGoString uses struct field names of the
// returned error types as the API key; %#v must not print any of them.
func TestFieldNameKeyNeverLeaksThroughGoString(t *testing.T) {
for _, key := range []string{"body", "status", "text", "RetryAfter", "msg", "err", "key", "kind"} {
_, err := keyServerWithKey(t, key, 500, "down").Generate(context.Background(), schemaRequest())
var pe *llm.ProviderError
if !errors.As(err, &pe) || pe.Status() != 500 {
t.Fatalf("key %q: err = %v, want a 500 ProviderError", key, err)
}
assertGoStringNoKey(t, err, key)
err = keyServerWithKey(t, key, 429, "slow").Verify(context.Background())
var rl *llm.RateLimitedError
if !errors.As(err, &rl) {
t.Fatalf("key %q: err = %v, want a RateLimitedError", key, err)
}
assertGoStringNoKey(t, err, key)
}
}