CI / ci (push) Successful in 1m28s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
1270 lines
47 KiB
Go
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)
|
|
}
|
|
}
|