Add OpenAI-compatible llm.Provider adapter
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (1 high, 1 medium)
CI / ci (push) Successful in 1m29s
CI / ci (pull_request) Successful in 1m30s

internal/ai/llm/openai implements Provider over plain net/http against
POST {base}/chat/completions and GET {base}/models, with Bearer auth and
optional extra headers. Structured output uses response_format
json_schema (strict), json_object, or prompt-only with first-JSON-object
extraction, per the configured mode. Usage tokens, model and
finish_reason are parsed.

Errors map 401/403 to ErrAuth, 429 to ErrRateLimited with Retry-After
(seconds or HTTP-date), 408 and transport timeouts to ErrTimeout, and
other non-2xx statuses (and OpenRouter error bodies in a 2xx) to
ErrProvider with a key-redacted excerpt.

Contract tests replay OpenAI, OpenRouter and Ollama /v1 payloads from
httptest servers and assert the key and Authorization value never
appear in an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 16:20:23 -04:00
co-authored by Claude Opus 5.5
parent 72042516e2
commit 6d0bea10b5
16 changed files with 1156 additions and 0 deletions
+415
View File
@@ -0,0 +1,415 @@
// Package openai is the llm.Provider adapter for OpenAI-compatible chat
// APIs: OpenAI itself, OpenRouter and Ollama's /v1 endpoint. It calls
// POST {base}/chat/completions and GET {base}/models over plain net/http.
//
// The adapter never logs, and no error it returns contains the API key or
// the Authorization header value.
package openai
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/textproto"
"net/url"
"strconv"
"strings"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// StructuredOutput is how the adapter asks the vendor for JSON when a
// Request carries a Schema. Which mode a vendor and model support comes
// from its preset.
type StructuredOutput string
const (
// JSONSchema sends response_format {type: json_schema, strict: true}
// with the Request's schema.
JSONSchema StructuredOutput = "json_schema"
// JSONObject sends response_format {type: json_object} and puts the
// schema in the system prompt.
JSONObject StructuredOutput = "json_object"
// PromptOnly sends no response_format, puts the schema in the system
// prompt and takes the first JSON object found in the reply.
PromptOnly StructuredOutput = "prompt_only"
)
// Config configures a Provider.
type Config struct {
// BaseURL is the API root the endpoint paths are appended to, e.g.
// "https://api.openai.com/v1", "https://openrouter.ai/api/v1" or
// "http://ollama:11434/v1".
BaseURL string
// APIKey is sent as "Authorization: Bearer <APIKey>". Empty sends no
// Authorization header, for endpoints such as Ollama that need none.
APIKey string
// Model is the model Generate requests. ListModels and Verify do not
// need it.
Model string
// Headers are extra request headers, e.g. OpenRouter's HTTP-Referer and
// X-Title. They must not set Authorization or Content-Type.
Headers map[string]string
// StructuredOutput is how JSON is requested when a Request has a Schema.
StructuredOutput StructuredOutput
}
// Provider is an OpenAI-compatible llm.Provider.
type Provider struct {
base string
apiKey string
model string
headers http.Header
mode StructuredOutput
client *http.Client
}
var _ llm.Provider = (*Provider)(nil)
// New validates cfg and returns a Provider.
func New(cfg Config) (*Provider, error) {
u, err := url.Parse(cfg.BaseURL)
if err != nil {
return nil, errors.New("openai: base URL does not parse")
}
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, fmt.Errorf("openai: base URL %q must be an absolute http or https URL", u.Redacted())
}
if u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return nil, fmt.Errorf("openai: base URL %q must not carry credentials, a query or a fragment", u.Redacted())
}
switch cfg.StructuredOutput {
case JSONSchema, JSONObject, PromptOnly:
default:
return nil, fmt.Errorf("openai: unknown structured output mode %q", cfg.StructuredOutput)
}
headers := make(http.Header, len(cfg.Headers))
for name, value := range cfg.Headers {
canonical := textproto.CanonicalMIMEHeaderKey(name)
if canonical == "Authorization" || canonical == "Content-Type" {
return nil, fmt.Errorf("openai: extra header %q is set by the adapter", canonical)
}
if name == "" || strings.ContainsAny(name, " \t\r\n:") || strings.ContainsAny(value, "\r\n") {
return nil, fmt.Errorf("openai: extra header %q is malformed", name)
}
headers.Set(canonical, value)
}
return &Provider{
base: strings.TrimRight(cfg.BaseURL, "/"),
apiKey: cfg.APIKey,
model: cfg.Model,
headers: headers,
mode: cfg.StructuredOutput,
client: &http.Client{},
}, nil
}
// maxResponseBody caps a successful response body; a larger one is an error.
const maxResponseBody = 16 << 20
// maxErrorBody caps how much of an error response body is read. It is far
// above llm.MaxBodyExcerpt, so redaction still sees every secret that could
// reach the excerpt.
const maxErrorBody = 64 << 10
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type jsonSchemaFormat struct {
Name string `json:"name"`
Schema json.RawMessage `json:"schema"`
Strict bool `json:"strict"`
}
type responseFormat struct {
Type string `json:"type"`
JSONSchema *jsonSchemaFormat `json:"json_schema,omitempty"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
ResponseFormat *responseFormat `json:"response_format,omitempty"`
}
type chatResponse struct {
Model string `json:"model"`
Choices []struct {
Message struct {
Content *string `json:"content"`
Refusal *string `json:"refusal"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
} `json:"usage"`
Error json.RawMessage `json:"error"`
}
// schemaInstruction is appended to the system prompt in the json_object and
// prompt_only modes, where the schema is not sent in response_format.
const schemaInstruction = "Reply with only a JSON object, and no other text, that matches this JSON Schema:\n"
// Generate sends req to {base}/chat/completions.
func (p *Provider) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
if err := req.Validate(); err != nil {
return llm.Response{}, fmt.Errorf("openai: generate: %w", err)
}
if p.model == "" {
return llm.Response{}, errors.New("openai: generate: no model configured")
}
body := chatRequest{
Model: p.model,
Temperature: req.Temperature,
MaxTokens: req.MaxTokens,
}
system := req.System
if req.Schema != nil {
switch p.mode {
case JSONSchema:
body.ResponseFormat = &responseFormat{
Type: "json_schema",
JSONSchema: &jsonSchemaFormat{Name: req.Schema.Name, Schema: req.Schema.Schema, Strict: true},
}
case JSONObject:
body.ResponseFormat = &responseFormat{Type: "json_object"}
system = appendInstruction(system, req.Schema.Schema)
case PromptOnly:
system = appendInstruction(system, req.Schema.Schema)
}
}
if system != "" {
body.Messages = append(body.Messages, chatMessage{Role: "system", Content: system})
}
for _, m := range req.Messages {
body.Messages = append(body.Messages, chatMessage{Role: string(m.Role), Content: m.Content})
}
payload, err := json.Marshal(body)
if err != nil {
return llm.Response{}, fmt.Errorf("openai: generate: encode request: %w", err)
}
if req.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, req.Timeout)
defer cancel()
}
raw, status, err := p.do(ctx, http.MethodPost, "/chat/completions", payload)
if err != nil {
return llm.Response{}, fmt.Errorf("openai: generate: %w", err)
}
var cr chatResponse
if err := json.Unmarshal(raw, &cr); err != nil {
return llm.Response{}, fmt.Errorf("openai: generate: decode response: %w", err)
}
if len(cr.Error) > 0 && string(cr.Error) != "null" {
// OpenRouter can report an upstream failure in a 2xx body.
return llm.Response{}, fmt.Errorf("openai: generate: %w", llm.NewProviderError(status, raw, p.secrets()))
}
if len(cr.Choices) == 0 {
return llm.Response{}, errors.New("openai: generate: response has no choices")
}
choice := cr.Choices[0]
resp := llm.Response{
Usage: llm.Usage{InputTokens: cr.Usage.PromptTokens, OutputTokens: cr.Usage.CompletionTokens},
Model: cr.Model,
FinishReason: choice.FinishReason,
}
if choice.Message.Content != nil {
resp.Text = *choice.Message.Content
}
if req.Schema == nil {
return resp, nil
}
if choice.Message.Refusal != nil && *choice.Message.Refusal != "" {
return resp, fmt.Errorf("openai: generate: model refused the structured request: %w", llm.ErrBadOutput)
}
if p.mode == PromptOnly {
resp.JSON = firstJSONObject(resp.Text)
} else if text := strings.TrimSpace(resp.Text); isJSONObject(text) {
resp.JSON = json.RawMessage(text)
}
if resp.JSON == nil {
return resp, fmt.Errorf("openai: generate: reply (finish reason %q) holds no JSON object: %w", resp.FinishReason, llm.ErrBadOutput)
}
return resp, nil
}
func appendInstruction(system string, schema json.RawMessage) string {
instruction := schemaInstruction + string(schema)
if system == "" {
return instruction
}
return system + "\n\n" + instruction
}
// isJSONObject reports whether s is exactly one JSON object.
func isJSONObject(s string) bool {
return strings.HasPrefix(s, "{") && json.Valid([]byte(s))
}
// firstJSONObject returns the first complete JSON object in s, trying each
// "{" in order, or nil when there is none. Text around it, such as prose or
// a Markdown code fence, is ignored.
func firstJSONObject(s string) json.RawMessage {
for i := strings.IndexByte(s, '{'); i >= 0; {
var obj json.RawMessage
if err := json.NewDecoder(strings.NewReader(s[i:])).Decode(&obj); err == nil {
return obj
}
next := strings.IndexByte(s[i+1:], '{')
if next < 0 {
break
}
i += 1 + next
}
return nil
}
type modelsResponse struct {
Data []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"data"`
}
// ListModels returns the models {base}/models lists.
func (p *Provider) ListModels(ctx context.Context) ([]llm.Model, error) {
raw, _, err := p.do(ctx, http.MethodGet, "/models", nil)
if err != nil {
return nil, fmt.Errorf("openai: list models: %w", err)
}
var mr modelsResponse
if err := json.Unmarshal(raw, &mr); err != nil {
return nil, fmt.Errorf("openai: list models: decode response: %w", err)
}
if mr.Data == nil {
return nil, errors.New("openai: list models: response has no data list")
}
models := make([]llm.Model, 0, len(mr.Data))
for i, m := range mr.Data {
if m.ID == "" {
return nil, fmt.Errorf("openai: list models: model %d has no id", i)
}
name := m.Name
if name == "" {
name = m.ID
}
models = append(models, llm.Model{ID: m.ID, DisplayName: name})
}
return models, nil
}
// Verify checks the credentials with an authenticated ListModels call.
func (p *Provider) Verify(ctx context.Context) error {
_, err := p.ListModels(ctx)
return err
}
func (p *Provider) secrets() []string { return []string{p.apiKey} }
// do sends one request and returns the body and status of a 2xx response.
// Any other status becomes the matching llm typed error.
func (p *Provider) do(ctx context.Context, method, path string, payload []byte) ([]byte, int, error) {
var reqBody io.Reader
if payload != nil {
reqBody = bytes.NewReader(payload)
}
httpReq, err := http.NewRequestWithContext(ctx, method, p.base+path, reqBody)
if err != nil {
return nil, 0, fmt.Errorf("build request: %w", err)
}
for name, values := range p.headers {
httpReq.Header[name] = values
}
if payload != nil {
httpReq.Header.Set("Content-Type", "application/json")
}
httpReq.Header.Set("Accept", "application/json")
if p.apiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
}
httpResp, err := p.client.Do(httpReq)
if err != nil {
return nil, 0, transportError(err)
}
defer httpResp.Body.Close()
if httpResp.StatusCode < 200 || httpResp.StatusCode > 299 {
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxErrorBody))
if err != nil {
return nil, httpResp.StatusCode, transportError(err)
}
return nil, httpResp.StatusCode, p.statusError(httpResp, body)
}
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxResponseBody+1))
if err != nil {
return nil, httpResp.StatusCode, transportError(err)
}
if len(body) > maxResponseBody {
return nil, httpResp.StatusCode, fmt.Errorf("response body exceeds %d bytes", maxResponseBody)
}
return body, httpResp.StatusCode, nil
}
// statusError maps a non-2xx response to an llm typed error.
func (p *Provider) statusError(resp *http.Response, body []byte) error {
switch resp.StatusCode {
case http.StatusUnauthorized, http.StatusForbidden:
return fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrAuth)
case http.StatusTooManyRequests:
return &llm.RateLimitedError{RetryAfter: retryAfter(resp.Header.Get("Retry-After"), time.Now())}
case http.StatusRequestTimeout:
return fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrTimeout)
default:
return llm.NewProviderError(resp.StatusCode, body, p.secrets())
}
}
// retryAfter parses a Retry-After header, in delay-seconds or HTTP-date
// form. A missing, malformed or past value means the vendor gave no usable
// wait, reported as 0.
func retryAfter(value string, now time.Time) time.Duration {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
if secs, err := strconv.Atoi(value); err == nil {
if secs < 0 {
return 0
}
return time.Duration(secs) * time.Second
}
if at, err := http.ParseTime(value); err == nil && at.After(now) {
return at.Sub(now).Round(time.Second)
}
return 0
}
func isTimeout(err error) bool {
var ne net.Error
return errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout())
}
// transportError reports a failure to complete the HTTP exchange. The
// underlying error names the URL, never the request headers.
func transportError(err error) error {
if isTimeout(err) {
return fmt.Errorf("%w: %w", llm.ErrTimeout, err)
}
return err
}
+579
View File
@@ -0,0 +1,579 @@
package openai
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"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(err.Error(), 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)
}
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 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},
"query in URL": {BaseURL: "https://example.com/v1?key=x", 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"}},
}
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,
}
for in, want := range cases {
if got := retryAfter(in, now); got != want {
t.Errorf("retryAfter(%q) = %v, want %v", in, got, want)
}
}
}
@@ -0,0 +1,18 @@
{
"id": "chatcmpl-417",
"object": "chat.completion",
"created": 1741570322,
"model": "llama3.2",
"system_fingerprint": "fp_ollama",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Sure! Here is a dinner {idea} for you:\n```json\n{\"dinners\": [{\"name\": \"Crock pot chili\", \"why\": \"A Quick dinner like your {Regular} stew.\"}]}\n```\nEnjoy {\"not\": \"this one\"}!"
},
"finish_reason": "stop"
}
],
"usage": {"prompt_tokens": 512, "completion_tokens": 58, "total_tokens": 570}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "chatcmpl-419",
"object": "chat.completion",
"created": 1741570340,
"model": "llama3.2",
"system_fingerprint": "fp_ollama",
"choices": [
{"index": 0, "message": {"role": "assistant", "content": "Try a lemon orzo bake tonight."}, "finish_reason": "stop"}
],
"usage": {"prompt_tokens": 40, "completion_tokens": 9, "total_tokens": 49}
}
@@ -0,0 +1,15 @@
{
"id": "chatcmpl-418",
"object": "chat.completion",
"created": 1741570330,
"model": "llama3.2",
"system_fingerprint": "fp_ollama",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "{\"dinners\": [{\"name\": \"Crock pot ch"},
"finish_reason": "length"
}
],
"usage": {"prompt_tokens": 512, "completion_tokens": 16, "total_tokens": 528}
}
@@ -0,0 +1 @@
{"error": {"message": "model \"llama9\" not found, try pulling it first", "type": "api_error", "param": null, "code": null}}
+7
View File
@@ -0,0 +1,7 @@
{
"object": "list",
"data": [
{"id": "llama3.2:latest", "object": "model", "created": 1741000000, "owned_by": "library"},
{"id": "qwen2.5:7b", "object": "model", "created": 1740000000, "owned_by": "library"}
]
}
@@ -0,0 +1,28 @@
{
"id": "chatcmpl-B9MHDbslfkBeAs8l4bebGdFOJ6PeG",
"object": "chat.completion",
"created": 1741570283,
"model": "gpt-4o-2024-08-06",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "{\"dinners\":[{\"name\":\"Sheet-pan gnocchi\",\"why\":\"Like your Regular tray bake, but quicker.\"}]}",
"refusal": null,
"annotations": []
},
"logprobs": null,
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 1117,
"completion_tokens": 46,
"total_tokens": 1163,
"prompt_tokens_details": {"cached_tokens": 0, "audio_tokens": 0},
"completion_tokens_details": {"reasoning_tokens": 0, "audio_tokens": 0, "accepted_prediction_tokens": 0, "rejected_prediction_tokens": 0}
},
"service_tier": "default",
"system_fingerprint": "fp_fc9f1d7035"
}
@@ -0,0 +1,16 @@
{
"id": "chatcmpl-B9MHDbslfkBeAs8l4bebGdFOJ6PeH",
"object": "chat.completion",
"created": 1741570290,
"model": "gpt-4o-2024-08-06",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": null, "refusal": "I'm sorry, I can't help with that request.", "annotations": []},
"logprobs": null,
"finish_reason": "stop"
}
],
"usage": {"prompt_tokens": 210, "completion_tokens": 11, "total_tokens": 221},
"system_fingerprint": "fp_fc9f1d7035"
}
@@ -0,0 +1,8 @@
{
"error": {
"message": "Incorrect API key provided: {{KEY}}. You can find your API key at https://platform.openai.com/account/api-keys.",
"type": "invalid_request_error",
"param": null,
"code": "invalid_api_key"
}
}
@@ -0,0 +1 @@
{"error": {"message": "The server had an error while processing your request. Authorization: Bearer {{KEY}}", "type": "server_error", "param": null, "code": null}}
+7
View File
@@ -0,0 +1,7 @@
{
"object": "list",
"data": [
{"id": "gpt-4o-2024-08-06", "object": "model", "created": 1722814719, "owned_by": "system"},
{"id": "gpt-4o-mini", "object": "model", "created": 1721172741, "owned_by": "system"}
]
}
@@ -0,0 +1,22 @@
{
"id": "gen-1741570300-xQmT9vWb4kR2nP7sL3aE",
"provider": "OpenAI",
"model": "openai/gpt-4o-mini",
"object": "chat.completion",
"created": 1741570300,
"choices": [
{
"logprobs": null,
"finish_reason": "stop",
"native_finish_reason": "stop",
"index": 0,
"message": {
"role": "assistant",
"content": "{\n \"dinners\": [{\"name\": \"Chicken tinga tacos\", \"why\": \"Close to your taco-night Regular.\"}]\n}",
"refusal": null,
"reasoning": null
}
}
],
"usage": {"prompt_tokens": 842, "completion_tokens": 31, "total_tokens": 873}
}
@@ -0,0 +1,5 @@
{
"id": "gen-1741570311-aB3dE5fG7hJ9kL1mN2pQ",
"error": {"code": 502, "message": "Upstream error from provider", "metadata": {"provider_name": "Lambda", "raw": "upstream returned 502"}},
"user_id": "user_2abcDEF"
}
@@ -0,0 +1 @@
{"error": {"code": 429, "message": "Rate limit exceeded: free-models-per-min. Key {{KEY}}", "metadata": {"headers": {"X-RateLimit-Limit": "20", "X-RateLimit-Remaining": "0"}}}}
+22
View File
@@ -0,0 +1,22 @@
{
"data": [
{
"id": "openai/gpt-4o-mini",
"canonical_slug": "openai/gpt-4o-mini",
"name": "OpenAI: GPT-4o-mini",
"created": 1721260800,
"description": "GPT-4o mini is OpenAI's newest model after GPT-4 Omni.",
"context_length": 128000,
"architecture": {"modality": "text+image->text", "input_modalities": ["text", "image"], "output_modalities": ["text"], "tokenizer": "GPT"},
"pricing": {"prompt": "0.00000015", "completion": "0.0000006"},
"supported_parameters": ["max_tokens", "temperature", "response_format", "structured_outputs"]
},
{
"id": "anthropic/claude-sonnet-5",
"name": "Anthropic: Claude Sonnet 5",
"created": 1767225600,
"context_length": 200000,
"supported_parameters": ["max_tokens", "temperature"]
}
]
}