Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b347e1c911 | ||
|
|
e105cdb9c5 | ||
|
|
9ace3aba9f | ||
|
|
32ab0c0b6d | ||
|
|
15886f2c55 | ||
|
|
acd29c8221 | ||
|
|
72042516e2 | ||
|
|
7cc96121e4 | ||
|
|
bba9033ac5 | ||
|
|
e050736178 | ||
|
|
3ba78d47bc |
@@ -330,3 +330,41 @@ func TestSeedSettingsAtBoot(t *testing.T) {
|
||||
t.Errorf("timezone after reboot = %q, want America/Denver", got.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedDefaultsAtBoot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := openStore(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := seedDefaults(ctx, db, "America/Denver"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := household.NewSettingsService(db).Get(ctx); err != nil {
|
||||
t.Errorf("household settings: %v", err)
|
||||
}
|
||||
var provider string
|
||||
if err := db.Reader.QueryRowContext(ctx, `SELECT provider FROM ai_settings`).Scan(&provider); err != nil {
|
||||
t.Errorf("AI settings: %v", err)
|
||||
} else if provider != household.SeedAIProvider {
|
||||
t.Errorf("AI provider = %q, want %q", provider, household.SeedAIProvider)
|
||||
}
|
||||
var prefs int
|
||||
if err := db.Reader.QueryRowContext(ctx, `SELECT count(*) FROM notification_prefs`).Scan(&prefs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prefs != 5 {
|
||||
t.Errorf("%d notification preferences, want 5", prefs)
|
||||
}
|
||||
|
||||
// A second boot seeds nothing new and succeeds.
|
||||
if err := seedDefaults(ctx, db, ""); err != nil {
|
||||
t.Fatalf("reboot: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
)
|
||||
|
||||
// TestNewJobRunnerRunsNoop proves the runner run builds can execute a job:
|
||||
// a noop job enqueued before Start, as at boot, succeeds with its result.
|
||||
func TestNewJobRunnerRunsNoop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := openStore(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
runner, kinds, err := newJobRunner(db, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := kinds.builtins.Noop.Enqueue(ctx, jobs.NoopPayload{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
jobsCtx, stopJobs := context.WithCancel(ctx)
|
||||
if err := runner.Start(jobsCtx); err != nil {
|
||||
stopJobs()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
stopJobs()
|
||||
runner.Wait()
|
||||
})
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
job, err := runner.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
||||
if job.Kind != jobs.KindNoop || job.Status != jobs.StatusSucceeded || string(job.Result) != `{"ok":true}` || job.Error != nil {
|
||||
t.Fatalf("noop job = %+v (result %s), want succeeded with {\"ok\":true}", job, job.Result)
|
||||
}
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("noop job never finished; last %+v", job)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
+63
-2
@@ -18,6 +18,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
@@ -27,6 +28,9 @@ import (
|
||||
// version is set at build time with -ldflags "-X main.version=<version>".
|
||||
var version = "dev"
|
||||
|
||||
// jobWorkers is the number of background jobs run at once.
|
||||
const jobWorkers = 2
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
@@ -59,10 +63,14 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
}
|
||||
}()
|
||||
|
||||
if err := seedSettings(ctx, db, cfg.DefaultTZ); err != nil {
|
||||
if err := seedDefaults(ctx, db, cfg.DefaultTZ); err != nil {
|
||||
return err
|
||||
}
|
||||
settings := household.NewSettingsService(db)
|
||||
runner, _, err := newJobRunner(db, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||
if err != nil {
|
||||
@@ -80,10 +88,22 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, runner, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start fails the jobs a previous process left running before any
|
||||
// worker runs. The workers stop, and are waited for, before the
|
||||
// database closes.
|
||||
jobsCtx, stopJobs := context.WithCancel(ctx)
|
||||
defer func() {
|
||||
stopJobs()
|
||||
runner.Wait()
|
||||
}()
|
||||
if err := runner.Start(jobsCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
srv := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: router,
|
||||
@@ -153,6 +173,47 @@ func seedSettings(ctx context.Context, db *store.Store, defaultTZ string) error
|
||||
return err
|
||||
}
|
||||
|
||||
// seedDefaults creates the household settings, AI settings and notification
|
||||
// preferences that do not exist yet. Existing rows are never changed.
|
||||
func seedDefaults(ctx context.Context, db *store.Store, defaultTZ string) error {
|
||||
if err := seedSettings(ctx, db, defaultTZ); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := household.SeedAISettings(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
return household.SeedNotificationPrefs(ctx, db)
|
||||
}
|
||||
|
||||
// jobKinds enqueues every job kind the server runs.
|
||||
type jobKinds struct {
|
||||
builtins *jobs.Builtins
|
||||
}
|
||||
|
||||
// newJobRunner returns the job runner with every job kind registered, ready
|
||||
// to Start.
|
||||
func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds, error) {
|
||||
runner, err := jobs.NewRunner(db, logger, jobWorkers)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
kinds, err := registerJobKinds(runner)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return runner, kinds, nil
|
||||
}
|
||||
|
||||
// registerJobKinds registers every job kind's handler on runner. Each
|
||||
// feature's job kinds are registered here.
|
||||
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
||||
builtins, err := jobs.RegisterBuiltins(runner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &jobKinds{builtins: builtins}, nil
|
||||
}
|
||||
|
||||
// logListening logs the startup line. Only the public URL's origin is logged
|
||||
// so no credential-bearing URL component can reach the log.
|
||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
// Package anthropic is the llm.Provider adapter for Anthropic's Messages
|
||||
// API. It calls POST {base}/v1/messages and GET {base}/v1/models over plain
|
||||
// net/http.
|
||||
//
|
||||
// The adapter never logs, and no error it returns contains the API key:
|
||||
// errors do not quote caller- or vendor-supplied text, and every exported
|
||||
// method passes its error through redact as a backstop.
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// APIVersion is the Messages API version sent as the anthropic-version
|
||||
// header.
|
||||
const APIVersion = "2023-06-01"
|
||||
|
||||
// Config configures a Provider.
|
||||
type Config struct {
|
||||
// BaseURL is the API root the /v1 endpoint paths are appended to, e.g.
|
||||
// "https://api.anthropic.com".
|
||||
BaseURL string
|
||||
// APIKey is sent as the x-api-key header. It must not be empty.
|
||||
APIKey string
|
||||
// Model is the model Generate requests. ListModels and Verify do not
|
||||
// need it.
|
||||
Model string
|
||||
// MaxTokens is the max_tokens Generate sends when a Request sets none:
|
||||
// the Messages API requires one. It must be positive.
|
||||
MaxTokens int
|
||||
}
|
||||
|
||||
// Provider is an Anthropic Messages API llm.Provider.
|
||||
type Provider struct {
|
||||
base string
|
||||
apiKey string
|
||||
model string
|
||||
maxTokens int
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
var _ llm.Provider = (*Provider)(nil)
|
||||
|
||||
// New validates cfg and returns a Provider.
|
||||
func New(cfg Config) (*Provider, error) {
|
||||
p, err := newFromConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, redact(err, cfg.APIKey)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func newFromConfig(cfg Config) (*Provider, error) {
|
||||
// No error from New quotes caller-supplied text: any of it may equal or
|
||||
// contain the API key. Base URL errors never echo the URL.
|
||||
u, err := url.Parse(cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, errors.New("anthropic: base URL does not parse")
|
||||
}
|
||||
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return nil, errors.New("anthropic: base URL must be an absolute http or https URL")
|
||||
}
|
||||
// A trailing "?" or "#" parses to an empty query or fragment, yet the
|
||||
// endpoint path appended to it would land in the query or fragment.
|
||||
if u.User != nil || strings.ContainsAny(cfg.BaseURL, "?#") {
|
||||
return nil, errors.New("anthropic: base URL must not carry credentials, a query or a fragment")
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
return nil, errors.New("anthropic: no API key configured")
|
||||
}
|
||||
if !validHeaderValue(cfg.APIKey) {
|
||||
return nil, errors.New("anthropic: API key has a control character")
|
||||
}
|
||||
if cfg.MaxTokens <= 0 {
|
||||
return nil, errors.New("anthropic: max tokens must be positive")
|
||||
}
|
||||
return &Provider{
|
||||
base: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
apiKey: cfg.APIKey,
|
||||
model: cfg.Model,
|
||||
maxTokens: cfg.MaxTokens,
|
||||
client: noRedirectClient(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validHeaderValue reports whether value is an RFC 9110 field-value: no
|
||||
// control characters other than horizontal tab, so no CR, LF or DEL.
|
||||
func validHeaderValue(value string) bool {
|
||||
for i := 0; i < len(value); i++ {
|
||||
if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// toolName is the pattern the Messages API requires of a tool name.
|
||||
var toolName = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
||||
|
||||
// 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 message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type tool struct {
|
||||
Name string `json:"name"`
|
||||
InputSchema json.RawMessage `json:"input_schema"`
|
||||
}
|
||||
|
||||
type toolChoice struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type messagesRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
System string `json:"system,omitempty"`
|
||||
Messages []message `json:"messages"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
Tools []tool `json:"tools,omitempty"`
|
||||
ToolChoice *toolChoice `json:"tool_choice,omitempty"`
|
||||
}
|
||||
|
||||
type messagesResponse struct {
|
||||
Type string `json:"type"`
|
||||
Model string `json:"model"`
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Name string `json:"name"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
} `json:"content"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
// Generate sends req to {base}/v1/messages. A Request with a Schema is sent
|
||||
// as one tool whose input_schema is the schema, with tool_choice forcing
|
||||
// it; Response.JSON is then that tool's input.
|
||||
func (p *Provider) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||
resp, err := p.generate(ctx, req)
|
||||
return resp, redact(err, p.apiKey)
|
||||
}
|
||||
|
||||
func (p *Provider) generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return llm.Response{}, fmt.Errorf("anthropic: generate: %w", err)
|
||||
}
|
||||
if p.model == "" {
|
||||
return llm.Response{}, errors.New("anthropic: generate: no model configured")
|
||||
}
|
||||
body := messagesRequest{
|
||||
Model: p.model,
|
||||
MaxTokens: p.maxTokens,
|
||||
System: req.System,
|
||||
Temperature: req.Temperature,
|
||||
}
|
||||
if req.MaxTokens > 0 {
|
||||
body.MaxTokens = req.MaxTokens
|
||||
}
|
||||
if req.Schema != nil {
|
||||
if !toolName.MatchString(req.Schema.Name) {
|
||||
return llm.Response{}, errors.New("anthropic: generate: schema name is not a valid tool name")
|
||||
}
|
||||
body.Tools = []tool{{Name: req.Schema.Name, InputSchema: req.Schema.Schema}}
|
||||
body.ToolChoice = &toolChoice{Type: "tool", Name: req.Schema.Name}
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
body.Messages = append(body.Messages, message{Role: string(m.Role), Content: m.Content})
|
||||
}
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return llm.Response{}, fmt.Errorf("anthropic: generate: encode request: %w", err)
|
||||
}
|
||||
|
||||
if req.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, req.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
raw, err := p.do(ctx, http.MethodPost, "/v1/messages", payload)
|
||||
if err != nil {
|
||||
return llm.Response{}, fmt.Errorf("anthropic: generate: %w", err)
|
||||
}
|
||||
|
||||
var mr messagesResponse
|
||||
if err := json.Unmarshal(raw, &mr); err != nil {
|
||||
return llm.Response{}, fmt.Errorf("anthropic: generate: decode response: %w", err)
|
||||
}
|
||||
if mr.Type != "message" {
|
||||
return llm.Response{}, errors.New("anthropic: generate: response is not a message")
|
||||
}
|
||||
resp := llm.Response{
|
||||
Usage: llm.Usage{InputTokens: mr.Usage.InputTokens, OutputTokens: mr.Usage.OutputTokens},
|
||||
Model: mr.Model,
|
||||
FinishReason: mr.StopReason,
|
||||
}
|
||||
var text strings.Builder
|
||||
for _, block := range mr.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
text.WriteString(block.Text)
|
||||
case "tool_use":
|
||||
if req.Schema != nil && block.Name == req.Schema.Name && resp.JSON == nil {
|
||||
resp.JSON = block.Input
|
||||
}
|
||||
}
|
||||
}
|
||||
resp.Text = text.String()
|
||||
if req.Schema == nil {
|
||||
return resp, nil
|
||||
}
|
||||
if !isJSONObject(resp.JSON) {
|
||||
resp.JSON = nil
|
||||
return resp, fmt.Errorf("anthropic: generate: reply holds no input for the forced tool: %w", llm.ErrBadOutput)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// isJSONObject reports whether raw is exactly one JSON object.
|
||||
func isJSONObject(raw json.RawMessage) bool {
|
||||
s := bytes.TrimSpace(raw)
|
||||
return bytes.HasPrefix(s, []byte("{")) && json.Valid(s)
|
||||
}
|
||||
|
||||
type modelsResponse struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
} `json:"data"`
|
||||
HasMore bool `json:"has_more"`
|
||||
LastID string `json:"last_id"`
|
||||
}
|
||||
|
||||
// modelsPageLimit is the largest page GET /v1/models serves.
|
||||
const modelsPageLimit = 1000
|
||||
|
||||
// ListModels returns every model {base}/v1/models lists, following pages.
|
||||
func (p *Provider) ListModels(ctx context.Context) ([]llm.Model, error) {
|
||||
models, err := p.listModels(ctx)
|
||||
return models, redact(err, p.apiKey)
|
||||
}
|
||||
|
||||
func (p *Provider) listModels(ctx context.Context) ([]llm.Model, error) {
|
||||
models := []llm.Model{}
|
||||
after := ""
|
||||
for {
|
||||
page, err := p.modelsPage(ctx, modelsPageLimit, after)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("anthropic: list models: %w", err)
|
||||
}
|
||||
for _, m := range page.Data {
|
||||
if m.ID == "" {
|
||||
return nil, fmt.Errorf("anthropic: list models: model %d has no id", len(models))
|
||||
}
|
||||
name := m.DisplayName
|
||||
if name == "" {
|
||||
name = m.ID
|
||||
}
|
||||
models = append(models, llm.Model{ID: m.ID, DisplayName: name})
|
||||
}
|
||||
if !page.HasMore {
|
||||
return models, nil
|
||||
}
|
||||
if page.LastID == "" || page.LastID == after {
|
||||
return nil, errors.New("anthropic: list models: response has more pages but no new cursor")
|
||||
}
|
||||
after = page.LastID
|
||||
}
|
||||
}
|
||||
|
||||
// modelsPage fetches one page of GET {base}/v1/models.
|
||||
func (p *Provider) modelsPage(ctx context.Context, limit int, after string) (modelsResponse, error) {
|
||||
q := url.Values{"limit": {strconv.Itoa(limit)}}
|
||||
if after != "" {
|
||||
q.Set("after_id", after)
|
||||
}
|
||||
raw, err := p.do(ctx, http.MethodGet, "/v1/models?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return modelsResponse{}, err
|
||||
}
|
||||
var mr modelsResponse
|
||||
if err := json.Unmarshal(raw, &mr); err != nil {
|
||||
return modelsResponse{}, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
if mr.Data == nil {
|
||||
return modelsResponse{}, errors.New("response has no data list")
|
||||
}
|
||||
return mr, nil
|
||||
}
|
||||
|
||||
// Verify checks the credentials with the cheapest authenticated call: one
|
||||
// page of GET {base}/v1/models holding at most one model.
|
||||
func (p *Provider) Verify(ctx context.Context) error {
|
||||
_, err := p.modelsPage(ctx, 1, "")
|
||||
if err != nil {
|
||||
err = fmt.Errorf("anthropic: verify: %w", err)
|
||||
}
|
||||
return redact(err, p.apiKey)
|
||||
}
|
||||
|
||||
// do sends one request and returns the body 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, 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 {
|
||||
// The parse error names the URL, whose path may hold the API key.
|
||||
return nil, errors.New("build request: request URL is invalid")
|
||||
}
|
||||
httpReq.Header.Set("x-api-key", p.apiKey)
|
||||
httpReq.Header.Set("anthropic-version", APIVersion)
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
if payload != nil {
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
httpResp, err := p.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, p.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, p.transportError(err)
|
||||
}
|
||||
return nil, p.statusError(httpResp, body)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxResponseBody+1))
|
||||
if err != nil {
|
||||
return nil, p.transportError(err)
|
||||
}
|
||||
if len(body) > maxResponseBody {
|
||||
return nil, fmt.Errorf("response body exceeds %d bytes", maxResponseBody)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// statusOverloaded is the status the Messages API returns when it is
|
||||
// temporarily overloaded.
|
||||
const statusOverloaded = 529
|
||||
|
||||
// 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, statusOverloaded:
|
||||
return llm.NewRateLimitedError(retryAfter(resp.Header.Get("Retry-After"), time.Now()), p.apiKey)
|
||||
case http.StatusRequestTimeout:
|
||||
return llm.NewTimeoutStatusError(resp.StatusCode, p.apiKey)
|
||||
default:
|
||||
return llm.NewProviderError(resp.StatusCode, body, p.apiKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.ParseInt(value, 10, 64); err == nil || errors.Is(err, strconv.ErrRange) {
|
||||
if secs < 0 {
|
||||
return 0
|
||||
}
|
||||
// time.Duration counts nanoseconds in an int64; a larger wait is
|
||||
// capped to the most whole seconds it holds instead of overflowing.
|
||||
if secs > maxRetryAfterSeconds {
|
||||
secs = maxRetryAfterSeconds
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// maxRetryAfterSeconds is the largest delay-seconds value retryAfter
|
||||
// returns unchanged: the most whole seconds a time.Duration holds.
|
||||
const maxRetryAfterSeconds = math.MaxInt64 / int64(time.Second)
|
||||
|
||||
// noRedirectClient returns an HTTP client that never follows a redirect.
|
||||
// A followed redirect would resend the credential headers, which net/http
|
||||
// strips only for its own list of sensitive headers and only across hosts;
|
||||
// instead the 3xx response is returned and becomes a provider error.
|
||||
func noRedirectClient() *http.Client {
|
||||
return &http.Client{
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func isTimeout(err error) bool {
|
||||
var ne net.Error
|
||||
return errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout())
|
||||
}
|
||||
|
||||
// transportFailure is a failed HTTP exchange. It carries the cause's text
|
||||
// with the request URL removed and the API key redacted, and unwraps only to
|
||||
// the cause's category: llm.ErrTimeout, context.Canceled or nothing.
|
||||
type transportFailure struct {
|
||||
text string
|
||||
kind error
|
||||
}
|
||||
|
||||
func (e *transportFailure) Error() string { return e.text }
|
||||
|
||||
// GoString keeps %#v to the redacted Error text instead of the fields.
|
||||
func (e *transportFailure) GoString() string { return e.text }
|
||||
|
||||
func (e *transportFailure) Unwrap() error { return e.kind }
|
||||
|
||||
// transportError reports a failure to complete the HTTP exchange. A
|
||||
// *url.Error names the request URL, and a base URL path may hold the API
|
||||
// key, so only the wrapped cause is kept, rendered with the key redacted.
|
||||
func (p *Provider) transportError(err error) error {
|
||||
var kind error
|
||||
switch {
|
||||
case isTimeout(err):
|
||||
kind = llm.ErrTimeout
|
||||
case errors.Is(err, context.Canceled):
|
||||
kind = context.Canceled
|
||||
}
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
err = ue.Err
|
||||
}
|
||||
text := "transport: " + err.Error()
|
||||
if kind == llm.ErrTimeout {
|
||||
text = fmt.Sprintf("%s: %s", llm.ErrTimeout, text)
|
||||
}
|
||||
return &transportFailure{text: llm.Redact(text, p.apiKey), kind: kind}
|
||||
}
|
||||
|
||||
// redactedError stands in for one layer of an error chain that holds the
|
||||
// API key. Its text is the layer's text with the key redacted, and Unwrap
|
||||
// returns the layer's causes passed through redact, so no error reached by
|
||||
// unwrapping has text holding the key. Is consults the original layer and
|
||||
// its chain, so sentinels still match. As does too. The llm typed errors
|
||||
// remove the key from their own complete text, fixed words included, so
|
||||
// they always match; As refuses a match whose text, or whose %#v form,
|
||||
// holds the key, so it never hands back an error that can print it.
|
||||
type redactedError struct {
|
||||
text string
|
||||
err error
|
||||
key string
|
||||
}
|
||||
|
||||
func (e *redactedError) Error() string { return e.text }
|
||||
|
||||
// GoString keeps %#v from printing the fields, which hold the key.
|
||||
func (e *redactedError) GoString() string { return e.text }
|
||||
|
||||
func (e *redactedError) Unwrap() error { return redact(errors.Unwrap(e.err), e.key) }
|
||||
|
||||
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
|
||||
|
||||
func (e *redactedError) As(target any) bool {
|
||||
if !errors.As(e.err, target) {
|
||||
return false
|
||||
}
|
||||
found := reflect.ValueOf(target).Elem()
|
||||
matched, isErr := found.Interface().(error)
|
||||
if (isErr && holdsKey(matched, e.key)) || strings.Contains(fmt.Sprintf("%#v", found.Interface()), e.key) {
|
||||
found.SetZero()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// redactedJoin is redactedError for a layer with several causes, such as
|
||||
// one built by errors.Join or by fmt.Errorf with more than one %w.
|
||||
type redactedJoin struct {
|
||||
redactedError
|
||||
}
|
||||
|
||||
func (e *redactedJoin) Unwrap() []error {
|
||||
causes := e.err.(interface{ Unwrap() []error }).Unwrap()
|
||||
safe := make([]error, len(causes))
|
||||
for i, c := range causes {
|
||||
safe[i] = redact(c, e.key)
|
||||
}
|
||||
return safe
|
||||
}
|
||||
|
||||
// redact is the backstop every exported method applies to its error: with
|
||||
// a non-empty key, it returns a stand-in whose text, and the text of every
|
||||
// error it unwraps to, has the key redacted. Every non-nil err is wrapped,
|
||||
// even one whose text lacks the key, because %#v of an unwrapped error
|
||||
// prints its struct field names, and a key may spell one.
|
||||
func redact(err error, key string) error {
|
||||
if err == nil || key == "" {
|
||||
return err
|
||||
}
|
||||
re := redactedError{text: llm.Redact(err.Error(), key), err: err, key: key}
|
||||
if _, ok := err.(interface{ Unwrap() []error }); ok {
|
||||
return &redactedJoin{re}
|
||||
}
|
||||
return &re
|
||||
}
|
||||
|
||||
// holdsKey reports whether the text of err or of any error in its chain
|
||||
// contains key.
|
||||
func holdsKey(err error, key string) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(err.Error(), key) {
|
||||
return true
|
||||
}
|
||||
switch u := err.(type) {
|
||||
case interface{ Unwrap() error }:
|
||||
return holdsKey(u.Unwrap(), key)
|
||||
case interface{ Unwrap() []error }:
|
||||
for _, c := range u.Unwrap() {
|
||||
if holdsKey(c, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+41
@@ -0,0 +1,41 @@
|
||||
# Anthropic adapter fixtures
|
||||
|
||||
These are the Messages API response bodies the contract tests in
|
||||
`anthropic_test.go` replay. They are **not live captures**. Product AI is
|
||||
tested with fakes, and live vendor calls are opt-in and never use
|
||||
credentials, so no request was sent to Anthropic to make them. Each fixture
|
||||
is a sanitized adaptation of the response shapes in Anthropic's public API
|
||||
documentation:
|
||||
|
||||
- Messages: <https://docs.anthropic.com/en/api/messages>
|
||||
- Tool use, including `tool_choice` `{type: tool}`:
|
||||
<https://docs.anthropic.com/en/docs/build-with-claude/tool-use>
|
||||
- List models: <https://docs.anthropic.com/en/api/models-list>
|
||||
- Errors: <https://docs.anthropic.com/en/api/errors>
|
||||
|
||||
Sanitizing:
|
||||
|
||||
- IDs, request IDs, timestamps and token counts are illustrative values in
|
||||
the documented format, not values from a real account.
|
||||
- Message content and tool input are replaced with MealQuest dinner text.
|
||||
- No real credential appears anywhere. `{{KEY}}` is a placeholder the tests
|
||||
replace with a fake key, to prove the adapter redacts a key echoed back in
|
||||
a vendor body.
|
||||
|
||||
Below, *not in the public example* marks fields or values that were added
|
||||
for a test case and do not come from the documented example.
|
||||
|
||||
| Fixture | Shape | Not in the public example |
|
||||
| --- | --- | --- |
|
||||
| `messages_tool_use.json` | Message with one `tool_use` block, `stop_reason: tool_use`, `usage` with cache fields. | The tool name and input. |
|
||||
| `messages_text.json` | Message with `text` blocks, `stop_reason: end_turn`. | A second text block, to test joining. |
|
||||
| `messages_max_tokens.json` | Message with a `text` block, `stop_reason: max_tokens`. | — |
|
||||
| `models_page1.json` | Models list page: `data`, `has_more: true`, `first_id`, `last_id`. | — |
|
||||
| `models_page2.json` | Last models list page, `has_more: false`. | The second model has no `display_name`, to test the fallback to `id`. |
|
||||
| `error_authentication.json` | Error, `authentication_error` (401). | The `{{KEY}}` text; added to test redaction. |
|
||||
| `error_permission.json` | Error, `permission_error` (403). | — |
|
||||
| `error_rate_limit.json` | Error, `rate_limit_error` (429). | The `{{KEY}}` text; added to test redaction. |
|
||||
| `error_overloaded.json` | Error, `overloaded_error` (529). | — |
|
||||
| `error_not_found.json` | Error, `not_found_error` (404). | — |
|
||||
| `error_invalid_request.json` | Error, `invalid_request_error` (400). | — |
|
||||
| `error_api.json` | Error, `api_error` (500). | The `x-api-key: {{KEY}}` text; added to test redaction. |
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "api_error", "message": "An unexpected error has occurred internal to Anthropic's systems. x-api-key: {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G4"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "authentication_error", "message": "invalid x-api-key: {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7Fy"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "invalid_request_error", "message": "max_tokens: Field required"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G3"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "not_found_error", "message": "model: claude-nonexistent"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G2"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G1"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "permission_error", "message": "Your API key does not have permission to use the specified resource."}, "request_id": "req_011CSHoEeqs5C35K2UUqR7Fz"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "rate_limit_error", "message": "Number of request tokens has exceeded your per-minute rate limit for key {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G0"}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "msg_01XFDUDYJgAACzvnptvVoYEL",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-5",
|
||||
"content": [
|
||||
{"type": "text", "text": "Here are some Side quests for Our kitchen"}
|
||||
],
|
||||
"stop_reason": "max_tokens",
|
||||
"stop_sequence": null,
|
||||
"usage": {"input_tokens": 472, "output_tokens": 400}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "msg_013Zva2CMHLNnXjNJJKqJ2EF",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-5",
|
||||
"content": [
|
||||
{"type": "text", "text": "Try a crock pot chili: "},
|
||||
{"type": "text", "text": "it is a Quick dinner."}
|
||||
],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": null,
|
||||
"usage": {
|
||||
"input_tokens": 2095,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
"output_tokens": 503
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"id": "msg_01Aq9w938a90dw8q",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-5",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01A09q90qw90lq917835lq9",
|
||||
"name": "side_quests",
|
||||
"input": {"dinners": [{"name": "Sheet-pan gnocchi", "why": "Like your Regular tray bake, but quicker."}]}
|
||||
}
|
||||
],
|
||||
"stop_reason": "tool_use",
|
||||
"stop_sequence": null,
|
||||
"usage": {
|
||||
"input_tokens": 472,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
"output_tokens": 61
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"type": "model",
|
||||
"id": "claude-sonnet-5",
|
||||
"display_name": "Claude Sonnet 5",
|
||||
"created_at": "2026-02-24T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"has_more": true,
|
||||
"first_id": "claude-sonnet-5",
|
||||
"last_id": "claude-sonnet-5"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"type": "model",
|
||||
"id": "claude-haiku-4-5-20251001",
|
||||
"display_name": "Claude Haiku 4.5",
|
||||
"created_at": "2025-10-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"type": "model",
|
||||
"id": "claude-opus-5-5",
|
||||
"created_at": "2026-05-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"first_id": "claude-haiku-4-5-20251001",
|
||||
"last_id": "claude-opus-5-5"
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAuth means the vendor rejected the credentials.
|
||||
ErrAuth = errors.New("llm: authentication failed")
|
||||
// ErrRateLimited means the vendor throttled the request. The concrete
|
||||
// error is a *RateLimitedError carrying the requested wait.
|
||||
ErrRateLimited = errors.New("llm: rate limited")
|
||||
// ErrTimeout means the request did not finish within its timeout.
|
||||
ErrTimeout = errors.New("llm: request timed out")
|
||||
// ErrBadOutput means the model replied but the reply is unusable, e.g.
|
||||
// structured output that does not parse.
|
||||
ErrBadOutput = errors.New("llm: unusable model output")
|
||||
// ErrProvider means the vendor returned an unexpected HTTP status. The
|
||||
// concrete error is a *ProviderError carrying the status and a redacted
|
||||
// body excerpt.
|
||||
ErrProvider = errors.New("llm: provider error")
|
||||
)
|
||||
|
||||
// RateLimitedError is a throttled request. It matches ErrRateLimited.
|
||||
// Adapters build it with NewRateLimitedError so its text never holds their
|
||||
// credential.
|
||||
type RateLimitedError struct {
|
||||
// RetryAfter is how long the vendor asked callers to wait; 0 means it
|
||||
// did not say.
|
||||
RetryAfter time.Duration
|
||||
// text is the redacted Error text set by NewRateLimitedError; "" means
|
||||
// the error was built without a credential.
|
||||
text string
|
||||
}
|
||||
|
||||
// NewRateLimitedError returns the error for a throttled request. secret is
|
||||
// the credential sent with the request, or "" when the request sent none.
|
||||
// Every occurrence of a non-empty secret is removed from the error text
|
||||
// when it is built, even where the fixed text happens to spell it, and the
|
||||
// secret itself is not kept.
|
||||
func NewRateLimitedError(retryAfter time.Duration, secret string) *RateLimitedError {
|
||||
e := &RateLimitedError{RetryAfter: retryAfter}
|
||||
e.text = Redact(e.Error(), secret)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *RateLimitedError) Error() string {
|
||||
if e.text != "" {
|
||||
return e.text
|
||||
}
|
||||
if e.RetryAfter == 0 {
|
||||
return ErrRateLimited.Error()
|
||||
}
|
||||
return fmt.Sprintf("%s: retry after %v", ErrRateLimited, e.RetryAfter)
|
||||
}
|
||||
|
||||
// GoString keeps %#v to the redacted Error text instead of the fields.
|
||||
func (e *RateLimitedError) GoString() string { return e.Error() }
|
||||
|
||||
// Is reports whether target is ErrRateLimited.
|
||||
func (e *RateLimitedError) Is(target error) bool { return target == ErrRateLimited }
|
||||
|
||||
// MaxBodyExcerpt is the longest response-body excerpt, in bytes, a
|
||||
// ProviderError keeps.
|
||||
const MaxBodyExcerpt = 512
|
||||
|
||||
// redactionMarker replaces each secret in redacted text, unless the marker
|
||||
// would itself hold the secret.
|
||||
const redactionMarker = "[REDACTED]"
|
||||
|
||||
// Redact returns s with every occurrence of a non-empty secret removed. Each
|
||||
// occurrence becomes "[REDACTED]" when the result then holds no secret;
|
||||
// otherwise, because the marker contains the secret or joins with its
|
||||
// neighbours to form it, occurrences are deleted until none remains. The
|
||||
// result never contains secret.
|
||||
func Redact(s, secret string) string {
|
||||
if secret == "" {
|
||||
return s
|
||||
}
|
||||
if out := strings.ReplaceAll(s, secret, redactionMarker); !strings.Contains(out, secret) {
|
||||
return out
|
||||
}
|
||||
for strings.Contains(s, secret) {
|
||||
s = strings.ReplaceAll(s, secret, "")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ProviderError is an unexpected HTTP status from the vendor. It matches
|
||||
// ErrProvider. Build it with NewProviderError, which redacts the body.
|
||||
type ProviderError struct {
|
||||
status int
|
||||
body string
|
||||
text string
|
||||
}
|
||||
|
||||
// NewProviderError returns the error for an unexpected HTTP status. It
|
||||
// always keeps a body excerpt of at most MaxBodyExcerpt bytes. secret is the
|
||||
// credential sent with the request, or "" when the request sent none; every
|
||||
// occurrence of a non-empty secret in body is removed by Redact before the
|
||||
// body is cut, so no part of it survives, and again after the cut, so the
|
||||
// truncation mark cannot complete it. It is removed from the complete error
|
||||
// text too, even where the fixed text happens to spell it, and the secret
|
||||
// itself is not kept.
|
||||
func NewProviderError(status int, body []byte, secret string) *ProviderError {
|
||||
s := strings.TrimSpace(Redact(string(body), secret))
|
||||
if len(s) > MaxBodyExcerpt {
|
||||
cut := MaxBodyExcerpt
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
s = Redact(s[:cut]+"…", secret)
|
||||
}
|
||||
text := fmt.Sprintf("%s: HTTP %d", ErrProvider, status)
|
||||
if s != "" {
|
||||
text += ": " + s
|
||||
}
|
||||
return &ProviderError{status: status, body: s, text: Redact(text, secret)}
|
||||
}
|
||||
|
||||
// Status returns the HTTP status code.
|
||||
func (e *ProviderError) Status() int { return e.status }
|
||||
|
||||
// Body returns the redacted, truncated response-body excerpt; it is empty
|
||||
// when the vendor sent no body.
|
||||
func (e *ProviderError) Body() string { return e.body }
|
||||
|
||||
func (e *ProviderError) Error() string { return e.text }
|
||||
|
||||
// GoString keeps %#v to the redacted Error text instead of the fields,
|
||||
// whose names could spell the credential.
|
||||
func (e *ProviderError) GoString() string { return e.text }
|
||||
|
||||
// Is reports whether target is ErrProvider.
|
||||
func (e *ProviderError) Is(target error) bool { return target == ErrProvider }
|
||||
|
||||
// TimeoutStatusError is an HTTP status, such as 408, by which the vendor
|
||||
// said the request timed out. It matches ErrTimeout, and Status keeps it
|
||||
// apart from a transport timeout, so a caller can tell the vendor answered.
|
||||
// Build it with NewTimeoutStatusError, which redacts its text.
|
||||
type TimeoutStatusError struct {
|
||||
status int
|
||||
text string
|
||||
}
|
||||
|
||||
// NewTimeoutStatusError returns the error for a timeout HTTP status.
|
||||
// secret is the credential sent with the request, or "" when the request
|
||||
// sent none; every occurrence of a non-empty secret is removed from the
|
||||
// error text, and the secret itself is not kept.
|
||||
func NewTimeoutStatusError(status int, secret string) *TimeoutStatusError {
|
||||
text := fmt.Sprintf("HTTP %d: %s", status, ErrTimeout)
|
||||
return &TimeoutStatusError{status: status, text: Redact(text, secret)}
|
||||
}
|
||||
|
||||
// Status returns the HTTP status code.
|
||||
func (e *TimeoutStatusError) Status() int { return e.status }
|
||||
|
||||
func (e *TimeoutStatusError) Error() string { return e.text }
|
||||
|
||||
// GoString keeps %#v to the redacted Error text instead of the fields.
|
||||
func (e *TimeoutStatusError) GoString() string { return e.text }
|
||||
|
||||
// Unwrap returns ErrTimeout.
|
||||
func (e *TimeoutStatusError) Unwrap() error { return ErrTimeout }
|
||||
@@ -0,0 +1,212 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSentinelErrorsMatchWhenWrapped(t *testing.T) {
|
||||
for _, sentinel := range []error{ErrAuth, ErrTimeout, ErrBadOutput} {
|
||||
wrapped := fmt.Errorf("openai: generate: %w", sentinel)
|
||||
if !errors.Is(wrapped, sentinel) {
|
||||
t.Errorf("errors.Is(%v, %v) = false", wrapped, sentinel)
|
||||
}
|
||||
for _, other := range []error{ErrAuth, ErrTimeout, ErrBadOutput, ErrRateLimited, ErrProvider} {
|
||||
if other != sentinel && errors.Is(wrapped, other) {
|
||||
t.Errorf("errors.Is(%v, %v) = true", wrapped, other)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitedError(t *testing.T) {
|
||||
err := fmt.Errorf("anthropic: %w", &RateLimitedError{RetryAfter: 30 * time.Second})
|
||||
if !errors.Is(err, ErrRateLimited) {
|
||||
t.Fatal("errors.Is(err, ErrRateLimited) = false")
|
||||
}
|
||||
if errors.Is(err, ErrProvider) || errors.Is(err, ErrAuth) {
|
||||
t.Fatal("rate-limited error matches an unrelated sentinel")
|
||||
}
|
||||
var rl *RateLimitedError
|
||||
if !errors.As(err, &rl) {
|
||||
t.Fatal("errors.As(err, *RateLimitedError) = false")
|
||||
}
|
||||
if rl.RetryAfter != 30*time.Second {
|
||||
t.Fatalf("RetryAfter = %v, want 30s", rl.RetryAfter)
|
||||
}
|
||||
if got := err.Error(); got != "anthropic: llm: rate limited: retry after 30s" {
|
||||
t.Fatalf("Error() = %q", got)
|
||||
}
|
||||
if got := (&RateLimitedError{}).Error(); got != "llm: rate limited" {
|
||||
t.Fatalf("Error() without RetryAfter = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderError(t *testing.T) {
|
||||
err := fmt.Errorf("gemini: %w", NewProviderError(503, []byte(` {"error":"overloaded"} `), "sk-test"))
|
||||
if !errors.Is(err, ErrProvider) {
|
||||
t.Fatal("errors.Is(err, ErrProvider) = false")
|
||||
}
|
||||
if errors.Is(err, ErrRateLimited) || errors.Is(err, ErrAuth) {
|
||||
t.Fatal("provider error matches an unrelated sentinel")
|
||||
}
|
||||
var pe *ProviderError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Fatal("errors.As(err, *ProviderError) = false")
|
||||
}
|
||||
if pe.Status() != 503 || pe.Body() != `{"error":"overloaded"}` {
|
||||
t.Fatalf("Status, Body = %d, %q", pe.Status(), pe.Body())
|
||||
}
|
||||
if got := err.Error(); got != `gemini: llm: provider error: HTTP 503: {"error":"overloaded"}` {
|
||||
t.Fatalf("Error() = %q", got)
|
||||
}
|
||||
if got := NewProviderError(500, nil, "sk-test").Error(); got != "llm: provider error: HTTP 500" {
|
||||
t.Fatalf("Error() without body = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderErrorRedactsSecrets(t *testing.T) {
|
||||
const key = "sk-live-0123456789abcdef"
|
||||
body := []byte(`{"error":"invalid key sk-live-0123456789abcdef","echo":"Bearer sk-live-0123456789abcdef"}`)
|
||||
pe := NewProviderError(400, body, key)
|
||||
for _, s := range []string{pe.Error(), pe.Body()} {
|
||||
if strings.Contains(s, key) {
|
||||
t.Fatalf("secret leaked: %q", s)
|
||||
}
|
||||
}
|
||||
want := `{"error":"invalid key [REDACTED]","echo":"Bearer [REDACTED]"}`
|
||||
if pe.Body() != want {
|
||||
t.Fatalf("Body() = %q, want %q", pe.Body(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderErrorRedactsBeforeTruncating(t *testing.T) {
|
||||
const key = "sk-secret-straddling-the-cut"
|
||||
// The key starts just before the excerpt limit, so truncating first
|
||||
// would leave its prefix in the excerpt.
|
||||
body := strings.Repeat("x", MaxBodyExcerpt-5) + key
|
||||
pe := NewProviderError(500, []byte(body), key)
|
||||
if strings.Contains(pe.Body(), "sk-se") {
|
||||
t.Fatalf("secret prefix leaked: %q", pe.Body())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderErrorTruncatesOnRuneBoundary(t *testing.T) {
|
||||
body := strings.Repeat("a", MaxBodyExcerpt-1) + "é" + strings.Repeat("b", 100)
|
||||
pe := NewProviderError(500, []byte(body), "sk-test")
|
||||
want := strings.Repeat("a", MaxBodyExcerpt-1) + "…"
|
||||
if pe.Body() != want {
|
||||
t.Fatalf("Body() has %d bytes, want %d", len(pe.Body()), len(want))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderErrorKeepsBoundedBodyWithoutSecret(t *testing.T) {
|
||||
body := []byte(` {"error":"model \"llama9\" not found"} `)
|
||||
pe := NewProviderError(404, body, "")
|
||||
if pe.Body() != `{"error":"model \"llama9\" not found"}` {
|
||||
t.Fatalf("Body() = %q", pe.Body())
|
||||
}
|
||||
if got := pe.Error(); got != `llm: provider error: HTTP 404: {"error":"model \"llama9\" not found"}` {
|
||||
t.Fatalf("Error() = %q", got)
|
||||
}
|
||||
long := NewProviderError(500, []byte(strings.Repeat("x", 4*MaxBodyExcerpt)), "")
|
||||
if want := strings.Repeat("x", MaxBodyExcerpt) + "…"; long.Body() != want {
|
||||
t.Fatalf("Body() has %d bytes, want %d", len(long.Body()), len(want))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactNeverLeavesSecret(t *testing.T) {
|
||||
cases := []struct{ s, secret, want string }{
|
||||
{"key sk-1 and Bearer sk-1", "sk-1", "key [REDACTED] and Bearer [REDACTED]"},
|
||||
{"no secret here", "sk-1", "no secret here"},
|
||||
{"x", "", "x"},
|
||||
// The marker holds the secret, so occurrences are deleted.
|
||||
{"rejected REDACTED", "REDACTED", "rejected "},
|
||||
{"A then A", "A", " then "},
|
||||
// The marker joins with a neighbour to form the secret.
|
||||
{"]]aa", "]a", ""},
|
||||
// The marker holds the secret and deleting once leaves a new one.
|
||||
{"DDAA", "DA", ""},
|
||||
{"aabb", "ab", "a[REDACTED]b"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := Redact(c.s, c.secret); got != c.want {
|
||||
t.Errorf("Redact(%q, %q) = %q, want %q", c.s, c.secret, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderErrorWithMarkerSecretNeverLeaksSecret(t *testing.T) {
|
||||
const key = "REDACTED"
|
||||
pe := NewProviderError(500, []byte(`{"error":"bad key REDACTED","echo":"Bearer REDACTED"}`), key)
|
||||
for _, s := range []string{pe.Error(), pe.Body()} {
|
||||
if strings.Contains(s, key) {
|
||||
t.Fatalf("secret leaked: %q", s)
|
||||
}
|
||||
}
|
||||
if want := `{"error":"bad key ","echo":"Bearer "}`; pe.Body() != want {
|
||||
t.Fatalf("Body() = %q, want %q", pe.Body(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderErrorTruncationMarkCannotCompleteSecret(t *testing.T) {
|
||||
const key = "z…"
|
||||
body := strings.Repeat("a", MaxBodyExcerpt-1) + "z" + strings.Repeat("b", 10)
|
||||
if pe := NewProviderError(500, []byte(body), key); strings.Contains(pe.Body(), key) {
|
||||
t.Fatalf("secret leaked: %q", pe.Body())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderErrorWithFixedWordSecretNeverLeaksSecret(t *testing.T) {
|
||||
const secret = "HTTP"
|
||||
pe := NewProviderError(500, []byte(`{"error":"HTTP down"}`), secret)
|
||||
if strings.Contains(pe.Error(), secret) || strings.Contains(pe.Body(), secret) {
|
||||
t.Fatalf("secret leaked: Error %q, Body %q", pe.Error(), pe.Body())
|
||||
}
|
||||
if got := NewProviderError(500, nil, secret).Error(); got != "llm: provider error: [REDACTED] 500" {
|
||||
t.Fatalf("Error() without body = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitedErrorWithFixedWordSecretNeverLeaksSecret(t *testing.T) {
|
||||
for _, secret := range []string{"rate", "retry after"} {
|
||||
for _, wait := range []time.Duration{0, 30 * time.Second} {
|
||||
rl := NewRateLimitedError(wait, secret)
|
||||
if strings.Contains(rl.Error(), secret) {
|
||||
t.Fatalf("secret %q leaked: %q", secret, rl.Error())
|
||||
}
|
||||
if rl.RetryAfter != wait || !errors.Is(rl, ErrRateLimited) {
|
||||
t.Fatalf("NewRateLimitedError(%v) = %+v", wait, rl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedErrorsDoNotKeepSecret(t *testing.T) {
|
||||
const secret = "sk-live-0123456789abcdef"
|
||||
for _, err := range []error{
|
||||
NewProviderError(500, []byte("down "+secret), secret),
|
||||
NewRateLimitedError(30*time.Second, secret),
|
||||
} {
|
||||
if got := fmt.Sprintf("%#v %+v", err, err); strings.Contains(got, secret) {
|
||||
t.Fatalf("formatting %T leaks the secret: %q", err, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeoutStatusError(t *testing.T) {
|
||||
err := fmt.Errorf("openai: %w", NewTimeoutStatusError(408, "HTTP"))
|
||||
if !errors.Is(err, ErrTimeout) || errors.Is(err, ErrProvider) {
|
||||
t.Fatalf("%v: want ErrTimeout only", err)
|
||||
}
|
||||
var ts *TimeoutStatusError
|
||||
if !errors.As(err, &ts) || ts.Status() != 408 {
|
||||
t.Fatalf("TimeoutStatusError from %v", err)
|
||||
}
|
||||
if s := ts.Error() + fmt.Sprintf("%#v", ts); strings.Contains(s, "HTTP") {
|
||||
t.Fatalf("text holds the secret: %s", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Fake is a scripted Provider for tests. Each method returns its queued
|
||||
// results in order, one per call, and records every Request Generate
|
||||
// receives. A call with nothing left in its queue fails, so an unexpected
|
||||
// call is never silently answered. It is safe for concurrent use.
|
||||
type Fake struct {
|
||||
mu sync.Mutex
|
||||
generate []GenerateResult
|
||||
models []ListModelsResult
|
||||
verify []error
|
||||
requests []Request
|
||||
}
|
||||
|
||||
var _ Provider = (*Fake)(nil)
|
||||
|
||||
// GenerateResult is one scripted Generate outcome.
|
||||
type GenerateResult struct {
|
||||
Response Response
|
||||
Err error
|
||||
}
|
||||
|
||||
// ListModelsResult is one scripted ListModels outcome.
|
||||
type ListModelsResult struct {
|
||||
Models []Model
|
||||
Err error
|
||||
}
|
||||
|
||||
// QueueGenerate appends results to the Generate script.
|
||||
func (f *Fake) QueueGenerate(results ...GenerateResult) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.generate = append(f.generate, results...)
|
||||
}
|
||||
|
||||
// QueueListModels appends results to the ListModels script.
|
||||
func (f *Fake) QueueListModels(results ...ListModelsResult) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.models = append(f.models, results...)
|
||||
}
|
||||
|
||||
// QueueVerify appends results to the Verify script; a nil entry is success.
|
||||
func (f *Fake) QueueVerify(results ...error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.verify = append(f.verify, results...)
|
||||
}
|
||||
|
||||
// Generate records req and returns the next scripted result.
|
||||
func (f *Fake) Generate(_ context.Context, req Request) (Response, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.requests = append(f.requests, cloneRequest(req))
|
||||
if len(f.generate) == 0 {
|
||||
return Response{}, fmt.Errorf("llm: fake: unscripted Generate call %d", len(f.requests))
|
||||
}
|
||||
r := f.generate[0]
|
||||
f.generate = f.generate[1:]
|
||||
return r.Response, r.Err
|
||||
}
|
||||
|
||||
// ListModels returns the next scripted result.
|
||||
func (f *Fake) ListModels(context.Context) ([]Model, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if len(f.models) == 0 {
|
||||
return nil, fmt.Errorf("llm: fake: unscripted ListModels call")
|
||||
}
|
||||
r := f.models[0]
|
||||
f.models = f.models[1:]
|
||||
return slices.Clone(r.Models), r.Err
|
||||
}
|
||||
|
||||
// Verify returns the next scripted result.
|
||||
func (f *Fake) Verify(context.Context) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if len(f.verify) == 0 {
|
||||
return fmt.Errorf("llm: fake: unscripted Verify call")
|
||||
}
|
||||
err := f.verify[0]
|
||||
f.verify = f.verify[1:]
|
||||
return err
|
||||
}
|
||||
|
||||
// Requests returns a copy of every Request Generate received, in order.
|
||||
func (f *Fake) Requests() []Request {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]Request, len(f.requests))
|
||||
for i, r := range f.requests {
|
||||
out[i] = cloneRequest(r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Remaining reports how many scripted results of each kind are unused, so a
|
||||
// test can assert its script was consumed.
|
||||
func (f *Fake) Remaining() (generate, listModels, verify int) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.generate), len(f.models), len(f.verify)
|
||||
}
|
||||
|
||||
// cloneRequest deep-copies r so a recorded Request is unaffected by later
|
||||
// changes the caller makes to its slices and pointers.
|
||||
func cloneRequest(r Request) Request {
|
||||
r.Messages = slices.Clone(r.Messages)
|
||||
if r.Temperature != nil {
|
||||
t := *r.Temperature
|
||||
r.Temperature = &t
|
||||
}
|
||||
if r.Schema != nil {
|
||||
s := JSONSchema{Name: r.Schema.Name, Schema: json.RawMessage(slices.Clone([]byte(r.Schema.Schema)))}
|
||||
r.Schema = &s
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFakeGenerateReturnsScriptInOrder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var f Fake
|
||||
f.QueueGenerate(
|
||||
GenerateResult{Response: Response{Text: "first", Model: "m1"}},
|
||||
GenerateResult{Err: &RateLimitedError{RetryAfter: time.Second}},
|
||||
)
|
||||
f.QueueGenerate(GenerateResult{Response: Response{JSON: json.RawMessage(`{"ok":true}`), FinishReason: "stop"}})
|
||||
|
||||
req := Request{Messages: []Message{{Role: RoleUser, Content: "one"}}}
|
||||
resp, err := f.Generate(ctx, req)
|
||||
if err != nil || resp.Text != "first" || resp.Model != "m1" {
|
||||
t.Fatalf("call 1 = %+v, %v", resp, err)
|
||||
}
|
||||
if _, err := f.Generate(ctx, req); !errors.Is(err, ErrRateLimited) {
|
||||
t.Fatalf("call 2 err = %v, want ErrRateLimited", err)
|
||||
}
|
||||
resp, err = f.Generate(ctx, req)
|
||||
if err != nil || string(resp.JSON) != `{"ok":true}` || resp.FinishReason != "stop" {
|
||||
t.Fatalf("call 3 = %+v, %v", resp, err)
|
||||
}
|
||||
if _, err := f.Generate(ctx, req); err == nil {
|
||||
t.Fatal("unscripted call 4 succeeded")
|
||||
}
|
||||
if g, l, v := f.Remaining(); g != 0 || l != 0 || v != 0 {
|
||||
t.Fatalf("Remaining() = %d, %d, %d", g, l, v)
|
||||
}
|
||||
if n := len(f.Requests()); n != 4 {
|
||||
t.Fatalf("recorded %d requests, want 4 (unscripted calls are recorded too)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeRecordsRequests(t *testing.T) {
|
||||
var f Fake
|
||||
f.QueueGenerate(GenerateResult{}, GenerateResult{})
|
||||
temp := 0.2
|
||||
first := Request{
|
||||
System: "be brief",
|
||||
Messages: []Message{{Role: RoleUser, Content: "plan dinners"}},
|
||||
Temperature: &temp,
|
||||
MaxTokens: 100,
|
||||
Schema: &JSONSchema{Name: "plan", Schema: json.RawMessage(`{"type":"object"}`)},
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
if _, err := f.Generate(context.Background(), first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.Generate(context.Background(), Request{System: "second"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Later changes by the caller must not alter what was recorded.
|
||||
first.Messages[0].Content = "changed"
|
||||
temp = 1
|
||||
first.Schema.Schema[2] = 'X'
|
||||
|
||||
got := f.Requests()
|
||||
if len(got) != 2 || got[1].System != "second" {
|
||||
t.Fatalf("Requests() = %+v", got)
|
||||
}
|
||||
r := got[0]
|
||||
if r.System != "be brief" || r.Messages[0].Content != "plan dinners" || *r.Temperature != 0.2 ||
|
||||
r.MaxTokens != 100 || r.Schema.Name != "plan" || string(r.Schema.Schema) != `{"type":"object"}` ||
|
||||
r.Timeout != 5*time.Second {
|
||||
t.Fatalf("recorded request = %+v", r)
|
||||
}
|
||||
|
||||
// Changing a returned copy must not alter the record either.
|
||||
got[0].Messages[0].Content = "mutated"
|
||||
if f.Requests()[0].Messages[0].Content != "plan dinners" {
|
||||
t.Fatal("Requests() returned shared state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeListModelsAndVerify(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var f Fake
|
||||
f.QueueListModels(
|
||||
ListModelsResult{Models: []Model{{ID: "a", DisplayName: "A"}, {ID: "b", DisplayName: "b"}}},
|
||||
ListModelsResult{Err: ErrAuth},
|
||||
)
|
||||
f.QueueVerify(nil, fmt.Errorf("wrapped: %w", ErrAuth))
|
||||
|
||||
models, err := f.ListModels(ctx)
|
||||
if err != nil || len(models) != 2 || models[0].ID != "a" {
|
||||
t.Fatalf("ListModels 1 = %+v, %v", models, err)
|
||||
}
|
||||
if _, err := f.ListModels(ctx); !errors.Is(err, ErrAuth) {
|
||||
t.Fatalf("ListModels 2 err = %v, want ErrAuth", err)
|
||||
}
|
||||
if _, err := f.ListModels(ctx); err == nil {
|
||||
t.Fatal("unscripted ListModels succeeded")
|
||||
}
|
||||
if err := f.Verify(ctx); err != nil {
|
||||
t.Fatalf("Verify 1 = %v", err)
|
||||
}
|
||||
if err := f.Verify(ctx); !errors.Is(err, ErrAuth) {
|
||||
t.Fatalf("Verify 2 = %v, want ErrAuth", err)
|
||||
}
|
||||
if err := f.Verify(ctx); err == nil {
|
||||
t.Fatal("unscripted Verify succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeConcurrentUse(t *testing.T) {
|
||||
const n = 50
|
||||
var f Fake
|
||||
for i := range n {
|
||||
f.QueueGenerate(GenerateResult{Response: Response{Text: fmt.Sprint(i)}})
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for range n {
|
||||
wg.Go(func() {
|
||||
if _, err := f.Generate(context.Background(), Request{}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
if len(f.Requests()) != n {
|
||||
t.Fatalf("recorded %d requests, want %d", len(f.Requests()), n)
|
||||
}
|
||||
if g, _, _ := f.Remaining(); g != 0 {
|
||||
t.Fatalf("%d Generate results unused", g)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Package llm is the single interface MealQuest uses to talk to a language
|
||||
// model. Every vendor adapter implements Provider over plain net/http (no
|
||||
// vendor SDKs) and reports failures with this package's typed errors, so
|
||||
// callers decide what to do from errors.Is and errors.As alone.
|
||||
//
|
||||
// Errors from this package and its adapters never contain an API key.
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Provider is a language-model vendor.
|
||||
type Provider interface {
|
||||
// Generate sends one request and returns the model's reply.
|
||||
Generate(ctx context.Context, req Request) (Response, error)
|
||||
// ListModels returns the models the configured credentials can use.
|
||||
ListModels(ctx context.Context) ([]Model, error)
|
||||
// Verify checks that the configured credentials are accepted.
|
||||
Verify(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Role is the author of a Message.
|
||||
type Role string
|
||||
|
||||
// The roles a Message may have. The system prompt is Request.System, not a
|
||||
// message.
|
||||
const (
|
||||
RoleUser Role = "user"
|
||||
RoleAssistant Role = "assistant"
|
||||
)
|
||||
|
||||
// Message is one turn of a conversation.
|
||||
type Message struct {
|
||||
Role Role
|
||||
Content string
|
||||
}
|
||||
|
||||
// JSONSchema is a named structured-output contract: when a Request carries
|
||||
// one, the model must reply with JSON matching Schema.
|
||||
type JSONSchema struct {
|
||||
// Name identifies the contract to the vendor, e.g. "side_quests".
|
||||
Name string
|
||||
// Schema is a JSON Schema object.
|
||||
Schema json.RawMessage
|
||||
}
|
||||
|
||||
// Request is one generation request.
|
||||
type Request struct {
|
||||
// System is the system prompt; empty means none.
|
||||
System string
|
||||
// Messages is the conversation, oldest first. It must not be empty.
|
||||
Messages []Message
|
||||
// Temperature is the sampling temperature; nil means the vendor default.
|
||||
Temperature *float64
|
||||
// MaxTokens caps the reply length; 0 means the vendor default.
|
||||
MaxTokens int
|
||||
// Schema, when set, requests structured output; the reply is then in
|
||||
// Response.JSON.
|
||||
Schema *JSONSchema
|
||||
// Timeout bounds the request; 0 means only ctx bounds it.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether r is well formed. Adapters call it before
|
||||
// contacting the vendor. Its errors never quote the request's text, such as
|
||||
// a role or schema name: any of it may equal the API key.
|
||||
func (r Request) Validate() error {
|
||||
if len(r.Messages) == 0 {
|
||||
return errors.New("llm: request has no messages")
|
||||
}
|
||||
for i, m := range r.Messages {
|
||||
if m.Role != RoleUser && m.Role != RoleAssistant {
|
||||
return fmt.Errorf("llm: message %d has an unknown role", i)
|
||||
}
|
||||
}
|
||||
if r.Temperature != nil && *r.Temperature < 0 {
|
||||
return fmt.Errorf("llm: temperature %v is negative", *r.Temperature)
|
||||
}
|
||||
if r.MaxTokens < 0 {
|
||||
return fmt.Errorf("llm: max tokens %d is negative", r.MaxTokens)
|
||||
}
|
||||
if r.Timeout < 0 {
|
||||
return fmt.Errorf("llm: timeout %v is negative", r.Timeout)
|
||||
}
|
||||
if r.Schema != nil {
|
||||
if r.Schema.Name == "" {
|
||||
return errors.New("llm: schema has no name")
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(r.Schema.Schema, &obj); err != nil || obj == nil {
|
||||
return errors.New("llm: schema is not a JSON object")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Usage is the token count of one Generate call.
|
||||
type Usage struct {
|
||||
InputTokens int
|
||||
OutputTokens int
|
||||
}
|
||||
|
||||
// Total returns InputTokens + OutputTokens.
|
||||
func (u Usage) Total() int { return u.InputTokens + u.OutputTokens }
|
||||
|
||||
// Response is the model's reply to a Request.
|
||||
type Response struct {
|
||||
// Text is the reply text.
|
||||
Text string
|
||||
// JSON is the structured reply when the Request carried a Schema, and
|
||||
// nil otherwise.
|
||||
JSON json.RawMessage
|
||||
// Usage is the tokens the call consumed.
|
||||
Usage Usage
|
||||
// Model is the model that answered, as the vendor reports it.
|
||||
Model string
|
||||
// FinishReason is why generation stopped, as the vendor reports it.
|
||||
FinishReason string
|
||||
}
|
||||
|
||||
// Model is a model a Provider offers.
|
||||
type Model struct {
|
||||
// ID is the vendor's model identifier, used in requests.
|
||||
ID string
|
||||
// DisplayName is a human-readable name; it equals ID when the vendor
|
||||
// gives none.
|
||||
DisplayName string
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRequestValidate(t *testing.T) {
|
||||
neg := -0.1
|
||||
valid := func() Request {
|
||||
return Request{
|
||||
Messages: []Message{{Role: RoleUser, Content: "hi"}, {Role: RoleAssistant, Content: "hello"}},
|
||||
Schema: &JSONSchema{Name: "plan", Schema: json.RawMessage(`{"type":"object"}`)},
|
||||
}
|
||||
}
|
||||
if err := valid().Validate(); err != nil {
|
||||
t.Fatalf("valid request: %v", err)
|
||||
}
|
||||
cases := map[string]func(*Request){
|
||||
"no messages": func(r *Request) { r.Messages = nil },
|
||||
"unknown role": func(r *Request) { r.Messages[0].Role = "system" },
|
||||
"negative temp": func(r *Request) { r.Temperature = &neg },
|
||||
"negative max": func(r *Request) { r.MaxTokens = -1 },
|
||||
"negative time": func(r *Request) { r.Timeout = -1 },
|
||||
"unnamed schema": func(r *Request) { r.Schema.Name = "" },
|
||||
"schema array": func(r *Request) { r.Schema.Schema = json.RawMessage(`[]`) },
|
||||
"schema null": func(r *Request) { r.Schema.Schema = json.RawMessage(`null`) },
|
||||
"schema invalid": func(r *Request) { r.Schema.Schema = json.RawMessage(`{`) },
|
||||
"schema empty": func(r *Request) { r.Schema.Schema = nil },
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
r := valid()
|
||||
mutate(&r)
|
||||
if err := r.Validate(); err == nil {
|
||||
t.Errorf("%s: Validate() = nil", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageTotal(t *testing.T) {
|
||||
if got := (Usage{InputTokens: 12, OutputTokens: 30}).Total(); got != 42 {
|
||||
t.Fatalf("Total() = %d, want 42", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// Package ollama is the llm.Provider adapter for Ollama's native API. It
|
||||
// calls POST {base}/api/chat and GET {base}/api/tags over plain net/http.
|
||||
//
|
||||
// Ollama takes no credentials: the adapter sends no Authorization header
|
||||
// and never logs. The base URL may be a LAN address, and errors for an
|
||||
// unreachable server name it, so New refuses a base URL that could carry a
|
||||
// credential.
|
||||
package ollama
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// Config configures a Provider.
|
||||
type Config struct {
|
||||
// BaseURL is the Ollama server root the /api endpoint paths are
|
||||
// appended to, e.g. "http://192.168.1.20:11434" or
|
||||
// "http://ollama:11434". It is required.
|
||||
BaseURL string
|
||||
// Model is the model Generate requests, e.g. "llama3.2". ListModels
|
||||
// and Verify do not need it.
|
||||
Model string
|
||||
}
|
||||
|
||||
// Provider is an Ollama native API llm.Provider.
|
||||
type Provider struct {
|
||||
base string
|
||||
model string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
var _ llm.Provider = (*Provider)(nil)
|
||||
|
||||
// New validates cfg and returns a Provider.
|
||||
func New(cfg Config) (*Provider, error) {
|
||||
// Base URL errors never echo the URL: a rejected one may hold a
|
||||
// credential.
|
||||
u, err := url.Parse(cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, errors.New("ollama: base URL does not parse")
|
||||
}
|
||||
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return nil, errors.New("ollama: base URL must be an absolute http or https URL")
|
||||
}
|
||||
// A trailing "?" or "#" parses to an empty query or fragment, yet the
|
||||
// endpoint path appended to it would land in the query or fragment.
|
||||
if u.User != nil || strings.ContainsAny(cfg.BaseURL, "?#") {
|
||||
return nil, errors.New("ollama: base URL must not carry credentials, a query or a fragment")
|
||||
}
|
||||
return &Provider{
|
||||
base: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
model: cfg.Model,
|
||||
client: noRedirectClient(),
|
||||
}, 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; only an
|
||||
// llm.MaxBodyExcerpt of it is kept.
|
||||
const maxErrorBody = 64 << 10
|
||||
|
||||
type message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type options struct {
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
NumPredict int `json:"num_predict,omitempty"`
|
||||
}
|
||||
|
||||
type chatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []message `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
Format json.RawMessage `json:"format,omitempty"`
|
||||
Options *options `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type chatResponse struct {
|
||||
Model string `json:"model"`
|
||||
Message *struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
Done bool `json:"done"`
|
||||
DoneReason string `json:"done_reason"`
|
||||
PromptEvalCount int `json:"prompt_eval_count"`
|
||||
EvalCount int `json:"eval_count"`
|
||||
}
|
||||
|
||||
// Generate sends req to {base}/api/chat without streaming. A Request with a
|
||||
// Schema sends it as "format", so Ollama constrains the reply to it;
|
||||
// Response.JSON is then the reply text, which must be a JSON object.
|
||||
func (p *Provider) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return llm.Response{}, fmt.Errorf("ollama: generate: %w", err)
|
||||
}
|
||||
if p.model == "" {
|
||||
return llm.Response{}, errors.New("ollama: generate: no model configured")
|
||||
}
|
||||
body := chatRequest{Model: p.model, Stream: false}
|
||||
if req.System != "" {
|
||||
body.Messages = append(body.Messages, message{Role: "system", Content: req.System})
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
body.Messages = append(body.Messages, message{Role: string(m.Role), Content: m.Content})
|
||||
}
|
||||
if req.Schema != nil {
|
||||
body.Format = req.Schema.Schema
|
||||
}
|
||||
if req.Temperature != nil || req.MaxTokens > 0 {
|
||||
body.Options = &options{Temperature: req.Temperature, NumPredict: req.MaxTokens}
|
||||
}
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return llm.Response{}, fmt.Errorf("ollama: generate: encode request: %w", err)
|
||||
}
|
||||
|
||||
if req.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, req.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
raw, err := p.do(ctx, http.MethodPost, "/api/chat", payload)
|
||||
if err != nil {
|
||||
var pe *llm.ProviderError
|
||||
if errors.As(err, &pe) && pe.Status() == http.StatusNotFound && p.modelNotFound(pe.Body()) {
|
||||
// The body is not quoted: the model name is all the caller
|
||||
// needs. Any other 404 keeps its body excerpt below.
|
||||
return llm.Response{}, fmt.Errorf("ollama: generate: model %q not found: %w", p.model, llm.NewProviderError(pe.Status(), nil, ""))
|
||||
}
|
||||
return llm.Response{}, fmt.Errorf("ollama: generate: %w", err)
|
||||
}
|
||||
|
||||
var cr chatResponse
|
||||
if err := json.Unmarshal(raw, &cr); err != nil {
|
||||
return llm.Response{}, fmt.Errorf("ollama: generate: decode response: %w", err)
|
||||
}
|
||||
if !cr.Done || cr.Message == nil {
|
||||
return llm.Response{}, errors.New("ollama: generate: response is not a finished message")
|
||||
}
|
||||
resp := llm.Response{
|
||||
Text: cr.Message.Content,
|
||||
Usage: llm.Usage{InputTokens: cr.PromptEvalCount, OutputTokens: cr.EvalCount},
|
||||
Model: cr.Model,
|
||||
FinishReason: cr.DoneReason,
|
||||
}
|
||||
if req.Schema == nil {
|
||||
return resp, nil
|
||||
}
|
||||
if text := strings.TrimSpace(resp.Text); isJSONObject(text) {
|
||||
resp.JSON = json.RawMessage(text)
|
||||
return resp, nil
|
||||
}
|
||||
return resp, fmt.Errorf("ollama: generate: reply is not a JSON object: %w", llm.ErrBadOutput)
|
||||
}
|
||||
|
||||
// modelNotFound reports whether body is Ollama's error for the configured
|
||||
// model not being pulled: {"error": "model \"name\" not found, ..."}, or
|
||||
// with the name in single quotes as newer servers send it. A 404 from
|
||||
// anything else, such as a proxy without /api/chat, is not.
|
||||
func (p *Provider) modelNotFound(body string) bool {
|
||||
var e struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &e); err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(e.Error, fmt.Sprintf("model %q not found", p.model)) ||
|
||||
strings.HasPrefix(e.Error, fmt.Sprintf("model '%s' not found", p.model))
|
||||
}
|
||||
|
||||
// isJSONObject reports whether s is exactly one JSON object.
|
||||
func isJSONObject(s string) bool {
|
||||
return strings.HasPrefix(s, "{") && json.Valid([]byte(s))
|
||||
}
|
||||
|
||||
type tagsResponse struct {
|
||||
Models []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"models"`
|
||||
}
|
||||
|
||||
// ListModels returns every model {base}/api/tags lists.
|
||||
func (p *Provider) ListModels(ctx context.Context) ([]llm.Model, error) {
|
||||
models, err := p.tags(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ollama: list models: %w", err)
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// Verify checks that the server answers GET {base}/api/tags with a model
|
||||
// list.
|
||||
func (p *Provider) Verify(ctx context.Context) error {
|
||||
if _, err := p.tags(ctx); err != nil {
|
||||
return fmt.Errorf("ollama: verify: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) tags(ctx context.Context) ([]llm.Model, error) {
|
||||
raw, err := p.do(ctx, http.MethodGet, "/api/tags", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tr tagsResponse
|
||||
if err := json.Unmarshal(raw, &tr); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
if tr.Models == nil {
|
||||
return nil, errors.New("response has no models list")
|
||||
}
|
||||
models := make([]llm.Model, 0, len(tr.Models))
|
||||
for i, m := range tr.Models {
|
||||
if m.Name == "" {
|
||||
return nil, fmt.Errorf("model %d has no name", i)
|
||||
}
|
||||
models = append(models, llm.Model{ID: m.Name, DisplayName: m.Name})
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// do sends one request and returns the body of a 2xx response. Any other
|
||||
// status, a 3xx included, is an *llm.ProviderError.
|
||||
func (p *Provider) do(ctx context.Context, method, path string, payload []byte) ([]byte, 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, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
if payload != nil {
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
httpResp, err := p.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, p.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, p.transportError(err)
|
||||
}
|
||||
return nil, llm.NewProviderError(httpResp.StatusCode, body, "")
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxResponseBody+1))
|
||||
if err != nil {
|
||||
return nil, p.transportError(err)
|
||||
}
|
||||
if len(body) > maxResponseBody {
|
||||
return nil, fmt.Errorf("response body exceeds %d bytes", maxResponseBody)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// noRedirectClient returns an HTTP client that never follows a redirect:
|
||||
// the 3xx response is returned and becomes a provider error, so a request
|
||||
// is only ever sent to the configured server.
|
||||
func noRedirectClient() *http.Client {
|
||||
return &http.Client{
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func isTimeout(err error) bool {
|
||||
var ne net.Error
|
||||
return errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout())
|
||||
}
|
||||
|
||||
// transportFailure is a failed HTTP exchange. Its text names the base URL
|
||||
// and the cause; it unwraps only to the cause's category, llm.ErrTimeout or
|
||||
// context.Canceled.
|
||||
type transportFailure struct {
|
||||
text string
|
||||
kind error
|
||||
}
|
||||
|
||||
func (e *transportFailure) Error() string { return e.text }
|
||||
|
||||
func (e *transportFailure) Unwrap() error { return e.kind }
|
||||
|
||||
// transportError reports a failure to complete the HTTP exchange. A
|
||||
// cancelled context is the caller's choice and stays context.Canceled.
|
||||
// Every other failure, a refused connection or a timeout alike, means the
|
||||
// server could not be reached in time: it matches llm.ErrTimeout and says
|
||||
// "Ollama not reachable at {base}".
|
||||
func (p *Provider) transportError(err error) error {
|
||||
timedOut := isTimeout(err)
|
||||
// The *url.Error repeats the request URL; the base is named once below.
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
err = ue.Err
|
||||
}
|
||||
if !timedOut && errors.Is(err, context.Canceled) {
|
||||
return &transportFailure{text: "request canceled: " + err.Error(), kind: context.Canceled}
|
||||
}
|
||||
text := fmt.Sprintf("Ollama not reachable at %s: %s", p.base, err)
|
||||
if timedOut {
|
||||
text = fmt.Sprintf("Ollama not reachable at %s: %s: %s", p.base, llm.ErrTimeout, err)
|
||||
}
|
||||
return &transportFailure{text: text, kind: llm.ErrTimeout}
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
package ollama
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
// recorded is one request the test server received.
|
||||
type recorded struct {
|
||||
method string
|
||||
path string
|
||||
query url.Values
|
||||
header http.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
// reply is a response the test server replays.
|
||||
type reply struct {
|
||||
status int
|
||||
fixture string // testdata file
|
||||
body string // used when fixture is empty
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
// ollamaServer replays its replies in order, repeating the last, and
|
||||
// records every request.
|
||||
type ollamaServer struct {
|
||||
*httptest.Server
|
||||
mu sync.Mutex
|
||||
got []recorded
|
||||
}
|
||||
|
||||
func newOllamaServer(t *testing.T, replies ...reply) *ollamaServer {
|
||||
t.Helper()
|
||||
payloads := make([][]byte, len(replies))
|
||||
for i, r := range replies {
|
||||
payloads[i] = []byte(r.body)
|
||||
if r.fixture != "" {
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", r.fixture))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payloads[i] = raw
|
||||
}
|
||||
}
|
||||
s := &ollamaServer{}
|
||||
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
s.mu.Lock()
|
||||
n := len(s.got)
|
||||
s.got = append(s.got, recorded{req.Method, req.URL.Path, req.URL.Query(), req.Header.Clone(), body})
|
||||
s.mu.Unlock()
|
||||
i := min(n, len(replies)-1)
|
||||
r := replies[i]
|
||||
if r.delay > 0 {
|
||||
select {
|
||||
case <-time.After(r.delay):
|
||||
case <-req.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(r.status)
|
||||
w.Write(payloads[i])
|
||||
}))
|
||||
t.Cleanup(s.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ollamaServer) requests() []recorded {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]recorded(nil), s.got...)
|
||||
}
|
||||
|
||||
func (s *ollamaServer) only(t *testing.T) recorded {
|
||||
t.Helper()
|
||||
got := s.requests()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("server got %d requests, want 1", len(got))
|
||||
}
|
||||
return got[0]
|
||||
}
|
||||
|
||||
func newProvider(t *testing.T, base string) *Provider {
|
||||
t.Helper()
|
||||
p, err := New(Config{BaseURL: base, Model: "llama3.2"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
var dinnersSchema = json.RawMessage(`{"type":"object","properties":{"dinners":{"type":"array"}},"required":["dinners"]}`)
|
||||
|
||||
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."},
|
||||
{Role: llm.RoleAssistant, Content: "Something quick?"},
|
||||
{Role: llm.RoleUser, Content: "Yes, a Quick dinner."},
|
||||
},
|
||||
Temperature: &temp,
|
||||
MaxTokens: 400,
|
||||
Schema: &llm.JSONSchema{Name: "side_quests", Schema: dinnersSchema},
|
||||
}
|
||||
}
|
||||
|
||||
func textRequest() llm.Request {
|
||||
return llm.Request{Messages: []llm.Message{{Role: llm.RoleUser, Content: "What should we cook tonight?"}}}
|
||||
}
|
||||
|
||||
// sentBody decodes a 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
|
||||
}
|
||||
|
||||
// canonical re-encodes JSON so two encodings of one value compare equal.
|
||||
func canonical(t *testing.T, raw []byte) string {
|
||||
t.Helper()
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
t.Fatalf("JSON %q: %v", raw, err)
|
||||
}
|
||||
out, _ := json.Marshal(v)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func assertNoAuth(t *testing.T, r recorded) {
|
||||
t.Helper()
|
||||
if got := r.header.Values("Authorization"); len(got) != 0 {
|
||||
t.Errorf("Authorization = %q, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateText(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 200, fixture: "chat_text.json"})
|
||||
p := newProvider(t, s.URL+"/")
|
||||
|
||||
resp, err := p.Generate(context.Background(), textRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Text != "Try a crock pot chili: it is a Quick dinner." || resp.JSON != nil {
|
||||
t.Errorf("Text, JSON = %q, %s", resp.Text, resp.JSON)
|
||||
}
|
||||
if resp.Usage != (llm.Usage{InputTokens: 26, OutputTokens: 298}) || resp.Model != "llama3.2" || resp.FinishReason != "stop" {
|
||||
t.Errorf("Usage, Model, FinishReason = %+v, %q, %q", resp.Usage, resp.Model, resp.FinishReason)
|
||||
}
|
||||
|
||||
got := s.only(t)
|
||||
if got.method != http.MethodPost || got.path != "/api/chat" || len(got.query) != 0 {
|
||||
t.Errorf("request = %s %s?%s", got.method, got.path, got.query.Encode())
|
||||
}
|
||||
assertNoAuth(t, got)
|
||||
body := sentBody(t, got)
|
||||
if stream, ok := body["stream"]; !ok || stream != false {
|
||||
t.Errorf("stream = %v (present %v), want false", stream, ok)
|
||||
}
|
||||
if body["model"] != "llama3.2" {
|
||||
t.Errorf("model = %v", body["model"])
|
||||
}
|
||||
for _, field := range []string{"format", "options"} {
|
||||
if v, ok := body[field]; ok {
|
||||
t.Errorf("%s = %v, want it omitted", field, v)
|
||||
}
|
||||
}
|
||||
if got, want := canonical(t, mustMarshal(t, body["messages"])), `[{"content":"What should we cook tonight?","role":"user"}]`; got != want {
|
||||
t.Errorf("messages = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func mustMarshal(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestGenerateSchemaOutput(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 200, fixture: "chat_schema.json"})
|
||||
p := newProvider(t, s.URL)
|
||||
|
||||
resp, err := p.Generate(context.Background(), schemaRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `{"dinners":[{"name":"Sheet-pan gnocchi","why":"Like your Regular tray bake, but quicker."}]}`
|
||||
if got := canonical(t, resp.JSON); got != want {
|
||||
t.Errorf("Response.JSON = %s, want %s", got, want)
|
||||
}
|
||||
if canonical(t, []byte(resp.Text)) != want {
|
||||
t.Errorf("Text = %q, want the reply content", resp.Text)
|
||||
}
|
||||
if resp.Usage != (llm.Usage{InputTokens: 147, OutputTokens: 41}) || resp.FinishReason != "stop" {
|
||||
t.Errorf("Usage, FinishReason = %+v, %q", resp.Usage, resp.FinishReason)
|
||||
}
|
||||
|
||||
got := s.only(t)
|
||||
assertNoAuth(t, got)
|
||||
body := sentBody(t, got)
|
||||
if body["stream"] != false {
|
||||
t.Errorf("stream = %v, want false", body["stream"])
|
||||
}
|
||||
if got, want := canonical(t, mustMarshal(t, body["format"])), canonical(t, dinnersSchema); got != want {
|
||||
t.Errorf("format = %s, want the schema %s", got, want)
|
||||
}
|
||||
opts, _ := body["options"].(map[string]any)
|
||||
if len(opts) != 2 || opts["temperature"] != 0.0 || opts["num_predict"] != 400.0 {
|
||||
t.Errorf("options = %v, want temperature 0 and num_predict 400", body["options"])
|
||||
}
|
||||
wantMessages := `[{"content":"You plan dinners for Our kitchen.","role":"system"},` +
|
||||
`{"content":"Suggest a Side quest.","role":"user"},` +
|
||||
`{"content":"Something quick?","role":"assistant"},` +
|
||||
`{"content":"Yes, a Quick dinner.","role":"user"}]`
|
||||
if got := canonical(t, mustMarshal(t, body["messages"])); got != wantMessages {
|
||||
t.Errorf("messages = %s, want %s", got, wantMessages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTemperatureOnlyWhenSet(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 200, fixture: "chat_text.json"})
|
||||
req := textRequest()
|
||||
temp := 0.7
|
||||
req.Temperature = &temp
|
||||
if _, err := newProvider(t, s.URL).Generate(context.Background(), req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, _ := sentBody(t, s.only(t))["options"].(map[string]any)
|
||||
if len(opts) != 1 || opts["temperature"] != 0.7 {
|
||||
t.Errorf("options = %v, want only temperature 0.7", opts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateBadOutput(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"prose": `{"model":"m","message":{"role":"assistant","content":"Sure! Try chili."},"done":true,"done_reason":"stop"}`,
|
||||
"array": `{"model":"m","message":{"role":"assistant","content":"[1,2]"},"done":true,"done_reason":"stop"}`,
|
||||
"cut-off JSON": `{"model":"m","message":{"role":"assistant","content":"{\"dinners\": ["},"done":true,"done_reason":"length"}`,
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 200, body: body})
|
||||
resp, err := newProvider(t, s.URL).Generate(context.Background(), schemaRequest())
|
||||
if !errors.Is(err, llm.ErrBadOutput) {
|
||||
t.Fatalf("err = %v, want ErrBadOutput", err)
|
||||
}
|
||||
if resp.JSON != nil || resp.Model != "m" || resp.Text == "" {
|
||||
t.Fatalf("Response = %+v, want no JSON and the reply kept", resp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsMalformedResponses(t *testing.T) {
|
||||
for name, body := range map[string]string{
|
||||
"not JSON": `not json`,
|
||||
"not finished": `{"model":"m","message":{"role":"assistant","content":"hi"},"done":false}`,
|
||||
"no message": `{"model":"m","done":true}`,
|
||||
} {
|
||||
s := newOllamaServer(t, reply{status: 200, body: body})
|
||||
_, err := newProvider(t, s.URL).Generate(context.Background(), textRequest())
|
||||
if err == nil || errors.Is(err, llm.ErrBadOutput) || errors.Is(err, llm.ErrProvider) {
|
||||
t.Errorf("%s: err = %v, want an untyped malformed-response error", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsInvalidRequests(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 200, fixture: "chat_text.json"})
|
||||
if _, err := newProvider(t, s.URL).Generate(context.Background(), llm.Request{}); err == nil {
|
||||
t.Error("Generate accepted a request with no messages")
|
||||
}
|
||||
noModel, err := New(Config{BaseURL: s.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := noModel.Generate(context.Background(), textRequest()); err == nil {
|
||||
t.Error("Generate ran with no model configured")
|
||||
}
|
||||
if n := len(s.requests()); n != 0 {
|
||||
t.Errorf("server got %d requests, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateModelMissing(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 404, fixture: "error_model_not_found.json"})
|
||||
p, err := New(Config{BaseURL: s.URL, Model: "llama9"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = p.Generate(context.Background(), textRequest())
|
||||
var pe *llm.ProviderError
|
||||
if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != http.StatusNotFound {
|
||||
t.Fatalf("err = %v, want a ProviderError for HTTP 404", err)
|
||||
}
|
||||
if errors.Is(err, llm.ErrTimeout) || errors.Is(err, llm.ErrBadOutput) {
|
||||
t.Fatalf("err = %v matches an unrelated sentinel", err)
|
||||
}
|
||||
if want := `ollama: generate: model "llama9" not found: llm: provider error: HTTP 404`; err.Error() != want {
|
||||
t.Fatalf("Error() = %q, want %q", err, want)
|
||||
}
|
||||
if pe.Body() != "" {
|
||||
t.Fatalf("Body() = %q, want the response body left out", pe.Body())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateNotFoundUnrelatedToModel(t *testing.T) {
|
||||
for name, body := range map[string]string{
|
||||
"proxy page": "404 page not found",
|
||||
"other error": `{"error":"no route for /api/chat"}`,
|
||||
"other model": `{"error":"model \"llama3\" not found, try pulling it first"}`,
|
||||
"empty": "",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 404, body: body})
|
||||
p, err := New(Config{BaseURL: s.URL, Model: "llama9"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = p.Generate(context.Background(), textRequest())
|
||||
var pe *llm.ProviderError
|
||||
if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != http.StatusNotFound {
|
||||
t.Fatalf("err = %v, want a ProviderError for HTTP 404", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "not found") && !strings.Contains(body, "not found") {
|
||||
t.Fatalf("Error() = %q claims something was not found", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "llama9") {
|
||||
t.Fatalf("Error() = %q claims the model was not found", err)
|
||||
}
|
||||
if pe.Body() != strings.TrimSpace(body) {
|
||||
t.Fatalf("Body() = %q, want the response body %q", pe.Body(), body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateModelMissingSingleQuoted(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 404, body: `{"error":"model 'llama9' not found"}`})
|
||||
p, err := New(Config{BaseURL: s.URL, Model: "llama9"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = p.Generate(context.Background(), textRequest())
|
||||
if want := `ollama: generate: model "llama9" not found: llm: provider error: HTTP 404`; err == nil || err.Error() != want {
|
||||
t.Fatalf("err = %v, want %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListModels(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 200, fixture: "tags.json"})
|
||||
models, err := newProvider(t, s.URL).ListModels(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []llm.Model{{ID: "llama3.2:latest", DisplayName: "llama3.2:latest"}, {ID: "qwen3:8b", DisplayName: "qwen3:8b"}}
|
||||
if len(models) != len(want) || models[0] != want[0] || models[1] != want[1] {
|
||||
t.Fatalf("ListModels() = %+v, want %+v", models, want)
|
||||
}
|
||||
got := s.only(t)
|
||||
if got.method != http.MethodGet || got.path != "/api/tags" || len(got.query) != 0 {
|
||||
t.Errorf("request = %s %s?%s", got.method, got.path, got.query.Encode())
|
||||
}
|
||||
assertNoAuth(t, got)
|
||||
|
||||
empty := newOllamaServer(t, reply{status: 200, body: `{"models":[]}`})
|
||||
if models, err := newProvider(t, empty.URL).ListModels(context.Background()); err != nil || models == nil || len(models) != 0 {
|
||||
t.Fatalf("empty list: %v, %v", models, err)
|
||||
}
|
||||
for name, body := range map[string]string{
|
||||
"no models list": `{}`,
|
||||
"unnamed model": `{"models":[{"model":"x"}]}`,
|
||||
"not JSON": `<html>`,
|
||||
} {
|
||||
bad := newOllamaServer(t, reply{status: 200, body: body})
|
||||
if _, err := newProvider(t, bad.URL).ListModels(context.Background()); err == nil {
|
||||
t.Errorf("%s: ListModels accepted it", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify(t *testing.T) {
|
||||
ok := newOllamaServer(t, reply{status: 200, fixture: "tags.json"})
|
||||
if err := newProvider(t, ok.URL).Verify(context.Background()); err != nil {
|
||||
t.Fatalf("Verify() = %v", err)
|
||||
}
|
||||
got := ok.only(t)
|
||||
if got.method != http.MethodGet || got.path != "/api/tags" {
|
||||
t.Errorf("Verify called %s %s", got.method, got.path)
|
||||
}
|
||||
assertNoAuth(t, got)
|
||||
|
||||
bad := newOllamaServer(t, reply{status: 500, body: `{"error":"something went wrong"}`})
|
||||
err := newProvider(t, bad.URL).Verify(context.Background())
|
||||
var pe *llm.ProviderError
|
||||
if !errors.As(err, &pe) || pe.Status() != 500 || !strings.Contains(err.Error(), "something went wrong") {
|
||||
t.Fatalf("Verify() = %v, want a ProviderError for HTTP 500", err)
|
||||
}
|
||||
malformed := newOllamaServer(t, reply{status: 200, body: `{}`})
|
||||
if err := newProvider(t, malformed.URL).Verify(context.Background()); err == nil {
|
||||
t.Error("Verify accepted a body with no models list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderErrors(t *testing.T) {
|
||||
cases := map[string]reply{
|
||||
"400": {status: 400, body: `{"error":"invalid format"}`},
|
||||
"500": {status: 500, body: `{"error":"llama runner process has terminated"}`},
|
||||
"503": {status: 503, body: `{"error":"server busy, please try again"}`},
|
||||
"huge": {status: 500, body: strings.Repeat("x", 1<<20)},
|
||||
}
|
||||
for name, r := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
s := newOllamaServer(t, r)
|
||||
p := newProvider(t, s.URL)
|
||||
_, genErr := p.Generate(context.Background(), schemaRequest())
|
||||
_, listErr := p.ListModels(context.Background())
|
||||
for _, err := range []error{genErr, listErr} {
|
||||
var pe *llm.ProviderError
|
||||
if !errors.As(err, &pe) || pe.Status() != r.status {
|
||||
t.Fatalf("err = %v, want a ProviderError for HTTP %d", err, r.status)
|
||||
}
|
||||
if errors.Is(err, llm.ErrTimeout) || strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("err = %v, want only a provider error", err)
|
||||
}
|
||||
if len(err.Error()) > llm.MaxBodyExcerpt+200 {
|
||||
t.Fatalf("error text has %d bytes, want a bounded excerpt", len(err.Error()))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertUnreachable checks the error a request to an unreachable base
|
||||
// returns: it matches llm.ErrTimeout and names the base.
|
||||
func assertUnreachable(t *testing.T, err error, base string) {
|
||||
t.Helper()
|
||||
if !errors.Is(err, llm.ErrTimeout) {
|
||||
t.Fatalf("err = %v, want ErrTimeout", err)
|
||||
}
|
||||
if errors.Is(err, llm.ErrProvider) || errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err = %v matches an unrelated sentinel", err)
|
||||
}
|
||||
if want := "Ollama not reachable at " + base; !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("Error() = %q, want it to contain %q", err, want)
|
||||
}
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
t.Fatalf("error chain exposes the url.Error: %v", ue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreachableHost(t *testing.T) {
|
||||
closed := httptest.NewServer(http.NotFoundHandler())
|
||||
base := closed.URL
|
||||
closed.Close()
|
||||
|
||||
p := newProvider(t, base+"/")
|
||||
_, genErr := p.Generate(context.Background(), schemaRequest())
|
||||
_, listErr := p.ListModels(context.Background())
|
||||
verifyErr := p.Verify(context.Background())
|
||||
for _, err := range []error{genErr, listErr, verifyErr} {
|
||||
assertUnreachable(t, err, base)
|
||||
if !strings.Contains(err.Error(), "connection refused") {
|
||||
t.Errorf("Error() = %q, want the cause", err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
_, err := newProvider(t, dropped.URL).Generate(context.Background(), textRequest())
|
||||
assertUnreachable(t, err, dropped.URL)
|
||||
}
|
||||
|
||||
func TestTimeouts(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 200, fixture: "chat_schema.json", delay: 5 * time.Second})
|
||||
p := newProvider(t, s.URL)
|
||||
|
||||
req := schemaRequest()
|
||||
req.Timeout = 50 * time.Millisecond
|
||||
start := time.Now()
|
||||
_, err := p.Generate(context.Background(), req)
|
||||
assertUnreachable(t, err, s.URL)
|
||||
if !strings.Contains(err.Error(), llm.ErrTimeout.Error()) {
|
||||
t.Errorf("Error() = %q, want it to say the request timed out", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Fatalf("Request.Timeout not honoured: took %v", elapsed)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err = p.ListModels(ctx)
|
||||
assertUnreachable(t, err, s.URL)
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
assertUnreachable(t, p.Verify(ctx), s.URL)
|
||||
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err = p.Generate(ctx, schemaRequest())
|
||||
if !errors.Is(err, context.Canceled) || errors.Is(err, llm.ErrTimeout) || strings.Contains(err.Error(), "not reachable") {
|
||||
t.Fatalf("cancelled context: err = %v, want context.Canceled only", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectIsNotFollowed(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var secondCalls int
|
||||
second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
mu.Lock()
|
||||
secondCalls++
|
||||
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 := newProvider(t, first.URL)
|
||||
|
||||
_, genErr := p.Generate(context.Background(), textRequest())
|
||||
verifyErr := p.Verify(context.Background())
|
||||
for _, err := range []error{genErr, verifyErr} {
|
||||
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 {
|
||||
t.Fatalf("redirect target got %d requests, want 0", secondCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsBadConfig(t *testing.T) {
|
||||
const secret = "hunter2-secret"
|
||||
cases := map[string]string{
|
||||
"empty base": "",
|
||||
"relative base": "ollama:11434",
|
||||
"host only": "192.168.1.20",
|
||||
"ftp base": "ftp://192.168.1.20:11434",
|
||||
"no host": "http://",
|
||||
"credentials in URL": "http://user:" + secret + "@192.168.1.20:11434",
|
||||
"user in URL": "http://" + secret + "@ollama:11434",
|
||||
"secret in query": "http://ollama:11434/?token=" + secret,
|
||||
"secret in fragment": "http://ollama:11434/#" + secret,
|
||||
"unparseable": "http://ollama:11434/%zz?token=" + secret,
|
||||
"empty query": "http://ollama:11434?",
|
||||
"empty fragment": "http://ollama:11434#",
|
||||
"empty both": "http://ollama:11434/?#",
|
||||
}
|
||||
for name, base := range cases {
|
||||
_, err := New(Config{BaseURL: base, Model: "llama3.2"})
|
||||
if err == nil {
|
||||
t.Errorf("%s: New() = nil error", name)
|
||||
continue
|
||||
}
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Errorf("%s: error echoes the URL: %v", name, err)
|
||||
}
|
||||
}
|
||||
for _, base := range []string{
|
||||
"http://192.168.1.20:11434",
|
||||
"http://ollama:11434",
|
||||
"http://localhost:11434/",
|
||||
"https://ollama.lan/proxy/",
|
||||
} {
|
||||
if _, err := New(Config{BaseURL: base, Model: "llama3.2"}); err != nil {
|
||||
t.Errorf("New(%q) = %v", base, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWithPath(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 200, fixture: "tags.json"})
|
||||
if err := newProvider(t, s.URL+"/proxy/").Verify(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.only(t).path; got != "/proxy/api/tags" {
|
||||
t.Fatalf("path = %s, want /proxy/api/tags", got)
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Ollama adapter fixtures
|
||||
|
||||
These are the native Ollama API response bodies the contract tests in
|
||||
`ollama_test.go` replay. They are **not live captures**. Product AI is tested
|
||||
with fakes and live vendor calls are opt-in, so no request was sent to an
|
||||
Ollama server to make them. Each fixture is a sanitized adaptation of the
|
||||
response shapes in Ollama's public API documentation
|
||||
(<https://github.com/ollama/ollama/blob/main/docs/api.md>):
|
||||
|
||||
- Chat, non-streaming: `POST /api/chat` with `"stream": false`.
|
||||
- Structured outputs: `POST /api/chat` with a JSON schema in `"format"`.
|
||||
- List local models: `GET /api/tags`.
|
||||
- Errors: a JSON `{"error": "..."}` body with a non-2xx status; a model
|
||||
that is not pulled is HTTP 404.
|
||||
|
||||
Sanitizing:
|
||||
|
||||
- Timestamps, durations, sizes, digests and token counts are illustrative
|
||||
values in the documented format, not values from a real server.
|
||||
- Message content is replaced with MealQuest dinner text.
|
||||
- Ollama takes no credentials, so none appear.
|
||||
|
||||
Below, *not in the public example* marks values that were added for a test
|
||||
case and do not come from the documented example.
|
||||
|
||||
| Fixture | Case | Shape | Not in the public example |
|
||||
| --- | --- | --- | --- |
|
||||
| `chat_text.json` | Success | Finished chat message, `done_reason: stop`, `prompt_eval_count` and `eval_count`. | — |
|
||||
| `chat_schema.json` | Schema output | Finished chat message whose content is a JSON object string. | The dinner object. |
|
||||
| `error_model_not_found.json` | Model missing | `{"error": "model \"…\" not found, try pulling it first"}`, served with HTTP 404. | — |
|
||||
| `tags.json` | Model list | `{"models": [...]}` with `name`, `model`, `digest` and `details`. | — |
|
||||
|
||||
The unreachable-host case has no fixture: there is no response to record.
|
||||
The tests close an `httptest` server and dial its address, so the
|
||||
connection is refused, and they hold a reply past a short deadline for the
|
||||
timeout.
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"model": "llama3.2",
|
||||
"created_at": "2026-10-04T12:20:11.009132Z",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "{\"dinners\": [{\"name\": \"Sheet-pan gnocchi\", \"why\": \"Like your Regular tray bake, but quicker.\"}]}"
|
||||
},
|
||||
"done_reason": "stop",
|
||||
"done": true,
|
||||
"total_duration": 1416823291,
|
||||
"load_duration": 12309083,
|
||||
"prompt_eval_count": 147,
|
||||
"prompt_eval_duration": 271802000,
|
||||
"eval_count": 41,
|
||||
"eval_duration": 1130948000
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"model": "llama3.2",
|
||||
"created_at": "2026-10-04T12:13:43.416799Z",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Try a crock pot chili: it is a Quick dinner."
|
||||
},
|
||||
"done": true,
|
||||
"done_reason": "stop",
|
||||
"total_duration": 5191566416,
|
||||
"load_duration": 2154458,
|
||||
"prompt_eval_count": 26,
|
||||
"prompt_eval_duration": 383809000,
|
||||
"eval_count": 298,
|
||||
"eval_duration": 4799921000
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"error":"model \"llama9\" not found, try pulling it first"}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"name": "llama3.2:latest",
|
||||
"model": "llama3.2:latest",
|
||||
"modified_at": "2026-09-30T08:06:48.639712648-07:00",
|
||||
"size": 2019393189,
|
||||
"digest": "a80c4f17acd55265feec403c7aef86be0c25983ab279d83f3bcd3abbcb5b8b72",
|
||||
"details": {
|
||||
"parent_model": "",
|
||||
"format": "gguf",
|
||||
"family": "llama",
|
||||
"families": ["llama"],
|
||||
"parameter_size": "3.2B",
|
||||
"quantization_level": "Q4_K_M"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "qwen3:8b",
|
||||
"model": "qwen3:8b",
|
||||
"modified_at": "2026-09-28T19:41:02.112408113-07:00",
|
||||
"size": 5225388164,
|
||||
"digest": "500a1f067a9f782620b40bee6f7b0c89e17ae61f686b92c24933e4ca4b2b8b41",
|
||||
"details": {
|
||||
"parent_model": "",
|
||||
"format": "gguf",
|
||||
"family": "qwen3",
|
||||
"families": ["qwen3"],
|
||||
"parameter_size": "8.2B",
|
||||
"quantization_level": "Q4_K_M"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
// 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: errors do not quote caller- or
|
||||
// vendor-supplied text, and every exported method passes its error through
|
||||
// redact as a backstop.
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"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.
|
||||
// It must not have a control character other than tab.
|
||||
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. An Accept header replaces the default "application/json".
|
||||
// Headers the adapter or net/http set themselves (see managedHeaders)
|
||||
// are rejected.
|
||||
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) {
|
||||
p, err := newFromConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, redact(err, cfg.APIKey)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func newFromConfig(cfg Config) (*Provider, error) {
|
||||
// Base URL errors never echo the URL: a rejected one may carry a
|
||||
// credential.
|
||||
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, errors.New("openai: base URL must be an absolute http or https URL")
|
||||
}
|
||||
// A trailing "?" or "#" parses to an empty query or fragment, yet the
|
||||
// endpoint path appended to it would land in the query or fragment.
|
||||
if u.User != nil || strings.ContainsAny(cfg.BaseURL, "?#") {
|
||||
return nil, errors.New("openai: base URL must not carry credentials, a query or a fragment")
|
||||
}
|
||||
if !validHeaderValue(cfg.APIKey) {
|
||||
return nil, errors.New("openai: API key has a control character")
|
||||
}
|
||||
// No error from New quotes caller-supplied text or a name derived from
|
||||
// it: any such text may equal or contain the API key, and redaction
|
||||
// cannot catch a fragment or a case-changed copy of it.
|
||||
switch cfg.StructuredOutput {
|
||||
case JSONSchema, JSONObject, PromptOnly:
|
||||
default:
|
||||
return nil, errors.New("openai: unknown structured output mode")
|
||||
}
|
||||
headers := make(http.Header, len(cfg.Headers)+1)
|
||||
for name, value := range cfg.Headers {
|
||||
if !validHeaderName(name) {
|
||||
return nil, errors.New("openai: an extra header name is not a valid HTTP token")
|
||||
}
|
||||
canonical := textproto.CanonicalMIMEHeaderKey(name)
|
||||
if managedHeaders[canonical] {
|
||||
return nil, errors.New("openai: an extra header is set by the adapter")
|
||||
}
|
||||
if !validHeaderValue(value) {
|
||||
return nil, errors.New("openai: an extra header value has a control character")
|
||||
}
|
||||
headers.Set(canonical, value)
|
||||
}
|
||||
if headers.Get("Accept") == "" {
|
||||
headers.Set("Accept", "application/json")
|
||||
}
|
||||
return &Provider{
|
||||
base: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
apiKey: cfg.APIKey,
|
||||
model: cfg.Model,
|
||||
headers: headers,
|
||||
mode: cfg.StructuredOutput,
|
||||
client: noRedirectClient(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// managedHeaders are the request headers the adapter or net/http set; a
|
||||
// configured value for one would be overwritten or ignored.
|
||||
var managedHeaders = map[string]bool{
|
||||
"Authorization": true,
|
||||
"Content-Type": true,
|
||||
"Content-Length": true,
|
||||
"Transfer-Encoding": true,
|
||||
"Host": true,
|
||||
"Connection": true,
|
||||
}
|
||||
|
||||
// validHeaderName reports whether name is an RFC 9110 field-name: a
|
||||
// non-empty token of letters, digits and !#$%&'*+-.^_`|~.
|
||||
func validHeaderName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(name); i++ {
|
||||
c := name[i]
|
||||
switch {
|
||||
case 'a' <= c && c <= 'z', 'A' <= c && c <= 'Z', '0' <= c && c <= '9':
|
||||
case strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// validHeaderValue reports whether value is an RFC 9110 field-value: no
|
||||
// control characters other than horizontal tab, so no CR, LF or DEL.
|
||||
func validHeaderValue(value string) bool {
|
||||
for i := 0; i < len(value); i++ {
|
||||
if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 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) {
|
||||
resp, err := p.generate(ctx, req)
|
||||
return resp, redact(err, p.apiKey)
|
||||
}
|
||||
|
||||
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.apiKey))
|
||||
}
|
||||
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 holds no JSON object: %w", 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) {
|
||||
models, err := p.listModels(ctx)
|
||||
return models, redact(err, p.apiKey)
|
||||
}
|
||||
|
||||
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 redact(err, 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 {
|
||||
// The parse error names the URL, whose path may hold the API key.
|
||||
return nil, 0, errors.New("build request: request URL is invalid")
|
||||
}
|
||||
for name, values := range p.headers {
|
||||
httpReq.Header[name] = values
|
||||
}
|
||||
if payload != nil {
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if p.apiKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
}
|
||||
|
||||
httpResp, err := p.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, 0, p.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, p.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, p.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.NewRateLimitedError(retryAfter(resp.Header.Get("Retry-After"), time.Now()), p.apiKey)
|
||||
case http.StatusRequestTimeout:
|
||||
return llm.NewTimeoutStatusError(resp.StatusCode, p.apiKey)
|
||||
default:
|
||||
return llm.NewProviderError(resp.StatusCode, body, p.apiKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.ParseInt(value, 10, 64); err == nil || errors.Is(err, strconv.ErrRange) {
|
||||
if secs < 0 {
|
||||
return 0
|
||||
}
|
||||
// time.Duration counts nanoseconds in an int64; a larger wait is
|
||||
// capped to the most whole seconds it holds instead of overflowing.
|
||||
if secs > maxRetryAfterSeconds {
|
||||
secs = maxRetryAfterSeconds
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// maxRetryAfterSeconds is the largest delay-seconds value retryAfter
|
||||
// returns unchanged: the most whole seconds a time.Duration holds.
|
||||
const maxRetryAfterSeconds = math.MaxInt64 / int64(time.Second)
|
||||
|
||||
// noRedirectClient returns an HTTP client that never follows a redirect.
|
||||
// A followed redirect would resend the credential headers, which net/http
|
||||
// strips only for its own list of sensitive headers and only across hosts;
|
||||
// instead the 3xx response is returned and becomes a provider error.
|
||||
func noRedirectClient() *http.Client {
|
||||
return &http.Client{
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func isTimeout(err error) bool {
|
||||
var ne net.Error
|
||||
return errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout())
|
||||
}
|
||||
|
||||
// transportFailure is a failed HTTP exchange. It carries the cause's text
|
||||
// with the request URL removed and the API key redacted, and unwraps only to
|
||||
// the cause's category: llm.ErrTimeout, context.Canceled or nothing.
|
||||
type transportFailure struct {
|
||||
text string
|
||||
kind error
|
||||
}
|
||||
|
||||
func (e *transportFailure) Error() string { return e.text }
|
||||
|
||||
// GoString keeps %#v to the redacted Error text instead of the fields.
|
||||
func (e *transportFailure) GoString() string { return e.text }
|
||||
|
||||
func (e *transportFailure) Unwrap() error { return e.kind }
|
||||
|
||||
// transportError reports a failure to complete the HTTP exchange. A
|
||||
// *url.Error names the request URL, and a base URL path may hold the API
|
||||
// key, so only the wrapped cause is kept, rendered with the key redacted.
|
||||
func (p *Provider) transportError(err error) error {
|
||||
var kind error
|
||||
switch {
|
||||
case isTimeout(err):
|
||||
kind = llm.ErrTimeout
|
||||
case errors.Is(err, context.Canceled):
|
||||
kind = context.Canceled
|
||||
}
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
err = ue.Err
|
||||
}
|
||||
text := "transport: " + err.Error()
|
||||
if kind == llm.ErrTimeout {
|
||||
text = fmt.Sprintf("%s: %s", llm.ErrTimeout, text)
|
||||
}
|
||||
return &transportFailure{text: llm.Redact(text, p.apiKey), kind: kind}
|
||||
}
|
||||
|
||||
// redactedError stands in for one layer of an error chain that holds the
|
||||
// API key. Its text is the layer's text with the key redacted, and Unwrap
|
||||
// returns the layer's causes passed through redact, so no error reached by
|
||||
// unwrapping has text holding the key. Is consults the original layer and
|
||||
// its chain, so sentinels still match. As does too. The llm typed errors
|
||||
// remove the key from their own complete text, fixed words included, so
|
||||
// they always match; As refuses a match whose text, or whose %#v form,
|
||||
// holds the key, so it never hands back an error that can print it.
|
||||
type redactedError struct {
|
||||
text string
|
||||
err error
|
||||
key string
|
||||
}
|
||||
|
||||
func (e *redactedError) Error() string { return e.text }
|
||||
|
||||
// GoString keeps %#v from printing the fields, which hold the key.
|
||||
func (e *redactedError) GoString() string { return e.text }
|
||||
|
||||
func (e *redactedError) Unwrap() error { return redact(errors.Unwrap(e.err), e.key) }
|
||||
|
||||
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
|
||||
|
||||
func (e *redactedError) As(target any) bool {
|
||||
if !errors.As(e.err, target) {
|
||||
return false
|
||||
}
|
||||
found := reflect.ValueOf(target).Elem()
|
||||
matched, isErr := found.Interface().(error)
|
||||
if (isErr && holdsKey(matched, e.key)) || strings.Contains(fmt.Sprintf("%#v", found.Interface()), e.key) {
|
||||
found.SetZero()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// redactedJoin is redactedError for a layer with several causes, such as
|
||||
// one built by errors.Join or by fmt.Errorf with more than one %w.
|
||||
type redactedJoin struct {
|
||||
redactedError
|
||||
}
|
||||
|
||||
func (e *redactedJoin) Unwrap() []error {
|
||||
causes := e.err.(interface{ Unwrap() []error }).Unwrap()
|
||||
safe := make([]error, len(causes))
|
||||
for i, c := range causes {
|
||||
safe[i] = redact(c, e.key)
|
||||
}
|
||||
return safe
|
||||
}
|
||||
|
||||
// redact is the backstop every exported method applies to its error: with
|
||||
// a non-empty key, it returns a stand-in whose text, and the text of every
|
||||
// error it unwraps to, has the key redacted. Every non-nil err is wrapped,
|
||||
// even one whose text lacks the key, because %#v of an unwrapped error
|
||||
// prints its struct field names, and a key may spell one.
|
||||
func redact(err error, key string) error {
|
||||
if err == nil || key == "" {
|
||||
return err
|
||||
}
|
||||
re := redactedError{text: llm.Redact(err.Error(), key), err: err, key: key}
|
||||
if _, ok := err.(interface{ Unwrap() []error }); ok {
|
||||
return &redactedJoin{re}
|
||||
}
|
||||
return &re
|
||||
}
|
||||
|
||||
// holdsKey reports whether the text of err or of any error in its chain
|
||||
// contains key.
|
||||
func holdsKey(err error, key string) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(err.Error(), key) {
|
||||
return true
|
||||
}
|
||||
switch u := err.(type) {
|
||||
case interface{ Unwrap() error }:
|
||||
return holdsKey(u.Unwrap(), key)
|
||||
case interface{ Unwrap() []error }:
|
||||
for _, c := range u.Unwrap() {
|
||||
if holdsKey(c, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
||||
# OpenAI-compatible adapter fixtures
|
||||
|
||||
These are the vendor response bodies the contract tests in `openai_test.go`
|
||||
replay. They are **not live captures**. Product AI is tested with fakes, and
|
||||
live vendor calls are opt-in and never use credentials, so no request was
|
||||
sent to a vendor to make them. Each fixture is adapted from the response
|
||||
shape in that vendor's public API documentation or open source, then
|
||||
sanitized:
|
||||
|
||||
- IDs, timestamps, fingerprints and token counts are illustrative values in
|
||||
the documented format, not values from a real account.
|
||||
- Message content is replaced with MealQuest dinner text.
|
||||
- No real credential appears anywhere. `{{KEY}}` is a placeholder the tests
|
||||
replace with a fake key, to prove the adapter redacts a key echoed back in
|
||||
a vendor body.
|
||||
|
||||
The tests check the adapter against these bodies with independent
|
||||
assertions on both the request sent (path, headers, `response_format`,
|
||||
messages) and the response decoded. The request side does not depend on the
|
||||
fixtures.
|
||||
|
||||
Below, *not in the public example* marks fields or values that were added
|
||||
for a test case and do not come from the vendor's documented example.
|
||||
|
||||
## OpenAI
|
||||
|
||||
Sources: API reference, "The chat completion object" and "List models"
|
||||
(<https://platform.openai.com/docs/api-reference/chat/object>,
|
||||
<https://platform.openai.com/docs/api-reference/models/list>); the
|
||||
Structured Outputs guide, for `refusal`
|
||||
(<https://platform.openai.com/docs/guides/structured-outputs>); and the error
|
||||
codes guide (<https://platform.openai.com/docs/guides/error-codes>).
|
||||
|
||||
| Fixture | Shape | Not in the public example |
|
||||
| --- | --- | --- |
|
||||
| `openai_chat_json_schema.json` | Chat completion object with `usage` details, `service_tier`, `system_fingerprint`. | The JSON reply content. |
|
||||
| `openai_chat_refusal.json` | Structured Outputs refusal: `content: null`, `refusal` set. | — |
|
||||
| `openai_models.json` | `{object: list, data: [model]}`. | — |
|
||||
| `openai_error_invalid_key.json` | Error object, `code: invalid_api_key`, message echoing the key. | The full key in the message; OpenAI masks most of it. Kept whole to test redaction. |
|
||||
| `openai_error_server.json` | Error object, `type: server_error`. | The `Authorization: Bearer {{KEY}}` text; added to test redaction. |
|
||||
|
||||
## OpenRouter
|
||||
|
||||
Sources: API reference, chat completion response and errors
|
||||
(<https://openrouter.ai/docs/api-reference/overview>,
|
||||
<https://openrouter.ai/docs/api-reference/errors>), and "List available
|
||||
models" (<https://openrouter.ai/docs/api-reference/list-available-models>).
|
||||
|
||||
| Fixture | Shape | Not in the public example |
|
||||
| --- | --- | --- |
|
||||
| `openrouter_chat_json_object.json` | Chat completion with `provider`, `native_finish_reason` and `message.reasoning`. | The JSON reply content. |
|
||||
| `openrouter_chat_upstream_error.json` | An `error` object (`code`, `message`, `metadata.provider_name`, `metadata.raw`) in an HTTP 200 body, which OpenRouter documents for upstream failures. | `user_id`. |
|
||||
| `openrouter_error_rate_limited.json` | Error object, `code: 429`, rate-limit `metadata`. | The `Key {{KEY}}` text; added to test redaction. |
|
||||
| `openrouter_models.json` | `{data: [model]}` with `name`, `context_length`, `architecture`, `pricing`, `supported_parameters`. | The second model is a minimal entry, to test optional fields. |
|
||||
|
||||
## Ollama
|
||||
|
||||
Ollama's OpenAI compatibility page
|
||||
(<https://github.com/ollama/ollama/blob/main/docs/openai.md>) documents
|
||||
requests but shows no response bodies. The shapes come from its `openai`
|
||||
middleware source (<https://github.com/ollama/ollama/blob/main/openai/openai.go>):
|
||||
`chatcmpl-<n>` IDs, `system_fingerprint: fp_ollama`, `owned_by: library` on
|
||||
models, and errors as `{error: {message, type: api_error, param, code}}`.
|
||||
|
||||
| Fixture | Shape | Not in the public example |
|
||||
| --- | --- | --- |
|
||||
| `ollama_chat_text.json` | Plain chat completion. | — |
|
||||
| `ollama_chat_prompt_only.json` | Chat completion. | The reply text: prose and stray braces around a fenced JSON object, to test prompt-only extraction. |
|
||||
| `ollama_chat_truncated.json` | Chat completion with `finish_reason: length`. | The cut-off JSON reply. |
|
||||
| `ollama_models.json` | `{object: list, data: [model]}`. | — |
|
||||
| `ollama_error_not_found.json` | Middleware error object for a missing model. | — |
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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"}}}}
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// Package presets holds what MealQuest knows about each language-model
|
||||
// vendor — base URL, adapter, credentials, headers, structured output mode
|
||||
// and model quirks — and FromSettings, which turns the household's
|
||||
// llm.Settings into a ready llm.Provider.
|
||||
//
|
||||
// FromSettings lives here rather than in package llm because it builds the
|
||||
// adapters, and every adapter imports llm.
|
||||
//
|
||||
// Nothing in this package logs, and no error it returns or unwraps to
|
||||
// contains the API key, even where the key equals a setting's or request's
|
||||
// value or text the error spells anyway.
|
||||
package presets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"slices"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/anthropic"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/ollama"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/openai"
|
||||
)
|
||||
|
||||
// Adapter names the llm adapter package a preset builds.
|
||||
type Adapter string
|
||||
|
||||
// The adapters.
|
||||
const (
|
||||
AdapterAnthropic Adapter = "anthropic"
|
||||
AdapterOpenAI Adapter = "openai"
|
||||
AdapterOllama Adapter = "ollama"
|
||||
)
|
||||
|
||||
// KeyPolicy is whether a preset sends an API key.
|
||||
type KeyPolicy int
|
||||
|
||||
// The key policies.
|
||||
const (
|
||||
// KeyRequired presets fail without a key.
|
||||
KeyRequired KeyPolicy = iota
|
||||
// KeyOptional presets send a key when one is given.
|
||||
KeyOptional
|
||||
// KeyNone presets send no key and refuse one.
|
||||
KeyNone
|
||||
)
|
||||
|
||||
// The reasoning models that reject the temperature field are OpenAI's o1
|
||||
// and o3 families: the bare family name or the name followed by a "-"
|
||||
// variant (o1, o1-mini, o3-2025-04-16), never a longer word such as o123.
|
||||
// OpenRouter names them with an "openai/" prefix and may add a ":" variant
|
||||
// (openai/o1:online). A preset whose OmitTemperature holds a pattern never
|
||||
// sends a temperature to a matching model.
|
||||
var (
|
||||
openAIReasoningModels = regexp.MustCompile(`^o[13](-.*)?$`)
|
||||
openRouterReasoningModels = regexp.MustCompile(`^openai/o[13]([-:].*)?$`)
|
||||
)
|
||||
|
||||
// Preset is one vendor's fixed configuration.
|
||||
type Preset struct {
|
||||
// Name is the Settings.Provider value that selects the preset.
|
||||
Name string
|
||||
// Adapter is the adapter the preset builds.
|
||||
Adapter Adapter
|
||||
// BaseURL is the API root the adapter appends its endpoint paths to.
|
||||
// Empty means the household must supply it in Settings.BaseURL.
|
||||
BaseURL string
|
||||
// Key is whether the preset sends an API key.
|
||||
Key KeyPolicy
|
||||
// RequiresPublicURL means the preset needs Settings.PublicURL for its
|
||||
// headers.
|
||||
RequiresPublicURL bool
|
||||
// Headers returns the extra request headers an openai-adapter preset
|
||||
// sends; nil means none.
|
||||
Headers func(llm.Settings) map[string]string
|
||||
// StructuredOutput is the default mode of an openai-adapter preset.
|
||||
StructuredOutput openai.StructuredOutput
|
||||
// FallbackStructuredOutput is the mode the household can select when
|
||||
// the default fails for its model; empty means none. FromSettings
|
||||
// builds one mode and never retries a request in another.
|
||||
FallbackStructuredOutput openai.StructuredOutput
|
||||
// StructuredOutputs are the modes Settings.StructuredOutput may select.
|
||||
StructuredOutputs []openai.StructuredOutput
|
||||
// MaxTokens is the max_tokens the anthropic adapter sends when a
|
||||
// Request sets none.
|
||||
MaxTokens int
|
||||
// OmitTemperature are model-id patterns the preset never sends a
|
||||
// temperature to.
|
||||
OmitTemperature []*regexp.Regexp
|
||||
}
|
||||
|
||||
// openRouterHeaders identify MealQuest to OpenRouter: HTTP-Referer is
|
||||
// MEALQUEST_PUBLIC_URL and X-Title is the app name.
|
||||
func openRouterHeaders(s llm.Settings) map[string]string {
|
||||
return map[string]string{
|
||||
"HTTP-Referer": s.PublicURL,
|
||||
"X-Title": "MealQuest",
|
||||
}
|
||||
}
|
||||
|
||||
// omitsTemperature reports whether model matches one of the preset's
|
||||
// OmitTemperature patterns.
|
||||
func (p Preset) omitsTemperature(model string) bool {
|
||||
return slices.ContainsFunc(p.OmitTemperature, func(re *regexp.Regexp) bool {
|
||||
return re.MatchString(model)
|
||||
})
|
||||
}
|
||||
|
||||
// Lookup returns the preset Settings.Provider name selects.
|
||||
func Lookup(name string) (Preset, bool) {
|
||||
p, ok := all[name]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// all is every preset, by Settings.Provider name.
|
||||
var all = map[string]Preset{
|
||||
"anthropic": {
|
||||
Name: "anthropic",
|
||||
Adapter: AdapterAnthropic,
|
||||
BaseURL: "https://api.anthropic.com",
|
||||
Key: KeyRequired,
|
||||
MaxTokens: 4096,
|
||||
},
|
||||
"openai": {
|
||||
Name: "openai",
|
||||
Adapter: AdapterOpenAI,
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Key: KeyRequired,
|
||||
StructuredOutput: openai.JSONSchema,
|
||||
StructuredOutputs: []openai.StructuredOutput{openai.JSONSchema},
|
||||
OmitTemperature: []*regexp.Regexp{openAIReasoningModels},
|
||||
},
|
||||
"openrouter": {
|
||||
Name: "openrouter",
|
||||
Adapter: AdapterOpenAI,
|
||||
BaseURL: "https://openrouter.ai/api/v1",
|
||||
Key: KeyRequired,
|
||||
RequiresPublicURL: true,
|
||||
Headers: openRouterHeaders,
|
||||
StructuredOutput: openai.JSONSchema,
|
||||
FallbackStructuredOutput: openai.JSONObject,
|
||||
StructuredOutputs: []openai.StructuredOutput{openai.JSONSchema, openai.JSONObject},
|
||||
OmitTemperature: []*regexp.Regexp{openRouterReasoningModels},
|
||||
},
|
||||
"xai": {
|
||||
Name: "xai",
|
||||
Adapter: AdapterOpenAI,
|
||||
BaseURL: "https://api.x.ai/v1",
|
||||
Key: KeyRequired,
|
||||
StructuredOutput: openai.JSONSchema,
|
||||
FallbackStructuredOutput: openai.JSONObject,
|
||||
StructuredOutputs: []openai.StructuredOutput{openai.JSONSchema, openai.JSONObject},
|
||||
},
|
||||
"ollama": {
|
||||
Name: "ollama",
|
||||
Adapter: AdapterOllama,
|
||||
Key: KeyNone,
|
||||
},
|
||||
"openai_compatible": {
|
||||
Name: "openai_compatible",
|
||||
Adapter: AdapterOpenAI,
|
||||
Key: KeyOptional,
|
||||
StructuredOutput: openai.JSONSchema,
|
||||
StructuredOutputs: []openai.StructuredOutput{openai.JSONSchema, openai.JSONObject, openai.PromptOnly},
|
||||
// The server may be OpenAI itself or a gateway that names models
|
||||
// the OpenRouter way.
|
||||
OmitTemperature: []*regexp.Regexp{openAIReasoningModels, openRouterReasoningModels},
|
||||
},
|
||||
}
|
||||
|
||||
// FromSettings returns the Provider s describes, using key as its API key.
|
||||
// It fails with a configuration error from package llm — ErrUnknownProvider,
|
||||
// ErrMissingKey, ErrUnexpectedKey, ErrInvalidKey, ErrMissingBaseURL, ErrMissingModel,
|
||||
// ErrMissingPublicURL or ErrInvalidSetting — before contacting any vendor.
|
||||
//
|
||||
// The Provider sends s.Temperature with every request whose Temperature is
|
||||
// nil; a request that sets one keeps it. Neither is sent to a model the
|
||||
// preset's OmitTemperature matches. It is wrapped in llm.NewRetrying, so
|
||||
// Generate retries transient vendor failures and has the default timeout.
|
||||
func FromSettings(s llm.Settings, key string) (llm.Provider, error) {
|
||||
cp, err := fromSettings(s, key)
|
||||
if err != nil {
|
||||
return nil, redact(err, key)
|
||||
}
|
||||
return llm.NewRetrying(cp), nil
|
||||
}
|
||||
|
||||
func fromSettings(s llm.Settings, key string) (*configured, error) {
|
||||
p, ok := Lookup(s.Provider)
|
||||
if !ok {
|
||||
return nil, llm.ErrUnknownProvider
|
||||
}
|
||||
if err := check(p, s, key); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", p.Name, err)
|
||||
}
|
||||
base := p.BaseURL
|
||||
if base == "" {
|
||||
base = s.BaseURL
|
||||
}
|
||||
|
||||
var inner llm.Provider
|
||||
var err error
|
||||
switch p.Adapter {
|
||||
case AdapterAnthropic:
|
||||
inner, err = anthropic.New(anthropic.Config{BaseURL: base, APIKey: key, Model: s.Model, MaxTokens: p.MaxTokens})
|
||||
case AdapterOllama:
|
||||
inner, err = ollama.New(ollama.Config{BaseURL: base, Model: s.Model})
|
||||
case AdapterOpenAI:
|
||||
mode := p.StructuredOutput
|
||||
if s.StructuredOutput != "" {
|
||||
mode = openai.StructuredOutput(s.StructuredOutput)
|
||||
}
|
||||
cfg := openai.Config{BaseURL: base, APIKey: key, Model: s.Model, StructuredOutput: mode}
|
||||
if p.Headers != nil {
|
||||
cfg.Headers = p.Headers(s)
|
||||
}
|
||||
inner, err = openai.New(cfg)
|
||||
default:
|
||||
panic("presets: preset " + p.Name + " has unknown adapter " + string(p.Adapter))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w: %w", p.Name, llm.ErrInvalidSetting, err)
|
||||
}
|
||||
|
||||
cp := &configured{Provider: inner, key: key}
|
||||
if !p.omitsTemperature(s.Model) {
|
||||
t := s.Temperature
|
||||
cp.temperature = &t
|
||||
}
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
// check validates s and key against p. Its errors never quote a value.
|
||||
func check(p Preset, s llm.Settings, key string) error {
|
||||
switch {
|
||||
case p.Key == KeyRequired && key == "":
|
||||
return llm.ErrMissingKey
|
||||
case p.Key == KeyNone && key != "":
|
||||
return llm.ErrUnexpectedKey
|
||||
case !validHeaderValue(key):
|
||||
return llm.ErrInvalidKey
|
||||
}
|
||||
switch {
|
||||
case p.BaseURL == "" && s.BaseURL == "":
|
||||
return llm.ErrMissingBaseURL
|
||||
case p.BaseURL != "" && s.BaseURL != "" && s.BaseURL != p.BaseURL:
|
||||
return fmt.Errorf("%w: base URL is fixed by the preset", llm.ErrInvalidSetting)
|
||||
}
|
||||
if s.Model == "" {
|
||||
return llm.ErrMissingModel
|
||||
}
|
||||
if p.RequiresPublicURL && s.PublicURL == "" {
|
||||
return llm.ErrMissingPublicURL
|
||||
}
|
||||
if s.StructuredOutput != "" && !slices.Contains(p.StructuredOutputs, openai.StructuredOutput(s.StructuredOutput)) {
|
||||
return fmt.Errorf("%w: structured output mode is not supported by the preset", llm.ErrInvalidSetting)
|
||||
}
|
||||
if math.IsNaN(s.Temperature) || math.IsInf(s.Temperature, 0) || s.Temperature < 0 {
|
||||
return fmt.Errorf("%w: temperature must be a non-negative number", llm.ErrInvalidSetting)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validHeaderValue reports whether value has no control character other
|
||||
// than tab, so it can be sent as an HTTP header value.
|
||||
func validHeaderValue(value string) bool {
|
||||
for i := 0; i < len(value); i++ {
|
||||
if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// configured applies the household temperature and the preset's
|
||||
// temperature quirk to every Generate.
|
||||
type configured struct {
|
||||
llm.Provider
|
||||
// key is the API key, redacted from validation errors; the adapter
|
||||
// redacts its own.
|
||||
key string
|
||||
// temperature is sent when a request sets none; nil means the model
|
||||
// rejects temperature, so none is ever sent.
|
||||
temperature *float64
|
||||
}
|
||||
|
||||
// Generate validates req as sent by the caller before changing its
|
||||
// temperature, so clearing it for a model that rejects temperature never
|
||||
// hides an invalid one.
|
||||
func (c *configured) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return llm.Response{}, redact(fmt.Errorf("presets: generate: %w", err), c.key)
|
||||
}
|
||||
if c.temperature == nil {
|
||||
req.Temperature = nil
|
||||
} else if req.Temperature == nil {
|
||||
t := *c.temperature
|
||||
req.Temperature = &t
|
||||
}
|
||||
return c.Provider.Generate(ctx, req)
|
||||
}
|
||||
|
||||
// redactedError is an error with the API key removed from its text. It
|
||||
// does not unwrap, because every layer below may hold the key; Is still
|
||||
// matches the sentinels the original error wraps.
|
||||
type redactedError struct {
|
||||
text string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *redactedError) Error() string { return e.text }
|
||||
|
||||
// GoString keeps %#v to the redacted Error text instead of the fields.
|
||||
func (e *redactedError) GoString() string { return e.text }
|
||||
|
||||
// Is reports whether the original error matches target.
|
||||
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
|
||||
|
||||
// redact returns err with key removed from its text; a nil err or an empty
|
||||
// key returns err unchanged.
|
||||
func redact(err error, key string) error {
|
||||
if err == nil || key == "" {
|
||||
return err
|
||||
}
|
||||
return &redactedError{text: llm.Redact(err.Error(), key), err: err}
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
package presets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
const testKey = "sk-test-0123456789"
|
||||
|
||||
// captured is the HTTP request an adapter sent.
|
||||
type captured struct {
|
||||
url string
|
||||
header http.Header
|
||||
body map[string]json.RawMessage
|
||||
}
|
||||
|
||||
// recordingTransport records each request and answers it 400, so no
|
||||
// request leaves the process and the Provider never retries it.
|
||||
type recordingTransport struct{ reqs []captured }
|
||||
|
||||
func (rt *recordingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
c := captured{url: r.URL.String(), header: r.Header.Clone()}
|
||||
if r.Body != nil {
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(raw, &c.body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
rt.reqs = append(rt.reqs, c)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{"Content-Type": {"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{}`)),
|
||||
Request: r,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generate builds the provider s describes and returns the request its
|
||||
// Generate sends for req. The adapters use http.DefaultTransport, which the
|
||||
// test replaces, so the real preset base URLs are never contacted.
|
||||
func generate(t *testing.T, s llm.Settings, key string, req llm.Request) captured {
|
||||
t.Helper()
|
||||
rt := &recordingTransport{}
|
||||
orig := http.DefaultTransport
|
||||
http.DefaultTransport = rt
|
||||
t.Cleanup(func() { http.DefaultTransport = orig })
|
||||
|
||||
p, err := FromSettings(s, key)
|
||||
if err != nil {
|
||||
t.Fatalf("FromSettings: %v", err)
|
||||
}
|
||||
if _, err := p.Generate(context.Background(), req); err == nil {
|
||||
t.Fatal("Generate succeeded through the recording transport")
|
||||
}
|
||||
if len(rt.reqs) != 1 {
|
||||
t.Fatalf("sent %d requests, want 1", len(rt.reqs))
|
||||
}
|
||||
return rt.reqs[0]
|
||||
}
|
||||
|
||||
func plainRequest() llm.Request {
|
||||
return llm.Request{Messages: []llm.Message{{Role: llm.RoleUser, Content: "dinner?"}}}
|
||||
}
|
||||
|
||||
func schemaRequest() llm.Request {
|
||||
r := plainRequest()
|
||||
r.Schema = &llm.JSONSchema{Name: "side_quests", Schema: json.RawMessage(`{"type":"object"}`)}
|
||||
return r
|
||||
}
|
||||
|
||||
func ptr(f float64) *float64 { return &f }
|
||||
|
||||
func responseFormatType(t *testing.T, c captured) string {
|
||||
t.Helper()
|
||||
raw, ok := c.body["response_format"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
var rf struct{ Type string }
|
||||
if err := json.Unmarshal(raw, &rf); err != nil {
|
||||
t.Fatalf("response_format: %v", err)
|
||||
}
|
||||
return rf.Type
|
||||
}
|
||||
|
||||
func temperature(t *testing.T, c captured) (float64, bool) {
|
||||
t.Helper()
|
||||
raw, ok := c.body["temperature"]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
var f float64
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
t.Fatalf("temperature: %v", err)
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
|
||||
func TestAnthropicPreset(t *testing.T) {
|
||||
c := generate(t, llm.Settings{Provider: "anthropic", Model: "claude-sonnet-5", Temperature: 0.7}, testKey, plainRequest())
|
||||
if c.url != "https://api.anthropic.com/v1/messages" {
|
||||
t.Fatalf("URL = %q", c.url)
|
||||
}
|
||||
if got := c.header.Get("X-Api-Key"); got != testKey {
|
||||
t.Fatalf("x-api-key = %q", got)
|
||||
}
|
||||
if got := c.header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("Authorization = %q, want none", got)
|
||||
}
|
||||
if got, ok := temperature(t, c); !ok || got != 0.7 {
|
||||
t.Fatalf("temperature = %v, %v; want 0.7", got, ok)
|
||||
}
|
||||
if string(c.body["max_tokens"]) != "4096" {
|
||||
t.Fatalf("max_tokens = %s", c.body["max_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicPresetAcceptsItsOwnBaseURL(t *testing.T) {
|
||||
c := generate(t, llm.Settings{Provider: "anthropic", BaseURL: "https://api.anthropic.com", Model: "claude-sonnet-5"}, testKey, plainRequest())
|
||||
if c.url != "https://api.anthropic.com/v1/messages" {
|
||||
t.Fatalf("URL = %q", c.url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIPreset(t *testing.T) {
|
||||
c := generate(t, llm.Settings{Provider: "openai", Model: "gpt-4o", Temperature: 0.4}, testKey, schemaRequest())
|
||||
if c.url != "https://api.openai.com/v1/chat/completions" {
|
||||
t.Fatalf("URL = %q", c.url)
|
||||
}
|
||||
if got := c.header.Get("Authorization"); got != "Bearer "+testKey {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
if got := responseFormatType(t, c); got != "json_schema" {
|
||||
t.Fatalf("response_format type = %q, want json_schema", got)
|
||||
}
|
||||
if c.header.Get("HTTP-Referer") != "" || c.header.Get("X-Title") != "" {
|
||||
t.Fatal("openai sends OpenRouter headers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRouterPreset(t *testing.T) {
|
||||
s := llm.Settings{Provider: "openrouter", Model: "openai/gpt-4o", PublicURL: "https://meals.example.com/"}
|
||||
c := generate(t, s, testKey, schemaRequest())
|
||||
if c.url != "https://openrouter.ai/api/v1/chat/completions" {
|
||||
t.Fatalf("URL = %q", c.url)
|
||||
}
|
||||
if got := c.header.Get("Authorization"); got != "Bearer "+testKey {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
if got := c.header.Get("HTTP-Referer"); got != "https://meals.example.com/" {
|
||||
t.Fatalf("HTTP-Referer = %q", got)
|
||||
}
|
||||
if got := c.header.Get("X-Title"); got != "MealQuest" {
|
||||
t.Fatalf("X-Title = %q", got)
|
||||
}
|
||||
if got := responseFormatType(t, c); got != "json_schema" {
|
||||
t.Fatalf("default response_format type = %q, want json_schema", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRouterFallbackModeIsJSONObject(t *testing.T) {
|
||||
p, _ := Lookup("openrouter")
|
||||
s := llm.Settings{
|
||||
Provider: "openrouter",
|
||||
Model: "meta-llama/llama-3.3-70b-instruct",
|
||||
PublicURL: "https://meals.example.com",
|
||||
StructuredOutput: string(p.FallbackStructuredOutput),
|
||||
}
|
||||
c := generate(t, s, testKey, schemaRequest())
|
||||
if got := responseFormatType(t, c); got != "json_object" {
|
||||
t.Fatalf("response_format type = %q, want json_object", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXAIPreset(t *testing.T) {
|
||||
c := generate(t, llm.Settings{Provider: "xai", Model: "grok-4", Temperature: 0.6}, testKey, schemaRequest())
|
||||
if c.url != "https://api.x.ai/v1/chat/completions" {
|
||||
t.Fatalf("URL = %q", c.url)
|
||||
}
|
||||
if got := c.header.Get("Authorization"); got != "Bearer "+testKey {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
for _, h := range []string{"X-Api-Key", "HTTP-Referer", "X-Title"} {
|
||||
if got := c.header.Get(h); got != "" {
|
||||
t.Fatalf("%s = %q, want none", h, got)
|
||||
}
|
||||
}
|
||||
if got := responseFormatType(t, c); got != "json_schema" {
|
||||
t.Fatalf("default response_format type = %q, want json_schema", got)
|
||||
}
|
||||
if got, ok := temperature(t, c); !ok || got != 0.6 {
|
||||
t.Fatalf("temperature = %v, %v; want 0.6", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXAIFallbackModeIsJSONObject(t *testing.T) {
|
||||
p, _ := Lookup("xai")
|
||||
s := llm.Settings{Provider: "xai", Model: "grok-4", StructuredOutput: string(p.FallbackStructuredOutput)}
|
||||
c := generate(t, s, testKey, schemaRequest())
|
||||
if got := responseFormatType(t, c); got != "json_object" {
|
||||
t.Fatalf("response_format type = %q, want json_object", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXAIPresetAcceptsItsOwnBaseURL(t *testing.T) {
|
||||
c := generate(t, llm.Settings{Provider: "xai", BaseURL: "https://api.x.ai/v1", Model: "grok-4"}, testKey, plainRequest())
|
||||
if c.url != "https://api.x.ai/v1/chat/completions" {
|
||||
t.Fatalf("URL = %q", c.url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOllamaPreset(t *testing.T) {
|
||||
s := llm.Settings{Provider: "ollama", BaseURL: "http://ollama.lan:11434", Model: "llama3.2", Temperature: 0.5}
|
||||
c := generate(t, s, "", plainRequest())
|
||||
if c.url != "http://ollama.lan:11434/api/chat" {
|
||||
t.Fatalf("URL = %q", c.url)
|
||||
}
|
||||
if got := c.header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("Authorization = %q, want none", got)
|
||||
}
|
||||
var opts struct{ Temperature *float64 }
|
||||
if err := json.Unmarshal(c.body["options"], &opts); err != nil || opts.Temperature == nil || *opts.Temperature != 0.5 {
|
||||
t.Fatalf("options = %s", c.body["options"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatiblePreset(t *testing.T) {
|
||||
base := llm.Settings{Provider: "openai_compatible", BaseURL: "http://llm.lan:8000/v1", Model: "qwen2.5"}
|
||||
|
||||
c := generate(t, base, "", schemaRequest())
|
||||
if c.url != "http://llm.lan:8000/v1/chat/completions" {
|
||||
t.Fatalf("URL = %q", c.url)
|
||||
}
|
||||
if got := c.header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("Authorization without key = %q, want none", got)
|
||||
}
|
||||
if got := responseFormatType(t, c); got != "json_schema" {
|
||||
t.Fatalf("default response_format type = %q, want json_schema", got)
|
||||
}
|
||||
|
||||
c = generate(t, base, testKey, plainRequest())
|
||||
if got := c.header.Get("Authorization"); got != "Bearer "+testKey {
|
||||
t.Fatalf("Authorization with key = %q", got)
|
||||
}
|
||||
|
||||
for mode, want := range map[string]string{"json_schema": "json_schema", "json_object": "json_object", "prompt_only": ""} {
|
||||
s := base
|
||||
s.StructuredOutput = mode
|
||||
if got := responseFormatType(t, generate(t, s, "", schemaRequest())); got != want {
|
||||
t.Errorf("mode %s: response_format type = %q, want %q", mode, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemperatureOmittedForRejectingModels(t *testing.T) {
|
||||
cases := map[string][]string{
|
||||
"openai": {"o1", "o1-mini", "o1-2024-12-17", "o3", "o3-mini"},
|
||||
"openai_compatible": {"o1", "o1-mini", "o3-mini", "openai/o1"},
|
||||
"openrouter": {"openai/o1", "openai/o1-mini", "openai/o3", "openai/o3-mini", "openai/o1:online"},
|
||||
}
|
||||
for provider, models := range cases {
|
||||
for _, model := range models {
|
||||
s := llm.Settings{Provider: provider, Model: model, Temperature: 0.8}
|
||||
switch provider {
|
||||
case "openai_compatible":
|
||||
s.BaseURL = "http://llm.lan/v1"
|
||||
case "openrouter":
|
||||
s.PublicURL = "https://mealquest.example"
|
||||
}
|
||||
for _, req := range []llm.Request{plainRequest(), func() llm.Request { r := plainRequest(); r.Temperature = ptr(0.2); return r }()} {
|
||||
if got, ok := temperature(t, generate(t, s, testKey, req)); ok {
|
||||
t.Errorf("%s %s: temperature %v sent, want omitted", provider, model, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemperatureSentForOtherModels(t *testing.T) {
|
||||
cases := map[string][]string{
|
||||
"openai": {"gpt-4o", "gpt-4o-mini", "chatgpt-o1-like", "o123", "o3custom", "o1mini", "openai/o1"},
|
||||
"openrouter": {"openai/gpt-4o", "openai/o123", "openai/o3custom", "acme/o1", "o1-mini"},
|
||||
}
|
||||
for provider, models := range cases {
|
||||
for _, model := range models {
|
||||
s := llm.Settings{Provider: provider, Model: model, Temperature: 0.8}
|
||||
if provider == "openrouter" {
|
||||
s.PublicURL = "https://mealquest.example"
|
||||
}
|
||||
if got, ok := temperature(t, generate(t, s, testKey, plainRequest())); !ok || got != 0.8 {
|
||||
t.Errorf("%s %s: temperature = %v, %v; want 0.8", provider, model, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A request's own temperature wins over the household's.
|
||||
req := plainRequest()
|
||||
req.Temperature = ptr(0.1)
|
||||
c := generate(t, llm.Settings{Provider: "openai", Model: "gpt-4o", Temperature: 0.8}, testKey, req)
|
||||
if got, ok := temperature(t, c); !ok || got != 0.1 {
|
||||
t.Fatalf("temperature = %v, %v; want 0.1", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// A request is validated before the temperature quirk clears its
|
||||
// temperature, so a negative one is an error and nothing is sent.
|
||||
func TestNegativeTemperatureRejectedForRejectingModels(t *testing.T) {
|
||||
rt := &recordingTransport{}
|
||||
orig := http.DefaultTransport
|
||||
http.DefaultTransport = rt
|
||||
t.Cleanup(func() { http.DefaultTransport = orig })
|
||||
|
||||
for _, s := range []llm.Settings{
|
||||
{Provider: "openai", Model: "o1"},
|
||||
{Provider: "openrouter", Model: "openai/o1", PublicURL: "https://mealquest.example"},
|
||||
} {
|
||||
p, err := FromSettings(s, testKey)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: FromSettings: %v", s.Model, err)
|
||||
}
|
||||
req := plainRequest()
|
||||
req.Temperature = ptr(-0.5)
|
||||
if _, err := p.Generate(context.Background(), req); err == nil || errors.Is(err, llm.ErrProvider) || !strings.Contains(err.Error(), "temperature") {
|
||||
t.Errorf("%s: Generate = %v, want a temperature validation error", s.Model, err)
|
||||
}
|
||||
}
|
||||
if len(rt.reqs) != 0 {
|
||||
t.Fatalf("sent %d requests, want 0", len(rt.reqs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemperatureQuirkPresets(t *testing.T) {
|
||||
want := map[string]bool{"openai": true, "openrouter": true, "openai_compatible": true}
|
||||
for name, p := range all {
|
||||
if got := len(p.OmitTemperature) > 0; got != want[name] {
|
||||
t.Errorf("%s: lists temperature quirk = %v, want %v", name, got, want[name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetTable(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
adapter Adapter
|
||||
baseURL string
|
||||
key KeyPolicy
|
||||
}{
|
||||
{"anthropic", AdapterAnthropic, "https://api.anthropic.com", KeyRequired},
|
||||
{"openai", AdapterOpenAI, "https://api.openai.com/v1", KeyRequired},
|
||||
{"openrouter", AdapterOpenAI, "https://openrouter.ai/api/v1", KeyRequired},
|
||||
{"xai", AdapterOpenAI, "https://api.x.ai/v1", KeyRequired},
|
||||
{"ollama", AdapterOllama, "", KeyNone},
|
||||
{"openai_compatible", AdapterOpenAI, "", KeyOptional},
|
||||
}
|
||||
if len(all) != len(cases) {
|
||||
t.Fatalf("%d presets, want %d", len(all), len(cases))
|
||||
}
|
||||
for _, c := range cases {
|
||||
p, ok := Lookup(c.name)
|
||||
if !ok {
|
||||
t.Fatalf("no preset %q", c.name)
|
||||
}
|
||||
if p.Name != c.name || p.Adapter != c.adapter || p.BaseURL != c.baseURL || p.Key != c.key {
|
||||
t.Errorf("%s = {%s %s %q %d}", c.name, p.Name, p.Adapter, p.BaseURL, p.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigErrors(t *testing.T) {
|
||||
sentinels := []error{
|
||||
llm.ErrUnknownProvider, llm.ErrMissingKey, llm.ErrUnexpectedKey, llm.ErrInvalidKey, llm.ErrMissingBaseURL,
|
||||
llm.ErrMissingModel, llm.ErrMissingPublicURL, llm.ErrInvalidSetting,
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
s llm.Settings
|
||||
key string
|
||||
want error
|
||||
}{
|
||||
{"unknown provider", llm.Settings{Provider: "gemini", Model: "m"}, testKey, llm.ErrUnknownProvider},
|
||||
{"anthropic no key", llm.Settings{Provider: "anthropic", Model: "m"}, "", llm.ErrMissingKey},
|
||||
{"openai no key", llm.Settings{Provider: "openai", Model: "m"}, "", llm.ErrMissingKey},
|
||||
{"openrouter no key", llm.Settings{Provider: "openrouter", Model: "m", PublicURL: "https://x.example"}, "", llm.ErrMissingKey},
|
||||
{"xai no key", llm.Settings{Provider: "xai", Model: "grok-4"}, "", llm.ErrMissingKey},
|
||||
{"ollama with key", llm.Settings{Provider: "ollama", BaseURL: "http://o:11434", Model: "m"}, testKey, llm.ErrUnexpectedKey},
|
||||
{"anthropic key with newline", llm.Settings{Provider: "anthropic", Model: "m"}, testKey + "\n", llm.ErrInvalidKey},
|
||||
{"openai key with newline", llm.Settings{Provider: "openai", Model: "m"}, testKey + "\n", llm.ErrInvalidKey},
|
||||
{"openrouter key with CR LF", llm.Settings{Provider: "openrouter", Model: "m", PublicURL: "https://x.example"}, testKey + "\r\nX-Injected: 1", llm.ErrInvalidKey},
|
||||
{"xai key with newline", llm.Settings{Provider: "xai", Model: "grok-4"}, testKey + "\n", llm.ErrInvalidKey},
|
||||
{"openai_compatible key with NUL", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m"}, "\x00" + testKey, llm.ErrInvalidKey},
|
||||
{"ollama no base URL", llm.Settings{Provider: "ollama", Model: "m"}, "", llm.ErrMissingBaseURL},
|
||||
{"openai_compatible no base URL", llm.Settings{Provider: "openai_compatible", Model: "m"}, "", llm.ErrMissingBaseURL},
|
||||
{"anthropic no model", llm.Settings{Provider: "anthropic"}, testKey, llm.ErrMissingModel},
|
||||
{"openai no model", llm.Settings{Provider: "openai"}, testKey, llm.ErrMissingModel},
|
||||
{"openrouter no model", llm.Settings{Provider: "openrouter", PublicURL: "https://x.example"}, testKey, llm.ErrMissingModel},
|
||||
{"ollama no model", llm.Settings{Provider: "ollama", BaseURL: "http://o:11434"}, "", llm.ErrMissingModel},
|
||||
{"openai_compatible no model", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1"}, "", llm.ErrMissingModel},
|
||||
{"xai no model", llm.Settings{Provider: "xai"}, testKey, llm.ErrMissingModel},
|
||||
{"openrouter no public URL", llm.Settings{Provider: "openrouter", Model: "m"}, testKey, llm.ErrMissingPublicURL},
|
||||
{"fixed base URL overridden", llm.Settings{Provider: "openai", BaseURL: "https://evil.example/v1", Model: "m"}, testKey, llm.ErrInvalidSetting},
|
||||
{"xai base URL overridden", llm.Settings{Provider: "xai", BaseURL: "https://api.openai.com/v1", Model: "grok-4"}, testKey, llm.ErrInvalidSetting},
|
||||
{"xai prompt_only", llm.Settings{Provider: "xai", Model: "grok-4", StructuredOutput: "prompt_only"}, testKey, llm.ErrInvalidSetting},
|
||||
{"openai json_object", llm.Settings{Provider: "openai", Model: "m", StructuredOutput: "json_object"}, testKey, llm.ErrInvalidSetting},
|
||||
{"anthropic mode", llm.Settings{Provider: "anthropic", Model: "m", StructuredOutput: "json_schema"}, testKey, llm.ErrInvalidSetting},
|
||||
{"unknown mode", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m", StructuredOutput: "xml"}, "", llm.ErrInvalidSetting},
|
||||
{"negative temperature", llm.Settings{Provider: "openai", Model: "m", Temperature: -0.1}, testKey, llm.ErrInvalidSetting},
|
||||
{"bad base URL", llm.Settings{Provider: "ollama", BaseURL: "ftp://o", Model: "m"}, "", llm.ErrInvalidSetting},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
p, err := FromSettings(c.s, c.key)
|
||||
if p != nil || !errors.Is(err, c.want) {
|
||||
t.Fatalf("FromSettings = %v, %v; want %v", p, err, c.want)
|
||||
}
|
||||
for _, other := range sentinels {
|
||||
if other != c.want && errors.Is(err, other) {
|
||||
t.Fatalf("error %v also matches %v", err, other)
|
||||
}
|
||||
}
|
||||
if c.key != "" && strings.Contains(err.Error(), c.key) {
|
||||
t.Fatalf("error leaks the key: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyProviderForEveryPreset(t *testing.T) {
|
||||
for _, s := range []struct {
|
||||
s llm.Settings
|
||||
key string
|
||||
}{
|
||||
{llm.Settings{Provider: "anthropic", Model: "m"}, testKey},
|
||||
{llm.Settings{Provider: "openai", Model: "m"}, testKey},
|
||||
{llm.Settings{Provider: "openrouter", Model: "m", PublicURL: "https://x.example"}, testKey},
|
||||
{llm.Settings{Provider: "xai", Model: "m"}, testKey},
|
||||
{llm.Settings{Provider: "ollama", BaseURL: "http://o:11434", Model: "m"}, ""},
|
||||
{llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m"}, ""},
|
||||
} {
|
||||
if p, err := FromSettings(s.s, s.key); err != nil || p == nil {
|
||||
t.Errorf("%s: FromSettings = %v, %v", s.s.Provider, p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A key that matches a value the request carries, or text the error spells
|
||||
// anyway, never appears in a validation error, and nothing is sent.
|
||||
func TestGenerateValidationErrorsNeverLeakKey(t *testing.T) {
|
||||
rt := &recordingTransport{}
|
||||
orig := http.DefaultTransport
|
||||
http.DefaultTransport = rt
|
||||
t.Cleanup(func() { http.DefaultTransport = orig })
|
||||
|
||||
for _, key := range []string{"-0.5", "temperature", "llm", "negative"} {
|
||||
p, err := FromSettings(llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m"}, key)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: FromSettings: %v", key, err)
|
||||
}
|
||||
req := plainRequest()
|
||||
req.Temperature = ptr(-0.5)
|
||||
_, err = p.Generate(context.Background(), req)
|
||||
if err == nil || errors.Is(err, llm.ErrProvider) {
|
||||
t.Fatalf("%s: Generate = %v, want a validation error", key, err)
|
||||
}
|
||||
for e := error(err); e != nil; e = errors.Unwrap(e) {
|
||||
if s := e.Error() + fmt.Sprintf("%#v", e); strings.Contains(s, key) {
|
||||
t.Fatalf("%s: error leaks the key: %s", key, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(rt.reqs) != 0 {
|
||||
t.Fatalf("sent %d requests, want 0", len(rt.reqs))
|
||||
}
|
||||
}
|
||||
|
||||
// A configuration error keeps its sentinel but never holds the key, even
|
||||
// when the key is also a setting's value or text the error spells anyway.
|
||||
func TestConfigErrorsNeverLeakKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
s llm.Settings
|
||||
key string
|
||||
want error
|
||||
}{
|
||||
{"unknown provider named by key", llm.Settings{Provider: "llm", Model: "m"}, "llm", llm.ErrUnknownProvider},
|
||||
{"no base URL", llm.Settings{Provider: "openai_compatible", Model: "m"}, "base URL", llm.ErrMissingBaseURL},
|
||||
{"no model", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1"}, "model", llm.ErrMissingModel},
|
||||
{"key as model, no public URL", llm.Settings{Provider: "openrouter", Model: "missing"}, "missing", llm.ErrMissingPublicURL},
|
||||
{"key as base URL", llm.Settings{Provider: "openai", BaseURL: "llm", Model: "m"}, "llm", llm.ErrInvalidSetting},
|
||||
{"key as mode", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m", StructuredOutput: "xml"}, "xml", llm.ErrInvalidSetting},
|
||||
{"key as temperature", llm.Settings{Provider: "openai", Model: "m", Temperature: -0.1}, "-0.1", llm.ErrInvalidSetting},
|
||||
{"key in bad base URL", llm.Settings{Provider: "openai_compatible", BaseURL: "ftp://k3y", Model: "m"}, "k3y", llm.ErrInvalidSetting},
|
||||
{"ollama with key", llm.Settings{Provider: "ollama", BaseURL: "http://o:11434", Model: "m"}, "key", llm.ErrUnexpectedKey},
|
||||
{"key with newline", llm.Settings{Provider: "openai", Model: "m"}, "sk-test\n", llm.ErrInvalidKey},
|
||||
{"xai key as preset name", llm.Settings{Provider: "xai"}, "xai", llm.ErrMissingModel},
|
||||
{"xai key as missing-model text", llm.Settings{Provider: "xai"}, "model is required", llm.ErrMissingModel},
|
||||
{"xai key as fixed-URL text", llm.Settings{Provider: "xai", BaseURL: "https://x.example/v1", Model: "m"}, "fixed", llm.ErrInvalidSetting},
|
||||
{"xai key as structured-output text", llm.Settings{Provider: "xai", Model: "m", StructuredOutput: "prompt_only"}, "structured output", llm.ErrInvalidSetting},
|
||||
{"key with newline as text", llm.Settings{Provider: "openai", Model: "m"}, "header\n", llm.ErrInvalidKey},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
p, err := FromSettings(c.s, c.key)
|
||||
if p != nil || !errors.Is(err, c.want) {
|
||||
t.Fatalf("FromSettings = %v, %v; want %v", p, err, c.want)
|
||||
}
|
||||
for e := error(err); e != nil; e = errors.Unwrap(e) {
|
||||
if s := e.Error() + fmt.Sprintf("%#v", e); strings.Contains(s, c.key) {
|
||||
t.Fatalf("error leaks the key: %s", s)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// scriptedTransport answers each request with the next scripted status and
|
||||
// body, echoing the key in every failure body, and counts the requests. A
|
||||
// scripted status of 0 is a transport timeout instead of a response.
|
||||
type scriptedTransport struct {
|
||||
statuses []int
|
||||
key string
|
||||
sent int
|
||||
}
|
||||
|
||||
func (st *scriptedTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
if st.sent == len(st.statuses) {
|
||||
return nil, fmt.Errorf("unscripted request %d", st.sent+1)
|
||||
}
|
||||
status := st.statuses[st.sent]
|
||||
st.sent++
|
||||
if status == 0 {
|
||||
return nil, transportTimeout{}
|
||||
}
|
||||
header := http.Header{"Content-Type": {"application/json"}}
|
||||
body := `{"error":{"message":"bad key ` + st.key + `"}}`
|
||||
switch status {
|
||||
case http.StatusOK:
|
||||
body = `{"model":"m","choices":[{"message":{"content":"tacos"},"finish_reason":"stop"}]}`
|
||||
case http.StatusTooManyRequests:
|
||||
header.Set("Retry-After", "1")
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: header,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: r,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// transportTimeout is a net.Error that timed out.
|
||||
type transportTimeout struct{}
|
||||
|
||||
func (transportTimeout) Error() string { return "i/o timeout" }
|
||||
func (transportTimeout) Timeout() bool { return true }
|
||||
func (transportTimeout) Temporary() bool { return true }
|
||||
|
||||
// useTransport makes the adapters send through rt for the rest of the test.
|
||||
func useTransport(t *testing.T, rt http.RoundTripper) {
|
||||
t.Helper()
|
||||
orig := http.DefaultTransport
|
||||
http.DefaultTransport = rt
|
||||
t.Cleanup(func() { http.DefaultTransport = orig })
|
||||
}
|
||||
|
||||
// The Provider FromSettings returns retries a rate-limited Generate.
|
||||
func TestFromSettingsRetriesRateLimited(t *testing.T) {
|
||||
st := &scriptedTransport{statuses: []int{http.StatusTooManyRequests, http.StatusOK}, key: testKey}
|
||||
useTransport(t, st)
|
||||
|
||||
p, err := FromSettings(llm.Settings{Provider: "openai", Model: "m"}, testKey)
|
||||
if err != nil {
|
||||
t.Fatalf("FromSettings: %v", err)
|
||||
}
|
||||
resp, err := p.Generate(context.Background(), plainRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if resp.Text != "tacos" {
|
||||
t.Errorf("Text = %q, want %q", resp.Text, "tacos")
|
||||
}
|
||||
if st.sent != 2 {
|
||||
t.Errorf("sent %d requests, want 2", st.sent)
|
||||
}
|
||||
}
|
||||
|
||||
// When every retry fails, the error FromSettings's Provider returns keeps
|
||||
// its sentinel and never holds the key the vendor echoed.
|
||||
func TestFromSettingsRetryErrorNeverLeaksKey(t *testing.T) {
|
||||
st := &scriptedTransport{statuses: []int{http.StatusTooManyRequests, http.StatusTooManyRequests, http.StatusServiceUnavailable}, key: testKey}
|
||||
useTransport(t, st)
|
||||
|
||||
p, err := FromSettings(llm.Settings{Provider: "openai", Model: "m"}, testKey)
|
||||
if err != nil {
|
||||
t.Fatalf("FromSettings: %v", err)
|
||||
}
|
||||
_, err = p.Generate(context.Background(), plainRequest())
|
||||
if !errors.Is(err, llm.ErrProvider) {
|
||||
t.Fatalf("Generate = %v, want %v", err, llm.ErrProvider)
|
||||
}
|
||||
if st.sent != llm.MaxRetries+1 {
|
||||
t.Errorf("sent %d requests, want %d", st.sent, llm.MaxRetries+1)
|
||||
}
|
||||
for e := error(err); e != nil; e = errors.Unwrap(e) {
|
||||
if s := e.Error() + fmt.Sprintf("%#v", e); strings.Contains(s, testKey) {
|
||||
t.Fatalf("error leaks the key: %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An HTTP 408 from the vendor is a 4xx answer and is not retried, though it
|
||||
// matches llm.ErrTimeout.
|
||||
func TestFromSettingsDoesNotRetryHTTP408(t *testing.T) {
|
||||
for _, provider := range []string{"openai", "anthropic"} {
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
st := &scriptedTransport{statuses: []int{http.StatusRequestTimeout}, key: testKey}
|
||||
useTransport(t, st)
|
||||
|
||||
p, err := FromSettings(llm.Settings{Provider: provider, Model: "m"}, testKey)
|
||||
if err != nil {
|
||||
t.Fatalf("FromSettings: %v", err)
|
||||
}
|
||||
_, err = p.Generate(context.Background(), plainRequest())
|
||||
var ts *llm.TimeoutStatusError
|
||||
if !errors.Is(err, llm.ErrTimeout) || !errors.As(err, &ts) || ts.Status() != http.StatusRequestTimeout {
|
||||
t.Fatalf("Generate = %v, want an HTTP 408 ErrTimeout", err)
|
||||
}
|
||||
if st.sent != 1 {
|
||||
t.Errorf("sent %d requests, want 1", st.sent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A transport timeout carries no status and is retried.
|
||||
func TestFromSettingsRetriesTransportTimeout(t *testing.T) {
|
||||
st := &scriptedTransport{statuses: []int{0, http.StatusOK}, key: testKey}
|
||||
useTransport(t, st)
|
||||
|
||||
p, err := FromSettings(llm.Settings{Provider: "openai", Model: "m"}, testKey)
|
||||
if err != nil {
|
||||
t.Fatalf("FromSettings: %v", err)
|
||||
}
|
||||
resp, err := p.Generate(context.Background(), plainRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if resp.Text != "tacos" {
|
||||
t.Errorf("Text = %q, want %q", resp.Text, "tacos")
|
||||
}
|
||||
if st.sent != 2 {
|
||||
t.Errorf("sent %d requests, want 2", st.sent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultTimeout bounds a whole Generate call, retries and waits
|
||||
// included, whose Request sets no Timeout.
|
||||
DefaultTimeout = 60 * time.Second
|
||||
// MaxRetries is how many times a failed Generate is retried, so a call
|
||||
// makes at most MaxRetries+1 attempts.
|
||||
MaxRetries = 2
|
||||
// MaxRetryAfter caps the wait a rate-limited vendor may ask for.
|
||||
MaxRetryAfter = 20 * time.Second
|
||||
// backoffBase is the first retry's backoff before jitter; each later
|
||||
// retry doubles it.
|
||||
backoffBase = time.Second
|
||||
)
|
||||
|
||||
// Retrying is a Provider that retries transient Generate failures of the
|
||||
// Provider it wraps. It retries ErrRateLimited, a transport ErrTimeout and
|
||||
// a 5xx ErrProvider at most MaxRetries times, and nothing else: an error
|
||||
// carrying a 4xx HTTP status other than a rate limit, such as an HTTP 408
|
||||
// TimeoutStatusError, is not retried. A rate-limited retry waits for the
|
||||
// vendor's RetryAfter, capped at MaxRetryAfter; every other retry, and a
|
||||
// rate limit that names no wait, waits a jittered exponential backoff. The Request's Timeout, or DefaultTimeout, is one
|
||||
// deadline for the whole call, retries and waits included; a caller's
|
||||
// earlier context deadline wins. A retry whose wait would not end before
|
||||
// that deadline is not made: the last error is returned at once.
|
||||
// ListModels and Verify pass straight through.
|
||||
type Retrying struct {
|
||||
Provider
|
||||
// now is the clock; tests replace it.
|
||||
now func() time.Time
|
||||
// sleep waits d or until ctx is done; tests replace it.
|
||||
sleep func(ctx context.Context, d time.Duration) error
|
||||
// jitter returns a random duration in [0, n) for n > 0; tests replace
|
||||
// it.
|
||||
jitter func(n time.Duration) time.Duration
|
||||
}
|
||||
|
||||
// NewRetrying returns inner wrapped with retries and the default Generate
|
||||
// timeout.
|
||||
func NewRetrying(inner Provider) *Retrying {
|
||||
return &Retrying{
|
||||
Provider: inner,
|
||||
now: time.Now,
|
||||
sleep: sleep,
|
||||
jitter: rand.N[time.Duration],
|
||||
}
|
||||
}
|
||||
|
||||
// Generate sends req to the wrapped Provider, retrying transient failures.
|
||||
// req.Timeout, or DefaultTimeout when it is 0, sets one deadline for the
|
||||
// whole call, carried by the context each attempt receives; attempts get
|
||||
// req with Timeout 0 so none restarts the clock.
|
||||
func (r *Retrying) Generate(ctx context.Context, req Request) (Response, error) {
|
||||
timeout := req.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = DefaultTimeout
|
||||
}
|
||||
if timeout > 0 {
|
||||
// A negative Timeout stays on req for the wrapped Provider's
|
||||
// Validate to reject, with its key redaction.
|
||||
req.Timeout = 0
|
||||
}
|
||||
ctx, cancel := context.WithDeadline(ctx, r.now().Add(timeout))
|
||||
defer cancel()
|
||||
deadline, _ := ctx.Deadline()
|
||||
for retry := 0; ; retry++ {
|
||||
resp, err := r.Provider.Generate(ctx, req)
|
||||
if err == nil || retry == MaxRetries || ctx.Err() != nil {
|
||||
return resp, err
|
||||
}
|
||||
wait, ok := r.wait(err, retry)
|
||||
if !ok {
|
||||
return resp, err
|
||||
}
|
||||
if !r.now().Add(wait).Before(deadline) {
|
||||
return resp, err
|
||||
}
|
||||
if serr := r.sleep(ctx, wait); serr != nil {
|
||||
return resp, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wait returns how long to wait before retrying after err, the retry-th
|
||||
// retry counting from 0, and false when err is not retried.
|
||||
func (r *Retrying) wait(err error, retry int) (time.Duration, bool) {
|
||||
var st interface{ Status() int }
|
||||
if errors.As(err, &st) && st.Status() >= 400 && st.Status() <= 499 {
|
||||
return 0, false
|
||||
}
|
||||
var rl *RateLimitedError
|
||||
if errors.As(err, &rl) && rl.RetryAfter > 0 {
|
||||
return min(rl.RetryAfter, MaxRetryAfter), true
|
||||
}
|
||||
var pe *ProviderError
|
||||
switch {
|
||||
case errors.Is(err, ErrRateLimited), errors.Is(err, ErrTimeout):
|
||||
case errors.As(err, &pe) && pe.Status() >= 500 && pe.Status() <= 599:
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
half := (backoffBase << retry) / 2
|
||||
return half + r.jitter(half), true
|
||||
}
|
||||
|
||||
// sleep waits d or until ctx is done, returning ctx's error in that case.
|
||||
func sleep(ctx context.Context, d time.Duration) error {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-t.C:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeClock is a clock whose sleeps return at once, advance it and are
|
||||
// recorded.
|
||||
type fakeClock struct {
|
||||
t time.Time
|
||||
sleeps []time.Duration
|
||||
}
|
||||
|
||||
func (c *fakeClock) now() time.Time { return c.t }
|
||||
|
||||
func (c *fakeClock) sleep(ctx context.Context, d time.Duration) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
c.sleeps = append(c.sleeps, d)
|
||||
c.t = c.t.Add(d)
|
||||
return nil
|
||||
}
|
||||
|
||||
// newTestRetrying returns a Retrying over a fresh Fake with a fake clock
|
||||
// at the real current time, so real context deadlines compare sensibly,
|
||||
// and jitter that always returns its maximum.
|
||||
func newTestRetrying() (*Retrying, *Fake, *fakeClock) {
|
||||
f := &Fake{}
|
||||
c := &fakeClock{t: time.Now()}
|
||||
r := NewRetrying(f)
|
||||
r.now = c.now
|
||||
r.sleep = c.sleep
|
||||
r.jitter = func(n time.Duration) time.Duration { return n - 1 }
|
||||
return r, f, c
|
||||
}
|
||||
|
||||
var retryReq = Request{Messages: []Message{{Role: RoleUser, Content: "plan dinners"}}}
|
||||
|
||||
func ok(text string) GenerateResult { return GenerateResult{Response: Response{Text: text}} }
|
||||
|
||||
func fail(err error) GenerateResult { return GenerateResult{Err: err} }
|
||||
|
||||
func assertConsumed(t *testing.T, f *Fake, attempts int) {
|
||||
t.Helper()
|
||||
if g, _, _ := f.Remaining(); g != 0 {
|
||||
t.Errorf("%d scripted Generate results unused", g)
|
||||
}
|
||||
if n := len(f.Requests()); n != attempts {
|
||||
t.Errorf("made %d attempts, want %d", n, attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryingRateLimitedThenSuccess(t *testing.T) {
|
||||
r, f, c := newTestRetrying()
|
||||
f.QueueGenerate(fail(NewRateLimitedError(3*time.Second, "")), ok("dinner"))
|
||||
|
||||
resp, err := r.Generate(context.Background(), retryReq)
|
||||
if err != nil || resp.Text != "dinner" {
|
||||
t.Fatalf("Generate = %+v, %v", resp, err)
|
||||
}
|
||||
if want := []time.Duration{3 * time.Second}; !slices.Equal(c.sleeps, want) {
|
||||
t.Errorf("sleeps = %v, want %v", c.sleeps, want)
|
||||
}
|
||||
assertConsumed(t, f, 2)
|
||||
}
|
||||
|
||||
func TestRetryingRateLimitedWaitIsCapped(t *testing.T) {
|
||||
r, f, c := newTestRetrying()
|
||||
f.QueueGenerate(fail(NewRateLimitedError(time.Hour, "")), ok("dinner"))
|
||||
|
||||
if _, err := r.Generate(context.Background(), retryReq); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []time.Duration{20 * time.Second}; !slices.Equal(c.sleeps, want) {
|
||||
t.Errorf("sleeps = %v, want %v", c.sleeps, want)
|
||||
}
|
||||
assertConsumed(t, f, 2)
|
||||
}
|
||||
|
||||
func TestRetryingRateLimitedWithoutRetryAfterBacksOff(t *testing.T) {
|
||||
r, f, c := newTestRetrying()
|
||||
f.QueueGenerate(fail(NewRateLimitedError(0, "")), ok("dinner"))
|
||||
|
||||
if _, err := r.Generate(context.Background(), retryReq); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []time.Duration{time.Second - 1}; !slices.Equal(c.sleeps, want) {
|
||||
t.Errorf("sleeps = %v, want %v", c.sleeps, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrying5xxTwiceThenSuccess(t *testing.T) {
|
||||
r, f, c := newTestRetrying()
|
||||
f.QueueGenerate(
|
||||
fail(NewProviderError(503, []byte("busy"), "")),
|
||||
// Adapters wrap the error they return.
|
||||
fail(fmt.Errorf("openai: generate: %w", NewProviderError(503, []byte("busy"), ""))),
|
||||
ok("dinner"),
|
||||
)
|
||||
|
||||
resp, err := r.Generate(context.Background(), retryReq)
|
||||
if err != nil || resp.Text != "dinner" {
|
||||
t.Fatalf("Generate = %+v, %v", resp, err)
|
||||
}
|
||||
// Max jitter: [0.5 s, 1 s) then [1 s, 2 s).
|
||||
if want := []time.Duration{time.Second - 1, 2*time.Second - 1}; !slices.Equal(c.sleeps, want) {
|
||||
t.Errorf("sleeps = %v, want %v", c.sleeps, want)
|
||||
}
|
||||
assertConsumed(t, f, 3)
|
||||
}
|
||||
|
||||
func TestRetryingBackoffJitterLowerBound(t *testing.T) {
|
||||
r, f, c := newTestRetrying()
|
||||
r.jitter = func(time.Duration) time.Duration { return 0 }
|
||||
f.QueueGenerate(fail(ErrTimeout), fail(ErrTimeout), ok("dinner"))
|
||||
|
||||
if _, err := r.Generate(context.Background(), retryReq); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []time.Duration{500 * time.Millisecond, time.Second}; !slices.Equal(c.sleeps, want) {
|
||||
t.Errorf("sleeps = %v, want %v", c.sleeps, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrying5xxThreeTimesFails(t *testing.T) {
|
||||
r, f, c := newTestRetrying()
|
||||
last := NewProviderError(503, []byte("third"), "")
|
||||
f.QueueGenerate(
|
||||
fail(NewProviderError(503, []byte("first"), "")),
|
||||
fail(NewProviderError(503, []byte("second"), "")),
|
||||
fail(last),
|
||||
)
|
||||
|
||||
_, err := r.Generate(context.Background(), retryReq)
|
||||
if err != last {
|
||||
t.Fatalf("err = %v, want the last attempt's error", err)
|
||||
}
|
||||
if len(c.sleeps) != MaxRetries {
|
||||
t.Errorf("slept %d times, want %d", len(c.sleeps), MaxRetries)
|
||||
}
|
||||
assertConsumed(t, f, 3)
|
||||
}
|
||||
|
||||
func TestRetryingDoesNotRetryPermanentErrors(t *testing.T) {
|
||||
for name, err := range map[string]error{
|
||||
"401 auth": ErrAuth,
|
||||
"400": NewProviderError(400, []byte("bad request"), ""),
|
||||
"404": NewProviderError(404, []byte("no such model"), ""),
|
||||
"408": NewTimeoutStatusError(408, ""),
|
||||
"wrapped 408": fmt.Errorf("openai: generate: %w", NewTimeoutStatusError(408, "")),
|
||||
"bad output": ErrBadOutput,
|
||||
"other": errors.New("llm: request has no messages"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r, f, c := newTestRetrying()
|
||||
f.QueueGenerate(fail(err))
|
||||
|
||||
if _, got := r.Generate(context.Background(), retryReq); got != err {
|
||||
t.Fatalf("err = %v, want %v", got, err)
|
||||
}
|
||||
if len(c.sleeps) != 0 {
|
||||
t.Errorf("slept %v", c.sleeps)
|
||||
}
|
||||
assertConsumed(t, f, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A transport timeout matches ErrTimeout like an HTTP 408, but carries no
|
||||
// status, so it is retried.
|
||||
func TestRetryingTransportTimeoutRetried(t *testing.T) {
|
||||
r, f, _ := newTestRetrying()
|
||||
f.QueueGenerate(fail(fmt.Errorf("openai: generate: %w", ErrTimeout)), ok("dinner"))
|
||||
|
||||
if _, err := r.Generate(context.Background(), retryReq); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertConsumed(t, f, 2)
|
||||
}
|
||||
|
||||
func TestRetryingDeadlineShorterThanRetryAfter(t *testing.T) {
|
||||
r, f, c := newTestRetrying()
|
||||
rl := NewRateLimitedError(10*time.Second, "")
|
||||
f.QueueGenerate(fail(rl))
|
||||
ctx, cancel := context.WithDeadline(context.Background(), c.t.Add(5*time.Second))
|
||||
defer cancel()
|
||||
|
||||
if _, err := r.Generate(ctx, retryReq); err != rl {
|
||||
t.Fatalf("err = %v, want the rate-limited error", err)
|
||||
}
|
||||
if len(c.sleeps) != 0 {
|
||||
t.Errorf("slept %v", c.sleeps)
|
||||
}
|
||||
assertConsumed(t, f, 1)
|
||||
}
|
||||
|
||||
func TestRetryingDeadlineShorterThanBackoff(t *testing.T) {
|
||||
r, f, c := newTestRetrying()
|
||||
f.QueueGenerate(fail(ErrTimeout))
|
||||
// The first backoff with maximum jitter is just under 1 s.
|
||||
ctx, cancel := context.WithDeadline(context.Background(), c.t.Add(time.Second-1))
|
||||
defer cancel()
|
||||
|
||||
if _, err := r.Generate(ctx, retryReq); !errors.Is(err, ErrTimeout) {
|
||||
t.Fatalf("err = %v, want ErrTimeout", err)
|
||||
}
|
||||
if len(c.sleeps) != 0 {
|
||||
t.Errorf("slept %v", c.sleeps)
|
||||
}
|
||||
assertConsumed(t, f, 1)
|
||||
}
|
||||
|
||||
func TestRetryingRetriesWithinDeadline(t *testing.T) {
|
||||
r, f, c := newTestRetrying()
|
||||
f.QueueGenerate(fail(NewRateLimitedError(3*time.Second, "")), ok("dinner"))
|
||||
ctx, cancel := context.WithDeadline(context.Background(), c.t.Add(time.Minute))
|
||||
defer cancel()
|
||||
|
||||
if _, err := r.Generate(ctx, retryReq); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertConsumed(t, f, 2)
|
||||
}
|
||||
|
||||
func TestRetryingStopsWhenContextDone(t *testing.T) {
|
||||
r, f, _ := newTestRetrying()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
f.QueueGenerate(fail(ErrTimeout))
|
||||
|
||||
if _, err := r.Generate(ctx, retryReq); !errors.Is(err, ErrTimeout) {
|
||||
t.Fatalf("err = %v, want ErrTimeout", err)
|
||||
}
|
||||
assertConsumed(t, f, 1)
|
||||
}
|
||||
|
||||
// slowProvider is a Provider whose every attempt takes d on clock c,
|
||||
// records the deadline of the context it received, and times out.
|
||||
type slowProvider struct {
|
||||
Provider
|
||||
c *fakeClock
|
||||
d time.Duration
|
||||
reqs []Request
|
||||
deadlines []time.Time
|
||||
}
|
||||
|
||||
func (p *slowProvider) Generate(ctx context.Context, req Request) (Response, error) {
|
||||
p.reqs = append(p.reqs, req)
|
||||
deadline, _ := ctx.Deadline()
|
||||
p.deadlines = append(p.deadlines, deadline)
|
||||
p.c.t = p.c.t.Add(p.d)
|
||||
return Response{}, ErrTimeout
|
||||
}
|
||||
|
||||
func TestRetryingTimeoutBoundsWholeCall(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
timeout time.Duration
|
||||
want time.Duration
|
||||
}{
|
||||
{"default", 0, 60 * time.Second},
|
||||
{"custom", 10 * time.Second, 10 * time.Second},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r, _, c := newTestRetrying()
|
||||
// Each attempt uses most of the one deadline, so a fresh full
|
||||
// timeout per attempt would allow every retry; one deadline
|
||||
// leaves room for exactly one.
|
||||
p := &slowProvider{c: c, d: tc.want * 5 / 6}
|
||||
r.Provider = p
|
||||
start := c.t
|
||||
req := retryReq
|
||||
req.Timeout = tc.timeout
|
||||
|
||||
if _, err := r.Generate(context.Background(), req); !errors.Is(err, ErrTimeout) {
|
||||
t.Fatalf("err = %v, want ErrTimeout", err)
|
||||
}
|
||||
if len(p.deadlines) != 2 {
|
||||
t.Fatalf("made %d attempts, want 2", len(p.deadlines))
|
||||
}
|
||||
for i, d := range p.deadlines {
|
||||
if !d.Equal(start.Add(tc.want)) {
|
||||
t.Errorf("attempt %d deadline = start+%v, want start+%v", i, d.Sub(start), tc.want)
|
||||
}
|
||||
if p.reqs[i].Timeout != 0 {
|
||||
t.Errorf("attempt %d Timeout = %v, want 0", i, p.reqs[i].Timeout)
|
||||
}
|
||||
}
|
||||
if want := []time.Duration{time.Second - 1}; !slices.Equal(c.sleeps, want) {
|
||||
t.Errorf("sleeps = %v, want %v", c.sleeps, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryingCallerDeadlineShorterThanTimeout(t *testing.T) {
|
||||
r, _, c := newTestRetrying()
|
||||
p := &slowProvider{c: c, d: time.Second}
|
||||
r.Provider = p
|
||||
ctx, cancel := context.WithDeadline(context.Background(), c.t.Add(5*time.Second))
|
||||
defer cancel()
|
||||
|
||||
if _, err := r.Generate(ctx, retryReq); !errors.Is(err, ErrTimeout) {
|
||||
t.Fatalf("err = %v, want ErrTimeout", err)
|
||||
}
|
||||
for i, d := range p.deadlines {
|
||||
if want, _ := ctx.Deadline(); !d.Equal(want) {
|
||||
t.Errorf("attempt %d deadline = %v, want caller's %v", i, d, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryingRejectsNegativeTimeout(t *testing.T) {
|
||||
r, f, _ := newTestRetrying()
|
||||
f.QueueGenerate(fail(errors.New("llm: timeout is negative")))
|
||||
req := retryReq
|
||||
req.Timeout = -time.Second
|
||||
|
||||
if _, err := r.Generate(context.Background(), req); err == nil {
|
||||
t.Fatal("Generate accepted a negative timeout")
|
||||
}
|
||||
assertConsumed(t, f, 1)
|
||||
if got := f.Requests()[0].Timeout; got != -time.Second {
|
||||
t.Errorf("wrapped Provider got Timeout %v, want -1s to reject", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryingPassesThroughListModelsAndVerify(t *testing.T) {
|
||||
r, f, _ := newTestRetrying()
|
||||
f.QueueListModels(ListModelsResult{Err: ErrTimeout})
|
||||
f.QueueVerify(ErrTimeout)
|
||||
|
||||
if _, err := r.ListModels(context.Background()); !errors.Is(err, ErrTimeout) {
|
||||
t.Fatalf("ListModels err = %v", err)
|
||||
}
|
||||
if err := r.Verify(context.Background()); !errors.Is(err, ErrTimeout) {
|
||||
t.Fatalf("Verify err = %v", err)
|
||||
}
|
||||
if _, l, v := f.Remaining(); l != 0 || v != 0 {
|
||||
t.Fatalf("Remaining = %d, %d", l, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSleepStopsWhenContextDone(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := sleep(ctx, time.Hour); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("sleep err = %v", err)
|
||||
}
|
||||
if err := sleep(context.Background(), time.Millisecond); err != nil {
|
||||
t.Fatalf("sleep err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package llm
|
||||
|
||||
import "errors"
|
||||
|
||||
// Settings is the household's AI configuration, the input to
|
||||
// presets.FromSettings. The caller fills it from the db.AiSetting row and
|
||||
// the service config; this package does not import internal/db.
|
||||
//
|
||||
// llm.Settings{
|
||||
// Provider: row.Provider,
|
||||
// BaseURL: row.BaseUrl,
|
||||
// Model: row.Model,
|
||||
// Temperature: row.Temperature,
|
||||
// PublicURL: cfg.PublicURL.String(), // MEALQUEST_PUBLIC_URL
|
||||
// }
|
||||
//
|
||||
// Settings never holds the API key: the caller decrypts it and passes it
|
||||
// to presets.FromSettings separately.
|
||||
type Settings struct {
|
||||
// Provider names the preset: "anthropic", "openai", "openrouter",
|
||||
// "xai", "ollama" or "openai_compatible".
|
||||
Provider string
|
||||
// BaseURL is the API root. It is required for ollama and
|
||||
// openai_compatible; the other presets fix their own, and a non-empty
|
||||
// value must equal it.
|
||||
BaseURL string
|
||||
// Model is the model id Generate requests. It is required.
|
||||
Model string
|
||||
// Temperature is the household's sampling temperature. It is sent with
|
||||
// every request that sets none, except to models that reject it.
|
||||
Temperature float64
|
||||
// PublicURL is MEALQUEST_PUBLIC_URL. OpenRouter requires it and
|
||||
// receives it as the HTTP-Referer header; other presets ignore it.
|
||||
PublicURL string
|
||||
// StructuredOutput selects how an OpenAI-compatible preset asks for
|
||||
// JSON: "json_schema", "json_object" or "prompt_only". Empty selects
|
||||
// the preset's default, json_schema. Each preset allows only the modes
|
||||
// it lists; anthropic and ollama allow none, so it must be empty.
|
||||
StructuredOutput string
|
||||
}
|
||||
|
||||
// Configuration errors from presets.FromSettings. Each is distinct and
|
||||
// matches with errors.Is.
|
||||
var (
|
||||
// ErrUnknownProvider means Settings.Provider names no preset.
|
||||
ErrUnknownProvider = errors.New("llm: unknown provider")
|
||||
// ErrMissingKey means the preset requires an API key and none was
|
||||
// given.
|
||||
ErrMissingKey = errors.New("llm: API key is required")
|
||||
// ErrUnexpectedKey means an API key was given to a preset that sends
|
||||
// none.
|
||||
ErrUnexpectedKey = errors.New("llm: provider takes no API key")
|
||||
// ErrInvalidKey means the API key is not a valid HTTP header value:
|
||||
// it has a control character such as CR or LF.
|
||||
ErrInvalidKey = errors.New("llm: API key is not a valid header value")
|
||||
// ErrMissingBaseURL means the preset requires a base URL and none was
|
||||
// given.
|
||||
ErrMissingBaseURL = errors.New("llm: base URL is required")
|
||||
// ErrMissingModel means Settings.Model is empty.
|
||||
ErrMissingModel = errors.New("llm: model is required")
|
||||
// ErrMissingPublicURL means the preset requires Settings.PublicURL and
|
||||
// it is empty.
|
||||
ErrMissingPublicURL = errors.New("llm: public URL is required")
|
||||
// ErrInvalidSetting means a setting has a value the preset does not
|
||||
// accept, such as a base URL other than the preset's, an unsupported
|
||||
// structured output mode or a negative temperature.
|
||||
ErrInvalidSetting = errors.New("llm: invalid setting")
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: jobs.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const claimNextJob = `-- name: ClaimNextJob :one
|
||||
UPDATE jobs
|
||||
SET status = 'running', started_at = ?1, updated_at = ?1
|
||||
WHERE id = (SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1)
|
||||
RETURNING id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at
|
||||
`
|
||||
|
||||
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
||||
// when no job is queued.
|
||||
func (q *Queries) ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error) {
|
||||
row := q.db.QueryRowContext(ctx, claimNextJob, now)
|
||||
var i Job
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Kind,
|
||||
&i.Status,
|
||||
&i.Payload,
|
||||
&i.Result,
|
||||
&i.Error,
|
||||
&i.StartedAt,
|
||||
&i.FinishedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createJob = `-- name: CreateJob :exec
|
||||
INSERT INTO jobs (id, kind, status, payload, created_at, updated_at)
|
||||
VALUES (?, ?, 'queued', ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateJobParams struct {
|
||||
ID string
|
||||
Kind string
|
||||
Payload string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateJob(ctx context.Context, arg CreateJobParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createJob,
|
||||
arg.ID,
|
||||
arg.Kind,
|
||||
arg.Payload,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const failJob = `-- name: FailJob :execrows
|
||||
UPDATE jobs
|
||||
SET status = 'failed', error = ?1, finished_at = ?2, updated_at = ?2
|
||||
WHERE id = ?3 AND status = 'running'
|
||||
`
|
||||
|
||||
type FailJobParams struct {
|
||||
Error sql.NullString
|
||||
Now sql.NullString
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) FailJob(ctx context.Context, arg FailJobParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, failJob, arg.Error, arg.Now, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const failRunningJobs = `-- name: FailRunningJobs :execrows
|
||||
UPDATE jobs
|
||||
SET status = 'failed', error = ?1, finished_at = ?2, updated_at = ?2
|
||||
WHERE status = 'running'
|
||||
`
|
||||
|
||||
type FailRunningJobsParams struct {
|
||||
Error sql.NullString
|
||||
Now sql.NullString
|
||||
}
|
||||
|
||||
// Fails every job left running, at boot before any worker starts.
|
||||
func (q *Queries) FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, failRunningJobs, arg.Error, arg.Now)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getJob = `-- name: GetJob :one
|
||||
SELECT id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at FROM jobs
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetJob(ctx context.Context, id string) (Job, error) {
|
||||
row := q.db.QueryRowContext(ctx, getJob, id)
|
||||
var i Job
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Kind,
|
||||
&i.Status,
|
||||
&i.Payload,
|
||||
&i.Result,
|
||||
&i.Error,
|
||||
&i.StartedAt,
|
||||
&i.FinishedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const publishJobResult = `-- name: PublishJobResult :execrows
|
||||
UPDATE jobs
|
||||
SET result = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'running'
|
||||
`
|
||||
|
||||
type PublishJobResultParams struct {
|
||||
Result sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Stores a partial result of a running job.
|
||||
func (q *Queries) PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, publishJobResult, arg.Result, arg.UpdatedAt, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const succeedJob = `-- name: SucceedJob :execrows
|
||||
UPDATE jobs
|
||||
SET status = 'succeeded', result = ?1, finished_at = ?2, updated_at = ?2
|
||||
WHERE id = ?3 AND status = 'running'
|
||||
`
|
||||
|
||||
type SucceedJobParams struct {
|
||||
Result sql.NullString
|
||||
Now sql.NullString
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, succeedJob, arg.Result, arg.Now, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
@@ -6,17 +6,25 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error)
|
||||
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
||||
// when no job is queued.
|
||||
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
|
||||
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
|
||||
CreateJob(ctx context.Context, arg CreateJobParams) error
|
||||
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
|
||||
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
|
||||
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
|
||||
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
||||
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
|
||||
// Fails every job left running, at boot before any worker starts.
|
||||
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetAISettings(ctx context.Context) (AiSetting, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
@@ -26,6 +34,7 @@ type Querier interface {
|
||||
GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error)
|
||||
GetJob(ctx context.Context, id string) (Job, error)
|
||||
// Finds stored image bytes by content hash. The schema does not make sha256
|
||||
// unique, so the earliest-created (lowest id) match is returned.
|
||||
GetPhotoBySHA256(ctx context.Context, sha256 string) (Photo, error)
|
||||
@@ -36,8 +45,11 @@ type Querier interface {
|
||||
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
||||
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
||||
// Stores a partial result of a running job.
|
||||
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
||||
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
|
||||
// Creates the single row (id 1) or replaces every column except id and
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package household
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// DefaultPersonaPrompt is the default AI system prompt (CTXD PROMPTS.MD §4).
|
||||
// SeedAISettings stores it on first boot and the Admin reset restores it.
|
||||
const DefaultPersonaPrompt = `You are MealQuest, a dinner planning assistant for one household. Suggest dinners
|
||||
similar to their regular rotation, nudging them to try something a little new each
|
||||
week. Prefer weeknight-friendly recipes with common grocery-store ingredients.
|
||||
Flag crock pot meals and anything 30 minutes or less as "quick". Keep explanations
|
||||
short, warm and specific — mention the household's own regular dinners when you
|
||||
explain why something fits. Never suggest a dinner they already make or have
|
||||
declined.`
|
||||
|
||||
// Seed values for the AI settings (CTXD CONTEXT.MD). The base URL and model
|
||||
// are empty until the household picks a provider preset; there is no key.
|
||||
const (
|
||||
SeedAIProvider = "anthropic"
|
||||
SeedAIBaseURL = ""
|
||||
SeedAIModel = ""
|
||||
SeedAITemperature = 0.7
|
||||
)
|
||||
|
||||
// seedNotificationPrefs are the notification preferences for a new
|
||||
// household, one per event: push for every event, chat only for the plan
|
||||
// and the shopping list.
|
||||
var seedNotificationPrefs = []struct {
|
||||
Event string
|
||||
Push, Chat bool
|
||||
}{
|
||||
{Event: "plan_ready", Push: true, Chat: true},
|
||||
{Event: "list_ready", Push: true, Chat: true},
|
||||
{Event: "day_of_reminder", Push: true, Chat: false},
|
||||
{Event: "new_suggestions", Push: true, Chat: false},
|
||||
{Event: "rotation_review", Push: true, Chat: false},
|
||||
}
|
||||
|
||||
// SeedAISettings creates the AI settings row with the seed values and
|
||||
// DefaultPersonaPrompt if it does not exist. An existing row is never
|
||||
// changed.
|
||||
func SeedAISettings(ctx context.Context, st *store.Store) error {
|
||||
return st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
_, err := q.GetAISettings(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("household: read AI settings: %w", err)
|
||||
}
|
||||
now := store.Now().String()
|
||||
_, err = q.UpsertAISettings(ctx, db.UpsertAISettingsParams{
|
||||
Provider: SeedAIProvider,
|
||||
BaseUrl: SeedAIBaseURL,
|
||||
Model: SeedAIModel,
|
||||
Temperature: SeedAITemperature,
|
||||
SystemPrompt: sql.NullString{String: DefaultPersonaPrompt, Valid: true},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: seed AI settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SeedNotificationPrefs creates the seed notification preference row of
|
||||
// every event that has none. Existing rows are never changed.
|
||||
func SeedNotificationPrefs(ctx context.Context, st *store.Store) error {
|
||||
return st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
now := store.Now().String()
|
||||
for _, p := range seedNotificationPrefs {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO notification_prefs (event, push, chat, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (event) DO NOTHING`,
|
||||
p.Event, p.Push, p.Chat, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: seed notification preference %s: %w", p.Event, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package household
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// notificationPrefs returns the stored notification preferences by event as
|
||||
// [push, chat].
|
||||
func notificationPrefs(t *testing.T, st *store.Store) map[string][2]int64 {
|
||||
t.Helper()
|
||||
rows, err := st.Reader.QueryContext(context.Background(), `SELECT event, push, chat FROM notification_prefs`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
got := make(map[string][2]int64)
|
||||
for rows.Next() {
|
||||
var event string
|
||||
var push, chat int64
|
||||
if err := rows.Scan(&event, &push, &chat); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got[event] = [2]int64{push, chat}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestSeedAISettingsCreatesRow(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
st := migratedStore(t, t.TempDir())
|
||||
|
||||
if err := SeedAISettings(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := db.New(st.Reader).GetAISettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := db.AiSetting{
|
||||
ID: 1,
|
||||
Provider: "anthropic",
|
||||
BaseUrl: "",
|
||||
Model: "",
|
||||
Temperature: 0.7,
|
||||
SystemPrompt: sql.NullString{String: DefaultPersonaPrompt, Valid: true},
|
||||
CreatedAt: got.CreatedAt,
|
||||
UpdatedAt: got.UpdatedAt,
|
||||
}
|
||||
if got.ID != want.ID || got.Provider != want.Provider || got.BaseUrl != want.BaseUrl ||
|
||||
got.Model != want.Model || got.Temperature != want.Temperature || got.SystemPrompt != want.SystemPrompt ||
|
||||
got.ApiKeyCt != nil || got.ApiKeyLast4.Valid || got.KeyVerifiedAt.Valid {
|
||||
t.Errorf("seeded AI settings = %+v\nwant %+v", got, want)
|
||||
}
|
||||
var created store.Timestamp
|
||||
if err := created.Scan(got.CreatedAt); err != nil {
|
||||
t.Errorf("created_at: %v", err)
|
||||
}
|
||||
if got.UpdatedAt != got.CreatedAt {
|
||||
t.Errorf("updated_at = %q, want created_at %q", got.UpdatedAt, got.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPersonaPrompt(t *testing.T) {
|
||||
t.Parallel()
|
||||
const want = "You are MealQuest, a dinner planning assistant for one household. Suggest dinners\n" +
|
||||
"similar to their regular rotation, nudging them to try something a little new each\n" +
|
||||
"week. Prefer weeknight-friendly recipes with common grocery-store ingredients.\n" +
|
||||
"Flag crock pot meals and anything 30 minutes or less as \"quick\". Keep explanations\n" +
|
||||
"short, warm and specific — mention the household's own regular dinners when you\n" +
|
||||
"explain why something fits. Never suggest a dinner they already make or have\n" +
|
||||
"declined."
|
||||
if DefaultPersonaPrompt != want {
|
||||
t.Errorf("DefaultPersonaPrompt = %q\nwant %q", DefaultPersonaPrompt, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedAISettingsNeverOverwrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
|
||||
// First boot seeds; the household then configures a provider.
|
||||
first := migratedStore(t, dir)
|
||||
if err := SeedAISettings(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seeded, err := db.New(first.Reader).GetAISettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed, err := db.New(first.Writer).UpsertAISettings(ctx, db.UpsertAISettingsParams{
|
||||
Provider: "ollama",
|
||||
BaseUrl: "http://ollama:11434",
|
||||
Model: "llama3",
|
||||
Temperature: 0.2,
|
||||
SystemPrompt: sql.NullString{String: "Be brief.", Valid: true},
|
||||
ApiKeyCt: []byte{1, 2, 3},
|
||||
ApiKeyLast4: sql.NullString{String: "wxyz", Valid: true},
|
||||
KeyVerifiedAt: sql.NullString{String: store.Now().String(), Valid: true},
|
||||
CreatedAt: seeded.CreatedAt,
|
||||
UpdatedAt: store.Now().String(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A later boot reopens the same database and seeds again.
|
||||
st := migratedStore(t, dir)
|
||||
if err := SeedAISettings(ctx, st); err != nil {
|
||||
t.Fatalf("reboot: %v", err)
|
||||
}
|
||||
got, err := db.New(st.Reader).GetAISettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Provider != changed.Provider || got.BaseUrl != changed.BaseUrl || got.Model != changed.Model ||
|
||||
got.Temperature != changed.Temperature || got.SystemPrompt != changed.SystemPrompt ||
|
||||
string(got.ApiKeyCt) != string(changed.ApiKeyCt) || got.ApiKeyLast4 != changed.ApiKeyLast4 ||
|
||||
got.KeyVerifiedAt != changed.KeyVerifiedAt || got.CreatedAt != changed.CreatedAt || got.UpdatedAt != changed.UpdatedAt {
|
||||
t.Errorf("reboot changed AI settings:\n got %+v\nwant %+v", got, changed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedNotificationPrefsCreatesRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
st := migratedStore(t, t.TempDir())
|
||||
|
||||
if err := SeedNotificationPrefs(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string][2]int64{
|
||||
"plan_ready": {1, 1},
|
||||
"list_ready": {1, 1},
|
||||
"day_of_reminder": {1, 0},
|
||||
"new_suggestions": {1, 0},
|
||||
"rotation_review": {1, 0},
|
||||
}
|
||||
got := notificationPrefs(t, st)
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("seeded %d notification preferences, want %d: %v", len(got), len(want), got)
|
||||
}
|
||||
for event, w := range want {
|
||||
if g, ok := got[event]; !ok || g != w {
|
||||
t.Errorf("%s [push, chat] = %v (present %t), want %v", event, g, ok, w)
|
||||
}
|
||||
}
|
||||
|
||||
var created, updated string
|
||||
err := st.Reader.QueryRowContext(ctx, `SELECT created_at, updated_at FROM notification_prefs WHERE event = 'plan_ready'`).Scan(&created, &updated)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ts store.Timestamp
|
||||
if err := ts.Scan(created); err != nil {
|
||||
t.Errorf("created_at: %v", err)
|
||||
}
|
||||
if updated != created {
|
||||
t.Errorf("updated_at = %q, want created_at %q", updated, created)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedNotificationPrefsNeverOverwrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
|
||||
// First boot seeds; the household then changes two preferences, and one
|
||||
// event's row goes missing.
|
||||
first := migratedStore(t, dir)
|
||||
if err := SeedNotificationPrefs(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
`UPDATE notification_prefs SET push = 0, chat = 0 WHERE event = 'plan_ready'`,
|
||||
`UPDATE notification_prefs SET chat = 1 WHERE event = 'rotation_review'`,
|
||||
`DELETE FROM notification_prefs WHERE event = 'new_suggestions'`,
|
||||
} {
|
||||
if _, err := first.Writer.ExecContext(ctx, stmt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A later boot keeps the changed rows and seeds only the missing one.
|
||||
st := migratedStore(t, dir)
|
||||
if err := SeedNotificationPrefs(ctx, st); err != nil {
|
||||
t.Fatalf("reboot: %v", err)
|
||||
}
|
||||
want := map[string][2]int64{
|
||||
"plan_ready": {0, 0},
|
||||
"list_ready": {1, 1},
|
||||
"day_of_reminder": {1, 0},
|
||||
"new_suggestions": {1, 0},
|
||||
"rotation_review": {1, 1},
|
||||
}
|
||||
got := notificationPrefs(t, st)
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("%d notification preferences after reboot, want %d: %v", len(got), len(want), got)
|
||||
}
|
||||
for event, w := range want {
|
||||
if g, ok := got[event]; !ok || g != w {
|
||||
t.Errorf("after reboot %s [push, chat] = %v (present %t), want %v", event, g, ok, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// KindNoop is the framework's built-in job kind: it does no work and
|
||||
// succeeds with {"ok":true}. It is the runner's own trivial kind, as Feature
|
||||
// 09's trivial test skill is the harness's, and the startup and readiness
|
||||
// job: a noop job reaching succeeded proves the runner claims, runs and
|
||||
// records jobs.
|
||||
const KindNoop = "noop"
|
||||
|
||||
// NoopPayload is the payload of a noop job.
|
||||
type NoopPayload struct{}
|
||||
|
||||
// NoopResult is the result of a noop job.
|
||||
type NoopResult struct{}
|
||||
|
||||
// PublicJSON returns {"ok":true}.
|
||||
func (NoopResult) PublicJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
OK bool `json:"ok"`
|
||||
}{OK: true})
|
||||
}
|
||||
|
||||
func runNoop(context.Context, NoopPayload, func(NoopResult) error) (NoopResult, error) {
|
||||
return NoopResult{}, nil
|
||||
}
|
||||
|
||||
// Builtins enqueues the framework's built-in job kinds.
|
||||
type Builtins struct {
|
||||
Noop *Kind[NoopPayload]
|
||||
}
|
||||
|
||||
// RegisterBuiltins registers the framework's built-in job kinds on r.
|
||||
func RegisterBuiltins(r *Runner) (*Builtins, error) {
|
||||
noop, err := Register(r, KindNoop, runNoop)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Builtins{Noop: noop}, nil
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
// Package jobs runs long work in the background. A job is a row in the jobs
|
||||
// table: Enqueue inserts it queued, and a fixed pool of workers claims queued
|
||||
// jobs oldest first, so the table itself is the queue and a job enqueued
|
||||
// before a restart still runs after it.
|
||||
//
|
||||
// Each job kind has one handler, registered before Start. A handler takes a
|
||||
// typed payload and returns a result implementing Public; it may publish
|
||||
// partial results while it runs. The runner stores only what a result's
|
||||
// PublicJSON returns and, for a failed job, only a failure code: either a
|
||||
// *Failure code the kind declared at registration or one of the runner's own
|
||||
// codes. Handler error text, which may quote a URL, token or response body
|
||||
// from a dependency, is never stored, returned by the API or logged.
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Status is the state of a job.
|
||||
type Status string
|
||||
|
||||
// Job states. A job moves queued → running → succeeded or failed.
|
||||
const (
|
||||
StatusQueued Status = "queued"
|
||||
StatusRunning Status = "running"
|
||||
StatusSucceeded Status = "succeeded"
|
||||
StatusFailed Status = "failed"
|
||||
)
|
||||
|
||||
// Failure codes the runner stores itself. A kind cannot declare them.
|
||||
const (
|
||||
// CodeInterrupted is the error of a job a previous process left running.
|
||||
// It is fixed framework text, not a snake-case code, so no kind can
|
||||
// declare it.
|
||||
CodeInterrupted = "interrupted by restart"
|
||||
// CodeInternal is the error of a job whose handler failed with anything
|
||||
// but a *Failure with a code its kind declared, or whose payload or
|
||||
// result could not be encoded. The underlying error is neither stored
|
||||
// nor logged.
|
||||
CodeInternal = "internal_error"
|
||||
// CodeUnknownKind is the error of a job whose kind has no handler.
|
||||
CodeUnknownKind = "unknown_kind"
|
||||
)
|
||||
|
||||
// Claim retry backoff. A worker whose claim fails with a database error
|
||||
// retries after claimRetryMin, doubling up to claimRetryMax, so a job queued
|
||||
// when the error clears is claimed without waiting for a wake token.
|
||||
const (
|
||||
claimRetryMin = 50 * time.Millisecond
|
||||
claimRetryMax = 5 * time.Second
|
||||
)
|
||||
|
||||
// codePattern is the form of every failure code: lower-case snake case.
|
||||
var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
||||
|
||||
// ErrNotFound is returned by Get for an unknown job id.
|
||||
var ErrNotFound = errors.New("jobs: job not found")
|
||||
|
||||
// ErrUnknownKind is returned by Runner.Enqueue for a kind with no
|
||||
// registered handler.
|
||||
var ErrUnknownKind = errors.New("jobs: unknown job kind")
|
||||
|
||||
// ErrInvalidPayload is returned by Runner.Enqueue for a payload that does
|
||||
// not decode into the kind's payload type.
|
||||
var ErrInvalidPayload = errors.New("jobs: payload does not decode")
|
||||
|
||||
// Failure is a handler error reporting a failure code the household may
|
||||
// see. Code must be one the job's kind declared at registration; the runner
|
||||
// stores it as the job's error. Any other handler error, including a Failure
|
||||
// with an undeclared code, is stored as CodeInternal.
|
||||
type Failure struct {
|
||||
Code string
|
||||
}
|
||||
|
||||
func (f *Failure) Error() string { return "jobs: failure " + f.Code }
|
||||
|
||||
// Public is a job result. PublicJSON returns the JSON the runner stores and
|
||||
// GET /api/v1/jobs/{id} returns, so a result type states exactly which of its
|
||||
// data is public; nothing else of the value is ever encoded.
|
||||
type Public interface {
|
||||
PublicJSON() ([]byte, error)
|
||||
}
|
||||
|
||||
// Handler runs one job with its payload. It may call publish any number of
|
||||
// times with a partial result, which Get returns while the job runs; its
|
||||
// return value is the final result. ctx is cancelled when the runner stops.
|
||||
type Handler[P any, R Public] func(ctx context.Context, payload P, publish func(partial R) error) (R, error)
|
||||
|
||||
// Job is a job as reported by GET /api/v1/jobs/{id}. Result is the latest
|
||||
// partial or final result, and is null until the handler publishes one.
|
||||
// Error is set only on a failed job.
|
||||
type Job struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Status Status `json:"status"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *string `json:"error"`
|
||||
}
|
||||
|
||||
// runFunc is a registered Handler with its payload and results as JSON. It
|
||||
// returns the failure code to store, or "" on success; the logged reason
|
||||
// never quotes an error.
|
||||
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
|
||||
|
||||
// handler is a registered kind: run runs a job, and check reports whether
|
||||
// a payload decodes into the kind's payload type.
|
||||
type handler struct {
|
||||
run runFunc
|
||||
check func(payload []byte) bool
|
||||
}
|
||||
|
||||
// Runner owns the job handlers and the worker pool.
|
||||
type Runner struct {
|
||||
st *store.Store
|
||||
logger *slog.Logger
|
||||
workers int
|
||||
// wake holds up to one token per worker; Enqueue adds one so an idle
|
||||
// worker looks for the new job.
|
||||
wake chan struct{}
|
||||
// claim claims the oldest queued job, marking it running at now.
|
||||
claim func(ctx context.Context, now sql.NullString) (db.Job, error)
|
||||
|
||||
mu sync.Mutex
|
||||
handlers map[string]handler
|
||||
started bool
|
||||
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewRunner returns a runner over st with workers concurrent workers.
|
||||
func NewRunner(st *store.Store, logger *slog.Logger, workers int) (*Runner, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("jobs: nil store")
|
||||
}
|
||||
if logger == nil {
|
||||
return nil, errors.New("jobs: nil logger")
|
||||
}
|
||||
if workers < 1 {
|
||||
return nil, fmt.Errorf("jobs: %d workers, want at least 1", workers)
|
||||
}
|
||||
return &Runner{
|
||||
st: st,
|
||||
logger: logger,
|
||||
workers: workers,
|
||||
wake: make(chan struct{}, workers),
|
||||
claim: db.New(st.Writer).ClaimNextJob,
|
||||
handlers: make(map[string]handler),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Kind enqueues jobs of one registered kind.
|
||||
type Kind[P any] struct {
|
||||
r *Runner
|
||||
name string
|
||||
}
|
||||
|
||||
// Register makes h the handler for kind and returns the Kind that enqueues
|
||||
// its jobs. failureCodes are the *Failure codes h may report. It must be
|
||||
// called before Start, once per kind.
|
||||
func Register[P any, R Public](r *Runner, kind string, h Handler[P, R], failureCodes ...string) (*Kind[P], error) {
|
||||
if kind == "" {
|
||||
return nil, errors.New("jobs: register: empty kind")
|
||||
}
|
||||
if h == nil {
|
||||
return nil, fmt.Errorf("jobs: register %q: nil handler", kind)
|
||||
}
|
||||
declared := make(map[string]bool, len(failureCodes))
|
||||
for _, code := range failureCodes {
|
||||
if !codePattern.MatchString(code) {
|
||||
return nil, fmt.Errorf("jobs: register %q: failure code %q is not lower-case snake case", kind, code)
|
||||
}
|
||||
if code == CodeInternal || code == CodeUnknownKind {
|
||||
return nil, fmt.Errorf("jobs: register %q: failure code %q is reserved for the runner", kind, code)
|
||||
}
|
||||
declared[code] = true
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.started {
|
||||
return nil, fmt.Errorf("jobs: register %q: runner already started", kind)
|
||||
}
|
||||
if _, ok := r.handlers[kind]; ok {
|
||||
return nil, fmt.Errorf("jobs: register %q: kind already registered", kind)
|
||||
}
|
||||
run := func(ctx context.Context, payload string, publish func(json.RawMessage) error) (json.RawMessage, string, string) {
|
||||
p, err := decodePayload[P]([]byte(payload))
|
||||
if err != nil {
|
||||
return nil, CodeInternal, "payload does not decode"
|
||||
}
|
||||
result, err := h(ctx, p, func(partial R) error {
|
||||
data, err := publicJSON(partial)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return publish(data)
|
||||
})
|
||||
if err != nil {
|
||||
var failure *Failure
|
||||
if !errors.As(err, &failure) {
|
||||
return nil, CodeInternal, "handler error"
|
||||
}
|
||||
if !declared[failure.Code] {
|
||||
return nil, CodeInternal, "undeclared failure code"
|
||||
}
|
||||
return nil, failure.Code, "handler failure"
|
||||
}
|
||||
data, err := publicJSON(result)
|
||||
if err != nil {
|
||||
return nil, CodeInternal, "result does not encode"
|
||||
}
|
||||
return data, "", ""
|
||||
}
|
||||
check := func(payload []byte) bool {
|
||||
_, err := decodePayload[P](payload)
|
||||
return err == nil
|
||||
}
|
||||
r.handlers[kind] = handler{run: run, check: check}
|
||||
return &Kind[P]{r: r, name: kind}, nil
|
||||
}
|
||||
|
||||
// decodePayload decodes payload into P, refusing unknown fields.
|
||||
func decodePayload[P any](payload []byte) (P, error) {
|
||||
var p P
|
||||
dec := json.NewDecoder(bytes.NewReader(payload))
|
||||
dec.DisallowUnknownFields()
|
||||
err := dec.Decode(&p)
|
||||
return p, err
|
||||
}
|
||||
|
||||
// publicJSON returns v's PublicJSON, refusing anything that is not one JSON
|
||||
// value. Its error never quotes PublicJSON's error.
|
||||
func publicJSON(v Public) (json.RawMessage, error) {
|
||||
data, err := v.PublicJSON()
|
||||
if err != nil || !json.Valid(data) {
|
||||
return nil, errors.New("jobs: result has no valid public JSON")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Enqueue stores a queued job with payload and returns its id. A job
|
||||
// enqueued before Start waits for it.
|
||||
func (k *Kind[P]) Enqueue(ctx context.Context, payload P) (string, error) {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jobs: enqueue %s: encode payload: %w", k.name, err)
|
||||
}
|
||||
return k.r.enqueue(ctx, k.name, data)
|
||||
}
|
||||
|
||||
// Enqueue stores a queued job of kind with its JSON payload and returns its
|
||||
// id, as Kind.Enqueue does for a kind known at compile time. It returns
|
||||
// ErrUnknownKind for a kind with no registered handler and
|
||||
// ErrInvalidPayload for a payload that does not decode into the kind's
|
||||
// payload type; neither stores a job.
|
||||
func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessage) (string, error) {
|
||||
r.mu.Lock()
|
||||
h, ok := r.handlers[kind]
|
||||
r.mu.Unlock()
|
||||
if !ok {
|
||||
return "", ErrUnknownKind
|
||||
}
|
||||
if !h.check(payload) {
|
||||
return "", ErrInvalidPayload
|
||||
}
|
||||
return r.enqueue(ctx, kind, payload)
|
||||
}
|
||||
|
||||
// enqueue stores a queued job of kind with its encoded payload and wakes a
|
||||
// worker.
|
||||
func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (string, error) {
|
||||
id := store.NewID()
|
||||
now := store.Now().String()
|
||||
if err := db.New(r.st.Writer).CreateJob(ctx, db.CreateJobParams{
|
||||
ID: id,
|
||||
Kind: kind,
|
||||
Payload: string(payload),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("jobs: enqueue %s: %w", kind, err)
|
||||
}
|
||||
select {
|
||||
case r.wake <- struct{}{}:
|
||||
default: // every worker already has a token and will look again
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Start fails every job a previous process left running with
|
||||
// CodeInterrupted, then starts the workers. They stop when ctx is cancelled;
|
||||
// Wait waits for them. A job whose handler returns an error after ctx is
|
||||
// cancelled is left running, so the next Start fails it as interrupted.
|
||||
func (r *Runner) Start(ctx context.Context) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.started {
|
||||
return errors.New("jobs: runner already started")
|
||||
}
|
||||
now := store.Now().String()
|
||||
n, err := db.New(r.st.Writer).FailRunningJobs(ctx, db.FailRunningJobsParams{
|
||||
Error: sql.NullString{String: CodeInterrupted, Valid: true},
|
||||
Now: sql.NullString{String: now, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("jobs: fail interrupted jobs: %w", err)
|
||||
}
|
||||
if n > 0 {
|
||||
r.logger.WarnContext(ctx, "failed jobs interrupted by restart", "count", n)
|
||||
}
|
||||
r.started = true
|
||||
for range r.workers {
|
||||
r.wg.Add(1)
|
||||
go r.work(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait blocks until every worker has stopped.
|
||||
func (r *Runner) Wait() {
|
||||
r.wg.Wait()
|
||||
}
|
||||
|
||||
// Get returns the job with id, or ErrNotFound.
|
||||
func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
|
||||
row, err := db.New(r.st.Reader).GetJob(ctx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Job{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Job{}, fmt.Errorf("jobs: read job: %w", err)
|
||||
}
|
||||
job := Job{ID: row.ID, Kind: row.Kind, Status: Status(row.Status)}
|
||||
if row.Result.Valid {
|
||||
job.Result = json.RawMessage(row.Result.String)
|
||||
}
|
||||
if row.Error.Valid {
|
||||
job.Error = &row.Error.String
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// work claims and runs queued jobs until ctx is cancelled, waiting for a
|
||||
// wake token whenever none is queued. A claim that fails with a database
|
||||
// error is retried with backoff rather than waiting for a token, so a job
|
||||
// already queued is not stranded by a transient error.
|
||||
func (r *Runner) work(ctx context.Context) {
|
||||
defer r.wg.Done()
|
||||
backoff := claimRetryMin
|
||||
for ctx.Err() == nil {
|
||||
job, err := r.claim(ctx, sql.NullString{String: store.Now().String(), Valid: true})
|
||||
switch {
|
||||
case err == nil:
|
||||
backoff = claimRetryMin
|
||||
r.run(ctx, job)
|
||||
continue
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
backoff = claimRetryMin
|
||||
select {
|
||||
case <-r.wake:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
continue
|
||||
case ctx.Err() != nil:
|
||||
return
|
||||
}
|
||||
r.logger.ErrorContext(ctx, "claim job failed; retrying", "reason", "database error", "retry_in", backoff)
|
||||
timer := time.NewTimer(backoff)
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
}
|
||||
backoff = min(2*backoff, claimRetryMax)
|
||||
}
|
||||
}
|
||||
|
||||
// run runs a claimed job to completion. The final write is made even when
|
||||
// ctx has been cancelled, so a handler that finishes during shutdown is
|
||||
// recorded.
|
||||
func (r *Runner) run(ctx context.Context, job db.Job) {
|
||||
write := context.WithoutCancel(ctx)
|
||||
logger := r.logger.With("job_id", job.ID, "kind", job.Kind)
|
||||
q := db.New(r.st.Writer)
|
||||
|
||||
h, ok := r.handlers[job.Kind] // read-only once started
|
||||
if !ok {
|
||||
logger.ErrorContext(ctx, "job failed", "code", CodeUnknownKind, "reason", "no handler registered for the kind")
|
||||
r.fail(write, logger, job.ID, CodeUnknownKind)
|
||||
return
|
||||
}
|
||||
|
||||
publish := func(partial json.RawMessage) error {
|
||||
n, err := q.PublishJobResult(ctx, db.PublishJobResultParams{
|
||||
Result: sql.NullString{String: string(partial), Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: job.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("jobs: publish result: %w", err)
|
||||
}
|
||||
if n != 1 {
|
||||
return fmt.Errorf("jobs: publish result: job %s is not running", job.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
result, code, reason := h.run(ctx, job.Payload, publish)
|
||||
if code != "" {
|
||||
if ctx.Err() != nil {
|
||||
logger.WarnContext(write, "job interrupted by shutdown; it fails at the next start")
|
||||
return
|
||||
}
|
||||
level := slog.LevelInfo
|
||||
if code == CodeInternal {
|
||||
level = slog.LevelError
|
||||
}
|
||||
logger.Log(ctx, level, "job failed", "code", code, "reason", reason)
|
||||
r.fail(write, logger, job.ID, code)
|
||||
return
|
||||
}
|
||||
|
||||
n, err := q.SucceedJob(write, db.SucceedJobParams{
|
||||
Result: sql.NullString{String: string(result), Valid: true},
|
||||
Now: sql.NullString{String: store.Now().String(), Valid: true},
|
||||
ID: job.ID,
|
||||
})
|
||||
if err == nil && n != 1 {
|
||||
err = errors.New("job is not running")
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(write, "record job success", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fail records the job as failed with code. The record errors it logs come
|
||||
// from the database, never from a handler.
|
||||
func (r *Runner) fail(ctx context.Context, logger *slog.Logger, id, code string) {
|
||||
n, err := db.New(r.st.Writer).FailJob(ctx, db.FailJobParams{
|
||||
Error: sql.NullString{String: code, Valid: true},
|
||||
Now: sql.NullString{String: store.Now().String(), Valid: true},
|
||||
ID: id,
|
||||
})
|
||||
if err == nil && n != 1 {
|
||||
err = errors.New("job is not running")
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "record job failure", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// sentinelSecret stands in for a credential a handler holds while it runs.
|
||||
const sentinelSecret = "sk-sentinel-5f2c9a"
|
||||
|
||||
type countPayload struct {
|
||||
Steps int `json:"steps"`
|
||||
}
|
||||
|
||||
type countResult struct {
|
||||
Done int `json:"done"`
|
||||
}
|
||||
|
||||
func (c countResult) PublicJSON() ([]byte, error) { return json.Marshal(c) }
|
||||
|
||||
func migratedStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func newRunner(t *testing.T, st *store.Store, workers int) *Runner {
|
||||
t.Helper()
|
||||
return newRunnerLogging(t, st, workers, io.Discard)
|
||||
}
|
||||
|
||||
func newRunnerLogging(t *testing.T, st *store.Store, workers int, w io.Writer) *Runner {
|
||||
t.Helper()
|
||||
r, err := NewRunner(st, slog.New(slog.NewTextHandler(w, nil)), workers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// logBuffer collects log output written by concurrent workers.
|
||||
type logBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *logBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *logBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// start starts r and stops it when the test ends. The returned function
|
||||
// stops it early and waits for its workers.
|
||||
func start(t *testing.T, r *Runner) (stop func()) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := r.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
stop = func() {
|
||||
cancel()
|
||||
r.Wait()
|
||||
}
|
||||
t.Cleanup(stop)
|
||||
return stop
|
||||
}
|
||||
|
||||
func register[P any, R Public](t *testing.T, r *Runner, kind string, h Handler[P, R], failureCodes ...string) *Kind[P] {
|
||||
t.Helper()
|
||||
k, err := Register(r, kind, h, failureCodes...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
func enqueue[P any](t *testing.T, k *Kind[P], payload P) string {
|
||||
t.Helper()
|
||||
id, err := k.Enqueue(context.Background(), payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// waitFor polls Get until done accepts the job, failing after five seconds.
|
||||
func waitFor(t *testing.T, r *Runner, id string, done func(Job) bool) Job {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
job, err := r.Get(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if done(job) {
|
||||
return job
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job %s never reached the expected state; last %+v (result %s)", id, job, job.Result)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func finished(j Job) bool { return j.Status == StatusSucceeded || j.Status == StatusFailed }
|
||||
|
||||
// rawRow returns every column of the job's row joined, for leak checks.
|
||||
func rawRow(t *testing.T, st *store.Store, id string) string {
|
||||
t.Helper()
|
||||
var cols [10]any
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range cols {
|
||||
ptrs[i] = &cols[i]
|
||||
}
|
||||
if err := st.Reader.QueryRow("SELECT * FROM jobs WHERE id = ?", id).Scan(ptrs...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fmt.Sprint(cols[:]...)
|
||||
}
|
||||
|
||||
func TestJobSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
r := newRunner(t, st, 2)
|
||||
count := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
start(t, r)
|
||||
|
||||
id := enqueue(t, count, countPayload{Steps: 3})
|
||||
job := waitFor(t, r, id, finished)
|
||||
if job.ID != id || job.Kind != "count" || job.Status != StatusSucceeded || job.Error != nil {
|
||||
t.Fatalf("job = %+v, want succeeded count job %s with no error", job, id)
|
||||
}
|
||||
if string(job.Result) != `{"done":3}` {
|
||||
t.Errorf("result = %s, want {\"done\":3}", job.Result)
|
||||
}
|
||||
|
||||
var startedAt, finishedAt, createdAt, updatedAt, payload string
|
||||
if err := st.Reader.QueryRow("SELECT started_at, finished_at, created_at, updated_at, payload FROM jobs WHERE id = ?", id).
|
||||
Scan(&startedAt, &finishedAt, &createdAt, &updatedAt, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, v := range map[string]string{"started_at": startedAt, "finished_at": finishedAt, "created_at": createdAt, "updated_at": updatedAt} {
|
||||
var ts store.Timestamp
|
||||
if err := ts.Scan(v); err != nil {
|
||||
t.Errorf("%s = %q: %v", name, v, err)
|
||||
}
|
||||
}
|
||||
if payload != `{"steps":3}` {
|
||||
t.Errorf("payload = %s, want {\"steps\":3}", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobFailsWithFailureCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
r := newRunner(t, st, 1)
|
||||
k := register(t, r, "suggest", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||
return countResult{}, fmt.Errorf("suggest: %w", &Failure{Code: "no_regulars"})
|
||||
}, "no_regulars")
|
||||
start(t, r)
|
||||
|
||||
job := waitFor(t, r, enqueue(t, k, countPayload{}), finished)
|
||||
if job.Status != StatusFailed || job.Error == nil || *job.Error != "no_regulars" {
|
||||
t.Fatalf("job = %+v, want failed with code no_regulars", job)
|
||||
}
|
||||
if job.Result != nil {
|
||||
t.Errorf("result = %s, want none", job.Result)
|
||||
}
|
||||
var stored string
|
||||
if err := st.Reader.QueryRow("SELECT error FROM jobs WHERE id = ?", job.ID).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored != "no_regulars" {
|
||||
t.Errorf("stored error = %q, want only the code", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndeclaredFailureCodeIsInternal(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := newRunner(t, migratedStore(t), 1)
|
||||
k := register(t, r, "suggest", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||
return countResult{}, &Failure{Code: "not_declared"}
|
||||
}, "no_regulars")
|
||||
start(t, r)
|
||||
|
||||
job := waitFor(t, r, enqueue(t, k, countPayload{}), finished)
|
||||
if job.Status != StatusFailed || job.Error == nil || *job.Error != CodeInternal {
|
||||
t.Fatalf("job = %+v, want failed with %q", job, CodeInternal)
|
||||
}
|
||||
}
|
||||
|
||||
// credential holds a secret the way config.Secret does: unexported, so
|
||||
// encoding/json cannot see it.
|
||||
type credential struct{ value string }
|
||||
|
||||
func (credential) String() string { return "[REDACTED]" }
|
||||
|
||||
// leakyResult holds a secret in exported and unexported fields; only
|
||||
// Dinners is public.
|
||||
type leakyResult struct {
|
||||
Dinners []string
|
||||
Key credential
|
||||
Token string
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func (l leakyResult) PublicJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Dinners []string `json:"dinners"`
|
||||
}{l.Dinners})
|
||||
}
|
||||
|
||||
// badResult has no valid public JSON; its encoding error quotes a secret.
|
||||
type badResult struct{}
|
||||
|
||||
func (badResult) PublicJSON() ([]byte, error) {
|
||||
return nil, fmt.Errorf("encode with key %s", sentinelSecret)
|
||||
}
|
||||
|
||||
// TestSecretsNeverStoredOrLogged proves a handler holding a secret cannot
|
||||
// surface it in the jobs row, Get's result or the log through its result,
|
||||
// partial results, *Failure, error text or result encoding error.
|
||||
func TestSecretsNeverStoredOrLogged(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
var logs logBuffer
|
||||
r := newRunnerLogging(t, st, 1, &logs)
|
||||
secretResult := leakyResult{Dinners: []string{"Tacos"}, Key: credential{sentinelSecret}, Token: sentinelSecret, apiKey: sentinelSecret}
|
||||
typed := register(t, r, "typed", func(_ context.Context, _ countPayload, publish func(leakyResult) error) (leakyResult, error) {
|
||||
if err := publish(secretResult); err != nil {
|
||||
return leakyResult{}, err
|
||||
}
|
||||
return secretResult, nil
|
||||
})
|
||||
leakyError := register(t, r, "leaky-error", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
|
||||
return leakyResult{}, fmt.Errorf("GET https://api.example.com/v1?key=%s: 401 %s", sentinelSecret, sentinelSecret)
|
||||
})
|
||||
leakyFailure := register(t, r, "leaky-failure", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
|
||||
return leakyResult{}, &Failure{Code: sentinelSecret}
|
||||
}, "quota_exceeded")
|
||||
failure := register(t, r, "failure", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
|
||||
return leakyResult{}, fmt.Errorf("call with key %s: %w", sentinelSecret, &Failure{Code: "quota_exceeded"})
|
||||
}, "quota_exceeded")
|
||||
badEncoding := register(t, r, "bad-encoding", func(context.Context, countPayload, func(badResult) error) (badResult, error) {
|
||||
return badResult{}, nil
|
||||
})
|
||||
stop := start(t, r)
|
||||
|
||||
cases := []struct {
|
||||
kind *Kind[countPayload]
|
||||
wantStatus Status
|
||||
wantResult string
|
||||
wantError string
|
||||
}{
|
||||
{typed, StatusSucceeded, `{"dinners":["Tacos"]}`, ""},
|
||||
{leakyError, StatusFailed, "", CodeInternal},
|
||||
{leakyFailure, StatusFailed, "", CodeInternal},
|
||||
{failure, StatusFailed, "", "quota_exceeded"},
|
||||
{badEncoding, StatusFailed, "", CodeInternal},
|
||||
}
|
||||
var ids []string
|
||||
for _, c := range cases {
|
||||
job := waitFor(t, r, enqueue(t, c.kind, countPayload{}), finished)
|
||||
ids = append(ids, job.ID)
|
||||
gotError := ""
|
||||
if job.Error != nil {
|
||||
gotError = *job.Error
|
||||
}
|
||||
if job.Status != c.wantStatus || string(job.Result) != c.wantResult || gotError != c.wantError {
|
||||
t.Errorf("%s job = %+v (result %s), want %s with result %q and error %q", job.Kind, job, job.Result, c.wantStatus, c.wantResult, c.wantError)
|
||||
}
|
||||
body, err := json.Marshal(job)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(body), sentinelSecret) {
|
||||
t.Errorf("%s job as served contains the secret: %s", job.Kind, body)
|
||||
}
|
||||
}
|
||||
stop()
|
||||
for _, id := range ids {
|
||||
if row := rawRow(t, st, id); strings.Contains(row, sentinelSecret) {
|
||||
t.Errorf("job %s row contains the secret: %s", id, row)
|
||||
}
|
||||
}
|
||||
if strings.Contains(logs.String(), sentinelSecret) {
|
||||
t.Errorf("log contains the secret:\n%s", logs.String())
|
||||
}
|
||||
if !strings.Contains(logs.String(), "code="+CodeInternal) {
|
||||
t.Errorf("log does not record the internal_error code:\n%s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressiveResultsVisibleMidRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := newRunner(t, migratedStore(t), 1)
|
||||
release := make(chan struct{})
|
||||
k := register(t, r, "draft", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
|
||||
for i := 1; i < p.Steps; i++ {
|
||||
if err := publish(countResult{Done: i}); err != nil {
|
||||
return countResult{}, err
|
||||
}
|
||||
select {
|
||||
case <-release:
|
||||
case <-ctx.Done():
|
||||
return countResult{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
start(t, r)
|
||||
|
||||
id := enqueue(t, k, countPayload{Steps: 3})
|
||||
for step := 1; step < 3; step++ {
|
||||
want := fmt.Sprintf(`{"done":%d}`, step)
|
||||
job := waitFor(t, r, id, func(j Job) bool { return string(j.Result) == want })
|
||||
if job.Status != StatusRunning || job.Error != nil {
|
||||
t.Fatalf("mid-run job = %+v, want running with no error", job)
|
||||
}
|
||||
release <- struct{}{}
|
||||
}
|
||||
job := waitFor(t, r, id, finished)
|
||||
if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
|
||||
t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartFailsInterruptedJobsAndRunsQueuedOnes(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
|
||||
first := newRunner(t, st, 1)
|
||||
started := make(chan struct{})
|
||||
k := register(t, first, "slow", func(ctx context.Context, _ countPayload, publish func(countResult) error) (countResult, error) {
|
||||
if err := publish(countResult{Done: 1}); err != nil {
|
||||
return countResult{}, err
|
||||
}
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
return countResult{}, ctx.Err()
|
||||
})
|
||||
stop := start(t, first)
|
||||
interrupted := enqueue(t, k, countPayload{})
|
||||
<-started
|
||||
waiting := enqueue(t, k, countPayload{}) // the only worker is busy
|
||||
stop()
|
||||
|
||||
if job := waitFor(t, first, interrupted, func(Job) bool { return true }); job.Status != StatusRunning {
|
||||
t.Fatalf("after shutdown job = %+v, want left running", job)
|
||||
}
|
||||
|
||||
second := newRunner(t, st, 1)
|
||||
register(t, second, "slow", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||
return countResult{Done: 9}, nil
|
||||
})
|
||||
start(t, second)
|
||||
|
||||
job := waitFor(t, second, interrupted, finished)
|
||||
if job.Status != StatusFailed || job.Error == nil || *job.Error != "interrupted by restart" {
|
||||
t.Errorf("interrupted job = %+v, want failed with %q", job, "interrupted by restart")
|
||||
}
|
||||
if string(job.Result) != `{"done":1}` {
|
||||
t.Errorf("interrupted job result = %s, want the last partial result kept", job.Result)
|
||||
}
|
||||
if job := waitFor(t, second, waiting, finished); job.Status != StatusSucceeded {
|
||||
t.Errorf("queued job = %+v, want run by the new runner", job)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPoolIsBounded(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := newRunner(t, migratedStore(t), 2)
|
||||
var running, peak atomic.Int32
|
||||
release := make(chan struct{})
|
||||
k := register(t, r, "busy", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||
n := running.Add(1)
|
||||
for {
|
||||
p := peak.Load()
|
||||
if n <= p || peak.CompareAndSwap(p, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
<-release
|
||||
running.Add(-1)
|
||||
return countResult{}, nil
|
||||
})
|
||||
start(t, r)
|
||||
|
||||
ids := make([]string, 5)
|
||||
for i := range ids {
|
||||
ids[i] = enqueue(t, k, countPayload{})
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for running.Load() < 2 {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("two workers never ran at once")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
close(release)
|
||||
for _, id := range ids {
|
||||
if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded {
|
||||
t.Errorf("job = %+v, want succeeded", job)
|
||||
}
|
||||
}
|
||||
if got := peak.Load(); got != 2 {
|
||||
t.Errorf("peak concurrent jobs = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobWithoutHandlerFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
old := newRunner(t, st, 1)
|
||||
k := register(t, old, "retired", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||
return countResult{}, nil
|
||||
})
|
||||
id := enqueue(t, k, countPayload{}) // never started
|
||||
|
||||
r := newRunner(t, st, 1)
|
||||
start(t, r)
|
||||
job := waitFor(t, r, id, finished)
|
||||
if job.Status != StatusFailed || job.Error == nil || *job.Error != CodeUnknownKind {
|
||||
t.Errorf("job = %+v, want failed for a missing handler", job)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUnknownID(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := newRunner(t, migratedStore(t), 1)
|
||||
if _, err := r.Get(context.Background(), store.NewID()); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Get unknown id error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRefusesBadRegistrations(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := newRunner(t, migratedStore(t), 1)
|
||||
h := func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||
return countResult{}, nil
|
||||
}
|
||||
register(t, r, "count", h)
|
||||
|
||||
if _, err := Register(r, "count", h); err == nil {
|
||||
t.Error("duplicate kind registered")
|
||||
}
|
||||
if _, err := Register(r, "", h); err == nil {
|
||||
t.Error("empty kind registered")
|
||||
}
|
||||
if _, err := Register[countPayload, countResult](r, "nil", nil); err == nil {
|
||||
t.Error("nil handler registered")
|
||||
}
|
||||
for _, code := range []string{"", "Bad Code", "sk-sentinel", CodeInternal, CodeInterrupted, CodeUnknownKind} {
|
||||
if _, err := Register(r, "code", h, code); err == nil {
|
||||
t.Errorf("failure code %q accepted", code)
|
||||
}
|
||||
}
|
||||
start(t, r)
|
||||
if _, err := Register(r, "late", h); err == nil {
|
||||
t.Error("kind registered after Start")
|
||||
}
|
||||
if err := r.Start(context.Background()); err == nil {
|
||||
t.Error("second Start succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRunnerRefusesBadConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRunner(st, logger, 0); err == nil {
|
||||
t.Error("zero workers accepted")
|
||||
}
|
||||
if _, err := NewRunner(nil, logger, 1); err == nil {
|
||||
t.Error("nil store accepted")
|
||||
}
|
||||
if _, err := NewRunner(st, nil, 1); err == nil {
|
||||
t.Error("nil logger accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoopSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := newRunner(t, migratedStore(t), 1)
|
||||
builtins, err := RegisterBuiltins(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start(t, r)
|
||||
|
||||
job := waitFor(t, r, enqueue(t, builtins.Noop, NoopPayload{}), finished)
|
||||
if job.Kind != KindNoop || job.Status != StatusSucceeded || string(job.Result) != `{"ok":true}` || job.Error != nil {
|
||||
t.Errorf("noop job = %+v (result %s), want succeeded with {\"ok\":true}", job, job.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimErrorIsRetried(t *testing.T) {
|
||||
st := migratedStore(t)
|
||||
logs := &logBuffer{}
|
||||
r := newRunnerLogging(t, st, 1, logs)
|
||||
k := register(t, r, "count", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
id := enqueue(t, k, countPayload{Steps: 3})
|
||||
// Drop the wake token, as for a job queued before a restart, so only
|
||||
// the retry can find the job.
|
||||
<-r.wake
|
||||
|
||||
var claims atomic.Int32
|
||||
claim := r.claim
|
||||
r.claim = func(ctx context.Context, now sql.NullString) (db.Job, error) {
|
||||
if claims.Add(1) == 1 {
|
||||
return db.Job{}, errors.New("database is locked " + sentinelSecret)
|
||||
}
|
||||
return claim(ctx, now)
|
||||
}
|
||||
start(t, r)
|
||||
|
||||
job := waitFor(t, r, id, finished)
|
||||
if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
|
||||
t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
|
||||
}
|
||||
if n := claims.Load(); n < 2 {
|
||||
t.Fatalf("claims = %d, want a retry after the error", n)
|
||||
}
|
||||
out := logs.String()
|
||||
if !strings.Contains(out, "claim job failed; retrying") {
|
||||
t.Fatalf("claim error not logged: %s", out)
|
||||
}
|
||||
if strings.Contains(out, sentinelSecret) {
|
||||
t.Fatalf("claim error text logged: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerEnqueueByKindName(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
r := newRunner(t, st, 1)
|
||||
register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := r.Enqueue(ctx, "no_such_kind", json.RawMessage(`{}`)); !errors.Is(err, ErrUnknownKind) {
|
||||
t.Errorf("unknown kind error = %v, want ErrUnknownKind", err)
|
||||
}
|
||||
for _, payload := range []string{`{"steps":"three"}`, `{"other":1}`, `[]`, ``} {
|
||||
if _, err := r.Enqueue(ctx, "count", json.RawMessage(payload)); !errors.Is(err, ErrInvalidPayload) {
|
||||
t.Errorf("payload %q error = %v, want ErrInvalidPayload", payload, err)
|
||||
}
|
||||
}
|
||||
var n int
|
||||
if err := st.Reader.QueryRow("SELECT COUNT(*) FROM jobs").Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("refused enqueues stored %d jobs, want 0", n)
|
||||
}
|
||||
|
||||
id, err := r.Enqueue(ctx, "count", json.RawMessage(`{"steps":3}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start(t, r)
|
||||
job := waitFor(t, r, id, finished)
|
||||
if job.Kind != "count" || job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
|
||||
t.Errorf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
)
|
||||
|
||||
// maxJobBody bounds a POST /jobs request body.
|
||||
const maxJobBody = 64 << 10
|
||||
|
||||
// jobRequest is the POST /jobs body. Payload is required; a job kind that
|
||||
// takes no input is sent the empty object {}.
|
||||
type jobRequest struct {
|
||||
Kind string `json:"kind"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
// jobCreated is the POST /jobs response.
|
||||
type jobCreated struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// postJob serves POST /api/v1/jobs: it enqueues a job of a registered kind
|
||||
// and returns 202 with its id, which GET /api/v1/jobs/{id} reports on.
|
||||
func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := decodeJobRequest(http.MaxBytesReader(w, r.Body, maxJobBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
id, err := runner.Enqueue(r.Context(), req.Kind, req.Payload)
|
||||
switch {
|
||||
case errors.Is(err, jobs.ErrUnknownKind):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "unknown_kind", "unknown job kind")
|
||||
return
|
||||
case errors.Is(err, jobs.ErrInvalidPayload):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_payload", "payload does not match the job kind")
|
||||
return
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "enqueue job", "kind", req.Kind, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusAccepted, jobCreated{ID: id})
|
||||
}
|
||||
}
|
||||
|
||||
// decodeJobRequest reads a single JSON object with a non-empty kind and a
|
||||
// payload, refusing unknown fields, an omitted or null payload and trailing
|
||||
// data.
|
||||
func decodeJobRequest(body io.Reader) (jobRequest, error) {
|
||||
data, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
return jobRequest{}, fmt.Errorf("request body exceeds %d bytes", tooLarge.Limit)
|
||||
}
|
||||
return jobRequest{}, errors.New("cannot read request body")
|
||||
}
|
||||
var req jobRequest
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
return jobRequest{}, errors.New("request body must be a JSON object with kind and payload")
|
||||
}
|
||||
if _, err := dec.Token(); err != io.EOF {
|
||||
return jobRequest{}, errors.New("request body must be a single JSON object")
|
||||
}
|
||||
if req.Kind == "" {
|
||||
return jobRequest{}, errors.New("kind: required")
|
||||
}
|
||||
if req.Payload == nil {
|
||||
return jobRequest{}, errors.New("payload: required")
|
||||
}
|
||||
if bytes.Equal(req.Payload, []byte("null")) {
|
||||
return jobRequest{}, errors.New("payload: must not be null")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// getJob serves GET /api/v1/jobs/{id}: the job's id, kind, status, latest
|
||||
// result and error.
|
||||
func getJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
job, err := runner.Get(r.Context(), chi.URLParam(r, "id"))
|
||||
if errors.Is(err, jobs.ErrNotFound) {
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "job not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "get job", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, job)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
type draftPayload struct{}
|
||||
|
||||
type draftResult struct {
|
||||
Days int `json:"days"`
|
||||
}
|
||||
|
||||
func (d draftResult) PublicJSON() ([]byte, error) { return json.Marshal(d) }
|
||||
|
||||
// startedRunner registers a "draft" kind that publishes {"days":1}, waits
|
||||
// for release and returns {"days":7}, or fails with a *jobs.Failure when
|
||||
// fail is closed first. It starts the runner and stops it when the test ends.
|
||||
func startedRunner(t *testing.T, release, fail <-chan struct{}) (*jobs.Runner, *jobs.Kind[draftPayload]) {
|
||||
t.Helper()
|
||||
runner := newTestRunner(t)
|
||||
kind, err := jobs.Register(runner, "draft", func(ctx context.Context, _ draftPayload, publish func(draftResult) error) (draftResult, error) {
|
||||
if err := publish(draftResult{Days: 1}); err != nil {
|
||||
return draftResult{}, err
|
||||
}
|
||||
select {
|
||||
case <-release:
|
||||
return draftResult{Days: 7}, nil
|
||||
case <-fail:
|
||||
return draftResult{}, &jobs.Failure{Code: "no_regulars"}
|
||||
case <-ctx.Done():
|
||||
return draftResult{}, ctx.Err()
|
||||
}
|
||||
}, "no_regulars")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
runner.Wait()
|
||||
})
|
||||
return runner, kind
|
||||
}
|
||||
|
||||
// pollJob GETs the job until want accepts the body, failing after five
|
||||
// seconds.
|
||||
func pollJob(t *testing.T, h http.Handler, id string, want func(map[string]any) bool) map[string]any {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/jobs/"+id, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if want(body) {
|
||||
if keys := slices.Sorted(maps.Keys(body)); !slices.Equal(keys, []string{"error", "id", "kind", "result", "status"}) {
|
||||
t.Errorf("body fields = %v, want id, kind, status, result, error", keys)
|
||||
}
|
||||
return body
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job %s never reached the expected state; last %v", id, body)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func resultDays(body map[string]any) float64 {
|
||||
result, _ := body["result"].(map[string]any)
|
||||
days, _ := result["days"].(float64)
|
||||
return days
|
||||
}
|
||||
|
||||
func TestGetJobShowsProgressThenResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
release := make(chan struct{})
|
||||
runner, kind := startedRunner(t, release, nil)
|
||||
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
|
||||
id, err := kind.Enqueue(context.Background(), draftPayload{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mid := pollJob(t, router, id, func(b map[string]any) bool { return resultDays(b) == 1 })
|
||||
if mid["id"] != id || mid["kind"] != "draft" || mid["status"] != "running" || mid["error"] != nil {
|
||||
t.Errorf("mid-run body = %v, want running draft job %s with no error", mid, id)
|
||||
}
|
||||
|
||||
close(release)
|
||||
done := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "succeeded" })
|
||||
if resultDays(done) != 7 || done["error"] != nil {
|
||||
t.Errorf("finished body = %v, want result {\"days\":7} and no error", done)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobShowsFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
fail := make(chan struct{})
|
||||
close(fail)
|
||||
runner, kind := startedRunner(t, nil, fail)
|
||||
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
|
||||
id, err := kind.Enqueue(context.Background(), draftPayload{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "failed" })
|
||||
if body["error"] != "no_regulars" {
|
||||
t.Errorf("error = %v, want the Failure code", body["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobUnknownIDIsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newTestRouter(t, "dev")
|
||||
|
||||
rec, _ := serve(t, router, signedIn(http.MethodGet, "/api/v1/jobs/"+store.NewID(), nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
var body errorBody
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := (errorBody{Error: apiError{Code: "not_found", Message: "job not found"}}); body != want {
|
||||
t.Errorf("body = %+v, want %+v", body, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobRequiresSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newTestRouter(t, "dev")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/jobs/"+store.NewID(), nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostJobThenGetProgressAndResult drives the whole flow over the
|
||||
// router: POST enqueues a progressive "draft" job, GET reads its partial
|
||||
// result while the handler is held mid-run, then its final result.
|
||||
func TestPostJobThenGetProgressAndResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
release := make(chan struct{})
|
||||
runner, _ := startedRunner(t, release, nil)
|
||||
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
|
||||
|
||||
rec, body := serve(t, router, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(`{"kind":"draft","payload":{}}`)))
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, want 202: %s", rec.Code, rec.Body)
|
||||
}
|
||||
id, _ := body["id"].(string)
|
||||
if keys := slices.Sorted(maps.Keys(body)); !slices.Equal(keys, []string{"id"}) || id == "" {
|
||||
t.Fatalf("body = %v, want only a job id", body)
|
||||
}
|
||||
|
||||
mid := pollJob(t, router, id, func(b map[string]any) bool { return resultDays(b) == 1 })
|
||||
if mid["id"] != id || mid["kind"] != "draft" || mid["status"] != "running" || mid["error"] != nil {
|
||||
t.Errorf("mid-run body = %v, want running draft job %s with no error", mid, id)
|
||||
}
|
||||
|
||||
close(release)
|
||||
done := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "succeeded" })
|
||||
if done["id"] != id || resultDays(done) != 7 || done["error"] != nil {
|
||||
t.Errorf("finished body = %v, want result {\"days\":7} and no error", done)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostJobRefusesBadRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
runner, _ := startedRunner(t, nil, nil)
|
||||
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, body, code, message string
|
||||
}{
|
||||
{"unknown kind", `{"kind":"no_such_kind","payload":{}}`, "unknown_kind", "unknown job kind"},
|
||||
{"payload for another type", `{"kind":"draft","payload":{"days":3}}`, "invalid_payload", "payload does not match the job kind"},
|
||||
{"payload not an object", `{"kind":"draft","payload":[1]}`, "invalid_payload", "payload does not match the job kind"},
|
||||
{"missing payload", `{"kind":"draft"}`, "invalid_request", "payload: required"},
|
||||
{"null payload", `{"kind":"draft","payload":null}`, "invalid_request", "payload: must not be null"},
|
||||
{"missing kind", `{"payload":{}}`, "invalid_request", "kind: required"},
|
||||
{"empty body", ``, "invalid_request", "request body must be a JSON object with kind and payload"},
|
||||
{"not an object", `["draft"]`, "invalid_request", "request body must be a JSON object with kind and payload"},
|
||||
{"unknown field", `{"kind":"draft","payload":{},"extra":1}`, "invalid_request", "request body must be a JSON object with kind and payload"},
|
||||
{"trailing data", `{"kind":"draft","payload":{}} {}`, "invalid_request", "request body must be a single JSON object"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(tc.body)))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body)
|
||||
}
|
||||
var body errorBody
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := (errorBody{Error: apiError{Code: tc.code, Message: tc.message}}); body != want {
|
||||
t.Errorf("body = %+v, want %+v", body, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostJobNeedsSessionAndCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newTestRouter(t, "dev")
|
||||
const body = `{"kind":"noop","payload":{}}`
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body)))
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
|
||||
t.Errorf("no session: %d %s, want 401 %s", rec.Code, rec.Body, unauthenticatedJSON)
|
||||
}
|
||||
|
||||
for _, header := range []string{"", "wrong-token"} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body))
|
||||
req.AddCookie(validSessionCookie)
|
||||
if header != "" {
|
||||
req.Header.Set(CSRFHeader, header)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden || rec.Body.String() != csrfJSON {
|
||||
t.Errorf("CSRF header %q: %d %s, want 403 %s", header, rec.Code, rec.Body, csrfJSON)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
)
|
||||
|
||||
// reservedPrefixes are owned by the API router and are never answered with the
|
||||
@@ -28,13 +29,17 @@ type RouteMounter interface {
|
||||
// has its client address and scheme resolved, honouring forwarded headers
|
||||
// only from a peer in trusted (see resolveOrigin). version is reported by
|
||||
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
||||
// /media; settings serves /api/v1/settings/household; assets is the web build
|
||||
// /media; settings serves /api/v1/settings/household; runner serves
|
||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, assets fs.FS) (http.Handler, error) {
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, runner *jobs.Runner, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("server: nil job runner")
|
||||
}
|
||||
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
|
||||
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
|
||||
@@ -63,6 +68,8 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Get("/me", me(logger))
|
||||
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
||||
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
||||
apiV1.Post("/jobs", postJob(logger, runner))
|
||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.MethodNotAllowed(methodNotAllowed)
|
||||
r.NotFound(notFound)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
@@ -27,17 +28,17 @@ const (
|
||||
|
||||
func newTestRouter(t *testing.T, version string) http.Handler {
|
||||
t.Helper()
|
||||
return newTestRouterWith(t, version, newTestSettings(t))
|
||||
return newTestRouterWith(t, version, newTestSettings(t), newTestRunner(t))
|
||||
}
|
||||
|
||||
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService) http.Handler {
|
||||
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
assets := fstest.MapFS{
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, runner, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -70,9 +71,8 @@ func TestAuthRoutesMounted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// newTestSettings returns a settings service over a migrated temp database
|
||||
// seeded with zone America/New_York.
|
||||
func newTestSettings(t *testing.T) *household.SettingsService {
|
||||
// newTestStore returns a migrated temp database, closed when the test ends.
|
||||
func newTestStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
@@ -87,21 +87,49 @@ func newTestSettings(t *testing.T) *household.SettingsService {
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
|
||||
return st
|
||||
}
|
||||
|
||||
// newTestSettings returns a settings service over a migrated temp database
|
||||
// seeded with zone America/New_York.
|
||||
func newTestSettings(t *testing.T) *household.SettingsService {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return household.NewSettingsService(st)
|
||||
}
|
||||
|
||||
// newTestRunner returns an unstarted job runner over a migrated temp
|
||||
// database.
|
||||
func newTestRunner(t *testing.T) *jobs.Runner {
|
||||
t.Helper()
|
||||
runner, err := jobs.NewRunner(newTestStore(t), slog.New(slog.NewTextHandler(io.Discard, nil)), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return runner
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestRunner(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresJobRunner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil job runner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ func TestGetHouseholdSettings(t *testing.T) {
|
||||
func TestPatchHouseholdSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
settings := newTestSettings(t)
|
||||
router := newTestRouterWith(t, "dev", settings)
|
||||
router := newTestRouterWith(t, "dev", settings, newTestRunner(t))
|
||||
|
||||
var notified []household.Settings
|
||||
settings.Subscribe(func(s household.Settings) { notified = append(notified, s) })
|
||||
@@ -146,7 +146,7 @@ func TestPatchHouseholdSettingsRejected(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
settings := newTestSettings(t)
|
||||
router := newTestRouterWith(t, "dev", settings)
|
||||
router := newTestRouterWith(t, "dev", settings, newTestRunner(t))
|
||||
notified := 0
|
||||
settings.Subscribe(func(household.Settings) { notified++ })
|
||||
|
||||
@@ -216,7 +216,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -175,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,8 +196,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai", got)
|
||||
}
|
||||
if !tableExists(t, s, "dinners") {
|
||||
t.Error("table from 0001_init was not created")
|
||||
@@ -262,3 +262,227 @@ func TestMigrateReopenSameDataDirIsNoOp(t *testing.T) {
|
||||
t.Errorf("schema changed on reopen:\n got %v\nwant %v", afterSchema, beforeSchema)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateJobsStatusMapsLegacyRows upgrades a database holding jobs in
|
||||
// every 0001 status and checks 0003 carries each row over with its status
|
||||
// mapped and every other column unchanged.
|
||||
func TestMigrateJobsStatusMapsLegacyRows(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
before := fstest.MapFS{}
|
||||
for _, name := range []string{"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql"} {
|
||||
body, err := fs.ReadFile(migrations.FS, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before[name] = &fstest.MapFile{Data: body}
|
||||
}
|
||||
if err := Migrate(ctx, s.Writer, before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := map[string]string{"pending": "queued", "running": "running", "done": "succeeded", "failed": "failed"}
|
||||
for old := range legacy {
|
||||
if _, err := s.Writer.Exec(`INSERT INTO jobs (id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at)
|
||||
VALUES (?, 'suggest', ?, '{"n":1}', '{"r":2}', 'boom', 's', 'f', 'c', 'u')`, "job-"+old, old); err != nil {
|
||||
t.Fatalf("insert %s job: %v", old, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for old, want := range legacy {
|
||||
var kind, status, payload, result, errText, startedAt, finishedAt, createdAt, updatedAt string
|
||||
if err := s.Reader.QueryRow(`SELECT kind, status, payload, result, error, started_at, finished_at, created_at, updated_at FROM jobs WHERE id = ?`, "job-"+old).
|
||||
Scan(&kind, &status, &payload, &result, &errText, &startedAt, &finishedAt, &createdAt, &updatedAt); err != nil {
|
||||
t.Fatalf("read %s job: %v", old, err)
|
||||
}
|
||||
if status != want {
|
||||
t.Errorf("%s job status = %q, want %q", old, status, want)
|
||||
}
|
||||
got := []string{kind, payload, result, errText, startedAt, finishedAt, createdAt, updatedAt}
|
||||
if wantCols := []string{"suggest", `{"n":1}`, `{"r":2}`, "boom", "s", "f", "c", "u"}; !slices.Equal(got, wantCols) {
|
||||
t.Errorf("%s job columns = %v, want %v", old, got, wantCols)
|
||||
}
|
||||
}
|
||||
if tableExists(t, s, "jobs_new") {
|
||||
t.Error("jobs_new left behind")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateAIProviderPresetsKeepsRowsAndReferences upgrades a database
|
||||
// holding ai_settings and ai_runs rows, with suggestion_batches and
|
||||
// week_plans referencing the run, and checks 0004 carries every row over,
|
||||
// keeps the references enforced and accepts the new providers.
|
||||
func TestMigrateAIProviderPresetsKeepsRowsAndReferences(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
before := fstest.MapFS{}
|
||||
for _, name := range []string{"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql", "0003_jobs_status.sql"} {
|
||||
body, err := fs.ReadFile(migrations.FS, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before[name] = &fstest.MapFile{Data: body}
|
||||
}
|
||||
if err := Migrate(ctx, s.Writer, before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
`INSERT INTO ai_settings (id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at)
|
||||
VALUES (1, 'anthropic', 'https://api.anthropic.com', 'claude', 0.7, 'sp', x'0102', 'abcd', 'v', 'c', 'u')`,
|
||||
`INSERT INTO ai_runs (id, skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at)
|
||||
VALUES ('run-1', 'suggest', '1', 'ollama', 'llama', 0.5, 'ok', 10, 20, 30, 'e', 'req', 'resp', 'c', 'u')`,
|
||||
`INSERT INTO suggestion_batches (id, trigger, status, ai_run_id, created_at, updated_at)
|
||||
VALUES ('batch-1', 'manual', 'done', 'run-1', 'c', 'u')`,
|
||||
`INSERT INTO week_plans (id, week_start, status, ai_run_id, created_at, updated_at)
|
||||
VALUES ('plan-1', '2026-10-04', 'draft', 'run-1', 'c', 'u')`,
|
||||
} {
|
||||
if _, err := s.Writer.Exec(stmt); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var provider, baseURL, model, systemPrompt, last4, verifiedAt, createdAt, updatedAt string
|
||||
var temperature float64
|
||||
var keyCT []byte
|
||||
if err := s.Reader.QueryRow(`SELECT provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at FROM ai_settings WHERE id = 1`).
|
||||
Scan(&provider, &baseURL, &model, &temperature, &systemPrompt, &keyCT, &last4, &verifiedAt, &createdAt, &updatedAt); err != nil {
|
||||
t.Fatalf("read ai_settings: %v", err)
|
||||
}
|
||||
settings := []any{provider, baseURL, model, temperature, systemPrompt, string(keyCT), last4, verifiedAt, createdAt, updatedAt}
|
||||
if want := []any{"anthropic", "https://api.anthropic.com", "claude", 0.7, "sp", "\x01\x02", "abcd", "v", "c", "u"}; !slices.Equal(settings, want) {
|
||||
t.Errorf("ai_settings = %v, want %v", settings, want)
|
||||
}
|
||||
|
||||
var skill, skillVersion, status, errText, request, response string
|
||||
var latency, tokensIn, tokensOut int
|
||||
if err := s.Reader.QueryRow(`SELECT skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at FROM ai_runs WHERE id = 'run-1'`).
|
||||
Scan(&skill, &skillVersion, &provider, &model, &temperature, &status, &latency, &tokensIn, &tokensOut, &errText, &request, &response, &createdAt, &updatedAt); err != nil {
|
||||
t.Fatalf("read ai_runs: %v", err)
|
||||
}
|
||||
run := []any{skill, skillVersion, provider, model, temperature, status, latency, tokensIn, tokensOut, errText, request, response, createdAt, updatedAt}
|
||||
if want := []any{"suggest", "1", "ollama", "llama", 0.5, "ok", 10, 20, 30, "e", "req", "resp", "c", "u"}; !slices.Equal(run, want) {
|
||||
t.Errorf("ai_runs = %v, want %v", run, want)
|
||||
}
|
||||
|
||||
rows, err := s.Reader.Query("PRAGMA foreign_key_check")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows.Next() {
|
||||
t.Error("foreign_key_check reported violations after 0004")
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Writer.Exec(`DELETE FROM ai_runs WHERE id = 'run-1'`); err == nil || !strings.Contains(err.Error(), "FOREIGN KEY constraint failed") {
|
||||
t.Errorf("deleting a referenced ai_run: error %v, want FOREIGN KEY constraint failure", err)
|
||||
}
|
||||
|
||||
if _, err := s.Writer.Exec(`UPDATE ai_settings SET provider = 'openrouter' WHERE id = 1`); err != nil {
|
||||
t.Errorf("ai_settings.provider openrouter rejected: %v", err)
|
||||
}
|
||||
if _, err := s.Writer.Exec(`UPDATE ai_runs SET provider = 'openai' WHERE id = 'run-1'`); err != nil {
|
||||
t.Errorf("ai_runs.provider openai rejected: %v", err)
|
||||
}
|
||||
for _, table := range []string{"ai_settings_copy", "ai_runs_copy"} {
|
||||
if tableExists(t, s, table) {
|
||||
t.Errorf("%s left behind", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateAIProviderXAIKeepsRowsAndReferences upgrades a database at 0004
|
||||
// holding ai_settings and ai_runs rows with 0004's providers, with
|
||||
// suggestion_batches and week_plans referencing the run, and checks 0005
|
||||
// carries every row over, keeps the references enforced, accepts xai and
|
||||
// still rejects an unknown provider.
|
||||
func TestMigrateAIProviderXAIKeepsRowsAndReferences(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
before := fstest.MapFS{}
|
||||
for _, name := range []string{"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql", "0003_jobs_status.sql", "0004_ai_provider_presets.sql"} {
|
||||
body, err := fs.ReadFile(migrations.FS, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before[name] = &fstest.MapFile{Data: body}
|
||||
}
|
||||
if err := Migrate(ctx, s.Writer, before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
`INSERT INTO ai_settings (id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at)
|
||||
VALUES (1, 'openrouter', 'https://openrouter.ai/api/v1', 'openai/gpt-4o', 0.7, 'sp', x'0102', 'abcd', 'v', 'c', 'u')`,
|
||||
`INSERT INTO ai_runs (id, skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at)
|
||||
VALUES ('run-1', 'suggest', '1', 'openai', 'gpt-4o', 0.5, 'ok', 10, 20, 30, 'e', 'req', 'resp', 'c', 'u')`,
|
||||
`INSERT INTO suggestion_batches (id, trigger, status, ai_run_id, created_at, updated_at)
|
||||
VALUES ('batch-1', 'manual', 'done', 'run-1', 'c', 'u')`,
|
||||
`INSERT INTO week_plans (id, week_start, status, ai_run_id, created_at, updated_at)
|
||||
VALUES ('plan-1', '2026-10-04', 'draft', 'run-1', 'c', 'u')`,
|
||||
} {
|
||||
if _, err := s.Writer.Exec(stmt); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var provider, baseURL, model, systemPrompt, last4, verifiedAt, createdAt, updatedAt string
|
||||
var temperature float64
|
||||
var keyCT []byte
|
||||
if err := s.Reader.QueryRow(`SELECT provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at FROM ai_settings WHERE id = 1`).
|
||||
Scan(&provider, &baseURL, &model, &temperature, &systemPrompt, &keyCT, &last4, &verifiedAt, &createdAt, &updatedAt); err != nil {
|
||||
t.Fatalf("read ai_settings: %v", err)
|
||||
}
|
||||
settings := []any{provider, baseURL, model, temperature, systemPrompt, string(keyCT), last4, verifiedAt, createdAt, updatedAt}
|
||||
if want := []any{"openrouter", "https://openrouter.ai/api/v1", "openai/gpt-4o", 0.7, "sp", "\x01\x02", "abcd", "v", "c", "u"}; !slices.Equal(settings, want) {
|
||||
t.Errorf("ai_settings = %v, want %v", settings, want)
|
||||
}
|
||||
|
||||
var skill, skillVersion, status, errText, request, response string
|
||||
var latency, tokensIn, tokensOut int
|
||||
if err := s.Reader.QueryRow(`SELECT skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at FROM ai_runs WHERE id = 'run-1'`).
|
||||
Scan(&skill, &skillVersion, &provider, &model, &temperature, &status, &latency, &tokensIn, &tokensOut, &errText, &request, &response, &createdAt, &updatedAt); err != nil {
|
||||
t.Fatalf("read ai_runs: %v", err)
|
||||
}
|
||||
run := []any{skill, skillVersion, provider, model, temperature, status, latency, tokensIn, tokensOut, errText, request, response, createdAt, updatedAt}
|
||||
if want := []any{"suggest", "1", "openai", "gpt-4o", 0.5, "ok", 10, 20, 30, "e", "req", "resp", "c", "u"}; !slices.Equal(run, want) {
|
||||
t.Errorf("ai_runs = %v, want %v", run, want)
|
||||
}
|
||||
|
||||
rows, err := s.Reader.Query("PRAGMA foreign_key_check")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows.Next() {
|
||||
t.Error("foreign_key_check reported violations after 0005")
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Writer.Exec(`DELETE FROM ai_runs WHERE id = 'run-1'`); err == nil || !strings.Contains(err.Error(), "FOREIGN KEY constraint failed") {
|
||||
t.Errorf("deleting a referenced ai_run: error %v, want FOREIGN KEY constraint failure", err)
|
||||
}
|
||||
|
||||
for _, table := range []string{"ai_settings", "ai_runs"} {
|
||||
if _, err := s.Writer.Exec(`UPDATE ` + table + ` SET provider = 'xai'`); err != nil {
|
||||
t.Errorf("%s.provider xai rejected: %v", table, err)
|
||||
}
|
||||
if _, err := s.Writer.Exec(`UPDATE ` + table + ` SET provider = 'grok'`); err == nil || !strings.Contains(err.Error(), "CHECK constraint failed") {
|
||||
t.Errorf("%s.provider grok: error %v, want CHECK constraint failure", table, err)
|
||||
}
|
||||
}
|
||||
for _, table := range []string{"ai_settings_copy", "ai_runs_copy"} {
|
||||
if tableExists(t, s, table) {
|
||||
t.Errorf("%s left behind", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func testRow(table string, i int) map[string]any {
|
||||
r["attempts"] = 0
|
||||
case "jobs":
|
||||
r["kind"] = "suggest"
|
||||
r["status"] = "pending"
|
||||
r["status"] = "queued"
|
||||
r["payload"] = "{}"
|
||||
case "schedule_runs":
|
||||
r["schedule"] = "plan_draft"
|
||||
@@ -305,7 +305,7 @@ func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) {
|
||||
{"household_settings", "plan_draft_dow", 5.5},
|
||||
{"household_settings", "suggestions_dow", 0.5},
|
||||
{"household_settings", "chat_autosend_dow", 3.5},
|
||||
{"ai_runs", "provider", "openai"},
|
||||
{"ai_runs", "provider", "gemini"},
|
||||
{"ai_runs", "status", "done"},
|
||||
{"dinners", "status", "deck"},
|
||||
{"dinners", "origin", "ai"},
|
||||
@@ -322,7 +322,7 @@ func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) {
|
||||
{"rotation_reviews", "decision", "promote"},
|
||||
{"shopping_lists", "status", "frozen"},
|
||||
{"shopping_items", "needed", 2},
|
||||
{"ai_settings", "provider", "openai"},
|
||||
{"ai_settings", "provider", "gemini"},
|
||||
{"recipe_search_settings", "provider", "google"},
|
||||
{"chat_settings", "last_test_status", "pending"},
|
||||
{"notification_prefs", "event", "weekly_digest"},
|
||||
@@ -330,7 +330,7 @@ func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) {
|
||||
{"notification_prefs", "chat", 2},
|
||||
{"notification_outbox", "channel", "email"},
|
||||
{"notification_outbox", "status", "queued"},
|
||||
{"jobs", "status", "queued"},
|
||||
{"jobs", "status", "bogus"},
|
||||
{"schedule_runs", "schedule", "hourly"},
|
||||
}
|
||||
s := seededStore(t)
|
||||
@@ -360,6 +360,23 @@ func TestSchemaRotationReviewEventAllowed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaAIProvidersAllowed checks every provider preset can be stored in
|
||||
// both ai_settings and ai_runs.
|
||||
func TestSchemaAIProvidersAllowed(t *testing.T) {
|
||||
s := seededStore(t)
|
||||
for _, provider := range []string{"openai_compatible", "anthropic", "ollama", "openai", "openrouter", "xai"} {
|
||||
for _, table := range []string{"ai_settings", "ai_runs"} {
|
||||
inRolledBackTx(t, s, func(tx *sql.Tx) {
|
||||
row := testRow(table, 1)
|
||||
row["provider"] = provider
|
||||
if err := insertSchemaRow(tx, table, row); err != nil {
|
||||
t.Errorf("%s.provider %s rejected: %v", table, provider, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaDinnerStatuses(t *testing.T) {
|
||||
s := seededStore(t)
|
||||
for _, status := range []string{"suggested", "quest_log", "tried", "regular", "archived"} {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- 0003_jobs_status: jobs.status becomes queued, running, succeeded, failed,
|
||||
-- the states the job runner (internal/jobs) reports. SQLite cannot change a
|
||||
-- CHECK in place, so the table is rebuilt: create the new table, copy every
|
||||
-- row, drop the old one and rename. Nothing references jobs and it has no
|
||||
-- indexes or triggers, so the rebuild touches no other schema object. Rows
|
||||
-- written under the 0001 values are mapped (pending -> queued,
|
||||
-- done -> succeeded) so the copy cannot fail on an existing database.
|
||||
|
||||
CREATE TABLE jobs_new (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed')),
|
||||
payload TEXT NOT NULL, -- JSON
|
||||
result TEXT, -- JSON, written progressively
|
||||
error TEXT,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO jobs_new (id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at)
|
||||
SELECT
|
||||
id,
|
||||
kind,
|
||||
CASE status WHEN 'pending' THEN 'queued' WHEN 'done' THEN 'succeeded' ELSE status END,
|
||||
payload, result, error, started_at, finished_at, created_at, updated_at
|
||||
FROM jobs;
|
||||
|
||||
DROP TABLE jobs;
|
||||
|
||||
ALTER TABLE jobs_new RENAME TO jobs;
|
||||
@@ -0,0 +1,68 @@
|
||||
-- 0004_ai_provider_presets: ai_settings.provider and ai_runs.provider become
|
||||
-- openai_compatible, anthropic, ollama, openai, openrouter, the presets in
|
||||
-- internal/ai/llm/presets. Every 0001 value is kept, so existing rows copy
|
||||
-- unchanged. SQLite cannot change a CHECK in place, so both tables are
|
||||
-- rebuilt: copy the rows aside, drop the table, create it again with the
|
||||
-- wider CHECK, copy the rows back and drop the copy. Neither table has
|
||||
-- indexes or triggers.
|
||||
--
|
||||
-- suggestion_batches.ai_run_id and week_plans.ai_run_id reference ai_runs,
|
||||
-- so the rebuild cannot use 0003's create-new-then-rename: dropping ai_runs
|
||||
-- counts every referencing row as a foreign key violation, and only inserting
|
||||
-- the matching ai_runs rows under the name ai_runs clears them. Deferring
|
||||
-- foreign keys holds the check until commit, after the rows are back. The
|
||||
-- pragma lasts only for this migration's transaction.
|
||||
|
||||
PRAGMA defer_foreign_keys = ON;
|
||||
|
||||
CREATE TABLE ai_settings_copy AS SELECT * FROM ai_settings;
|
||||
|
||||
DROP TABLE ai_settings;
|
||||
|
||||
CREATE TABLE ai_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama', 'openai', 'openrouter')),
|
||||
base_url TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
temperature REAL NOT NULL,
|
||||
system_prompt TEXT,
|
||||
api_key_ct BLOB,
|
||||
api_key_last4 TEXT,
|
||||
key_verified_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO ai_settings (id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at)
|
||||
SELECT id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at
|
||||
FROM ai_settings_copy;
|
||||
|
||||
DROP TABLE ai_settings_copy;
|
||||
|
||||
CREATE TABLE ai_runs_copy AS SELECT * FROM ai_runs;
|
||||
|
||||
DROP TABLE ai_runs;
|
||||
|
||||
CREATE TABLE ai_runs (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
skill TEXT NOT NULL,
|
||||
skill_version TEXT NOT NULL,
|
||||
provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama', 'openai', 'openrouter')),
|
||||
model TEXT NOT NULL,
|
||||
temperature REAL NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('running', 'ok', 'error')),
|
||||
latency_ms INTEGER,
|
||||
tokens_in INTEGER,
|
||||
tokens_out INTEGER,
|
||||
error TEXT,
|
||||
request_redacted TEXT NOT NULL,
|
||||
response_redacted TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO ai_runs (id, skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at)
|
||||
SELECT id, skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at
|
||||
FROM ai_runs_copy;
|
||||
|
||||
DROP TABLE ai_runs_copy;
|
||||
@@ -0,0 +1,64 @@
|
||||
-- 0005_ai_provider_xai: ai_settings.provider and ai_runs.provider also
|
||||
-- accept xai, the xAI Grok preset in internal/ai/llm/presets. Every 0004
|
||||
-- value is kept, so existing rows copy unchanged. The rebuild is 0004's:
|
||||
-- copy the rows aside, drop the table, create it again with the wider CHECK,
|
||||
-- copy the rows back and drop the copy. Neither table has indexes or
|
||||
-- triggers.
|
||||
--
|
||||
-- suggestion_batches.ai_run_id and week_plans.ai_run_id reference ai_runs,
|
||||
-- so foreign keys are deferred until commit, after the ai_runs rows are
|
||||
-- back. The pragma lasts only for this migration's transaction.
|
||||
|
||||
PRAGMA defer_foreign_keys = ON;
|
||||
|
||||
CREATE TABLE ai_settings_copy AS SELECT * FROM ai_settings;
|
||||
|
||||
DROP TABLE ai_settings;
|
||||
|
||||
CREATE TABLE ai_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama', 'openai', 'openrouter', 'xai')),
|
||||
base_url TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
temperature REAL NOT NULL,
|
||||
system_prompt TEXT,
|
||||
api_key_ct BLOB,
|
||||
api_key_last4 TEXT,
|
||||
key_verified_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO ai_settings (id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at)
|
||||
SELECT id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at
|
||||
FROM ai_settings_copy;
|
||||
|
||||
DROP TABLE ai_settings_copy;
|
||||
|
||||
CREATE TABLE ai_runs_copy AS SELECT * FROM ai_runs;
|
||||
|
||||
DROP TABLE ai_runs;
|
||||
|
||||
CREATE TABLE ai_runs (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
skill TEXT NOT NULL,
|
||||
skill_version TEXT NOT NULL,
|
||||
provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama', 'openai', 'openrouter', 'xai')),
|
||||
model TEXT NOT NULL,
|
||||
temperature REAL NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('running', 'ok', 'error')),
|
||||
latency_ms INTEGER,
|
||||
tokens_in INTEGER,
|
||||
tokens_out INTEGER,
|
||||
error TEXT,
|
||||
request_redacted TEXT NOT NULL,
|
||||
response_redacted TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO ai_runs (id, skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at)
|
||||
SELECT id, skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at
|
||||
FROM ai_runs_copy;
|
||||
|
||||
DROP TABLE ai_runs_copy;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- name: CreateJob :exec
|
||||
INSERT INTO jobs (id, kind, status, payload, created_at, updated_at)
|
||||
VALUES (?, ?, 'queued', ?, ?, ?);
|
||||
|
||||
-- name: ClaimNextJob :one
|
||||
-- Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
||||
-- when no job is queued.
|
||||
UPDATE jobs
|
||||
SET status = 'running', started_at = sqlc.arg(now), updated_at = sqlc.arg(now)
|
||||
WHERE id = (SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1)
|
||||
RETURNING *;
|
||||
|
||||
-- name: PublishJobResult :execrows
|
||||
-- Stores a partial result of a running job.
|
||||
UPDATE jobs
|
||||
SET result = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'running';
|
||||
|
||||
-- name: SucceedJob :execrows
|
||||
UPDATE jobs
|
||||
SET status = 'succeeded', result = sqlc.arg(result), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now)
|
||||
WHERE id = sqlc.arg(id) AND status = 'running';
|
||||
|
||||
-- name: FailJob :execrows
|
||||
UPDATE jobs
|
||||
SET status = 'failed', error = sqlc.arg(error), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now)
|
||||
WHERE id = sqlc.arg(id) AND status = 'running';
|
||||
|
||||
-- name: FailRunningJobs :execrows
|
||||
-- Fails every job left running, at boot before any worker starts.
|
||||
UPDATE jobs
|
||||
SET status = 'failed', error = sqlc.arg(error), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now)
|
||||
WHERE status = 'running';
|
||||
|
||||
-- name: GetJob :one
|
||||
SELECT * FROM jobs
|
||||
WHERE id = ?;
|
||||
Reference in New Issue
Block a user