From 6d0bea10b5e0e0abb86426ba485da87877870aa7 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 16:20:23 -0400 Subject: [PATCH] Add OpenAI-compatible llm.Provider adapter 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 --- internal/ai/llm/openai/openai.go | 415 +++++++++++++ internal/ai/llm/openai/openai_test.go | 579 ++++++++++++++++++ .../testdata/ollama_chat_prompt_only.json | 18 + .../llm/openai/testdata/ollama_chat_text.json | 11 + .../testdata/ollama_chat_truncated.json | 15 + .../testdata/ollama_error_not_found.json | 1 + .../ai/llm/openai/testdata/ollama_models.json | 7 + .../testdata/openai_chat_json_schema.json | 28 + .../openai/testdata/openai_chat_refusal.json | 16 + .../testdata/openai_error_invalid_key.json | 8 + .../openai/testdata/openai_error_server.json | 1 + .../ai/llm/openai/testdata/openai_models.json | 7 + .../testdata/openrouter_chat_json_object.json | 22 + .../openrouter_chat_upstream_error.json | 5 + .../openrouter_error_rate_limited.json | 1 + .../openai/testdata/openrouter_models.json | 22 + 16 files changed, 1156 insertions(+) create mode 100644 internal/ai/llm/openai/openai.go create mode 100644 internal/ai/llm/openai/openai_test.go create mode 100644 internal/ai/llm/openai/testdata/ollama_chat_prompt_only.json create mode 100644 internal/ai/llm/openai/testdata/ollama_chat_text.json create mode 100644 internal/ai/llm/openai/testdata/ollama_chat_truncated.json create mode 100644 internal/ai/llm/openai/testdata/ollama_error_not_found.json create mode 100644 internal/ai/llm/openai/testdata/ollama_models.json create mode 100644 internal/ai/llm/openai/testdata/openai_chat_json_schema.json create mode 100644 internal/ai/llm/openai/testdata/openai_chat_refusal.json create mode 100644 internal/ai/llm/openai/testdata/openai_error_invalid_key.json create mode 100644 internal/ai/llm/openai/testdata/openai_error_server.json create mode 100644 internal/ai/llm/openai/testdata/openai_models.json create mode 100644 internal/ai/llm/openai/testdata/openrouter_chat_json_object.json create mode 100644 internal/ai/llm/openai/testdata/openrouter_chat_upstream_error.json create mode 100644 internal/ai/llm/openai/testdata/openrouter_error_rate_limited.json create mode 100644 internal/ai/llm/openai/testdata/openrouter_models.json diff --git a/internal/ai/llm/openai/openai.go b/internal/ai/llm/openai/openai.go new file mode 100644 index 0000000..3e7c4c8 --- /dev/null +++ b/internal/ai/llm/openai/openai.go @@ -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 ". 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 +} diff --git a/internal/ai/llm/openai/openai_test.go b/internal/ai/llm/openai/openai_test.go new file mode 100644 index 0000000..2f13e5c --- /dev/null +++ b/internal/ai/llm/openai/openai_test.go @@ -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) + } + } +} diff --git a/internal/ai/llm/openai/testdata/ollama_chat_prompt_only.json b/internal/ai/llm/openai/testdata/ollama_chat_prompt_only.json new file mode 100644 index 0000000..f235cd4 --- /dev/null +++ b/internal/ai/llm/openai/testdata/ollama_chat_prompt_only.json @@ -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} +} diff --git a/internal/ai/llm/openai/testdata/ollama_chat_text.json b/internal/ai/llm/openai/testdata/ollama_chat_text.json new file mode 100644 index 0000000..cebe0de --- /dev/null +++ b/internal/ai/llm/openai/testdata/ollama_chat_text.json @@ -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} +} diff --git a/internal/ai/llm/openai/testdata/ollama_chat_truncated.json b/internal/ai/llm/openai/testdata/ollama_chat_truncated.json new file mode 100644 index 0000000..ff036f8 --- /dev/null +++ b/internal/ai/llm/openai/testdata/ollama_chat_truncated.json @@ -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} +} diff --git a/internal/ai/llm/openai/testdata/ollama_error_not_found.json b/internal/ai/llm/openai/testdata/ollama_error_not_found.json new file mode 100644 index 0000000..137e3ba --- /dev/null +++ b/internal/ai/llm/openai/testdata/ollama_error_not_found.json @@ -0,0 +1 @@ +{"error": {"message": "model \"llama9\" not found, try pulling it first", "type": "api_error", "param": null, "code": null}} diff --git a/internal/ai/llm/openai/testdata/ollama_models.json b/internal/ai/llm/openai/testdata/ollama_models.json new file mode 100644 index 0000000..2421a5e --- /dev/null +++ b/internal/ai/llm/openai/testdata/ollama_models.json @@ -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"} + ] +} diff --git a/internal/ai/llm/openai/testdata/openai_chat_json_schema.json b/internal/ai/llm/openai/testdata/openai_chat_json_schema.json new file mode 100644 index 0000000..3d596d1 --- /dev/null +++ b/internal/ai/llm/openai/testdata/openai_chat_json_schema.json @@ -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" +} diff --git a/internal/ai/llm/openai/testdata/openai_chat_refusal.json b/internal/ai/llm/openai/testdata/openai_chat_refusal.json new file mode 100644 index 0000000..d00903b --- /dev/null +++ b/internal/ai/llm/openai/testdata/openai_chat_refusal.json @@ -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" +} diff --git a/internal/ai/llm/openai/testdata/openai_error_invalid_key.json b/internal/ai/llm/openai/testdata/openai_error_invalid_key.json new file mode 100644 index 0000000..f82a0ff --- /dev/null +++ b/internal/ai/llm/openai/testdata/openai_error_invalid_key.json @@ -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" + } +} diff --git a/internal/ai/llm/openai/testdata/openai_error_server.json b/internal/ai/llm/openai/testdata/openai_error_server.json new file mode 100644 index 0000000..006fc65 --- /dev/null +++ b/internal/ai/llm/openai/testdata/openai_error_server.json @@ -0,0 +1 @@ +{"error": {"message": "The server had an error while processing your request. Authorization: Bearer {{KEY}}", "type": "server_error", "param": null, "code": null}} diff --git a/internal/ai/llm/openai/testdata/openai_models.json b/internal/ai/llm/openai/testdata/openai_models.json new file mode 100644 index 0000000..c5dda95 --- /dev/null +++ b/internal/ai/llm/openai/testdata/openai_models.json @@ -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"} + ] +} diff --git a/internal/ai/llm/openai/testdata/openrouter_chat_json_object.json b/internal/ai/llm/openai/testdata/openrouter_chat_json_object.json new file mode 100644 index 0000000..a1fddac --- /dev/null +++ b/internal/ai/llm/openai/testdata/openrouter_chat_json_object.json @@ -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} +} diff --git a/internal/ai/llm/openai/testdata/openrouter_chat_upstream_error.json b/internal/ai/llm/openai/testdata/openrouter_chat_upstream_error.json new file mode 100644 index 0000000..6167272 --- /dev/null +++ b/internal/ai/llm/openai/testdata/openrouter_chat_upstream_error.json @@ -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" +} diff --git a/internal/ai/llm/openai/testdata/openrouter_error_rate_limited.json b/internal/ai/llm/openai/testdata/openrouter_error_rate_limited.json new file mode 100644 index 0000000..bc12a1e --- /dev/null +++ b/internal/ai/llm/openai/testdata/openrouter_error_rate_limited.json @@ -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"}}}} diff --git a/internal/ai/llm/openai/testdata/openrouter_models.json b/internal/ai/llm/openai/testdata/openrouter_models.json new file mode 100644 index 0000000..741effd --- /dev/null +++ b/internal/ai/llm/openai/testdata/openrouter_models.json @@ -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"] + } + ] +}