Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 5a1ee389dc Keep MealQuest firewall rules first and off docker.service
CI / ci (push) Successful in 1m33s
CI / ci (pull_request) Successful in 1m33s
Address review of the port 8090 firewall:

- Insert the three MealQuest rules at the top of DOCKER-USER, ahead of
  other projects' rules. Each run deletes every rule tagged
  "mealquest-firewall" and re-inserts them at positions 1-3, so re-runs
  restore the order and never duplicate.
- Scope the match to MealQuest's published address with
  --ctorigdst 10.60.1.14, so outbound container connections to a remote
  :8090 are not matched.
- Drop the docker.service ExecStartPost drop-in, which made a firewall
  error fatal to the shared Docker daemon. The unit is now PartOf= and
  WantedBy= docker.service: it re-runs on every Docker start or restart
  and fails visibly on its own without failing docker.service.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:47:36 -04:00
dev_agentandClaude Opus 5.5 63808ccd20 Add MealQuest host firewall unit for port 8090
CI / ci (pull_request) Successful in 1m33s
CI / ci (push) Successful in 1m33s
Restrict the published MealQuest port 8090 in DOCKER-USER to Traefik
(10.60.1.33) and the Docker host (10.60.1.14), dropping all other
sources. DOCKER-USER sees post-DNAT traffic, so rules match the original
destination port with conntrack rather than --dport. A docker.service
drop-in re-applies the rules after every docker restart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:35:39 -04:00
dev_agent 3b524ec118 Compose stack and env template (#54)
CI / ci (push) Successful in 1m46s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-05 08:08:16 -04:00
approver_agent 8adbd2d7c2 Merge pull request 'Feature 08: LLM provider layer' (#49) from feature/5dd6e253-cc9b-41f3-9091-bb834f7a373a-llm-provider-layer into dev
CI / ci (push) Successful in 1m31s
2026-10-04 16:13:44 -04:00
dev_agent b347e1c911 Retry, backoff and default Generate timeout (#48)
CI / ci (push) Successful in 1m28s
CI / ci (pull_request) Successful in 1m30s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-04 11:02:55 -04:00
dev_agent e105cdb9c5 Add xAI Grok as a provider (#46)
CI / ci (push) Successful in 1m28s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-04 09:36:10 -04:00
approver_agent d80012a821 Merge pull request 'Feature 05: Frontend foundation and design system' (#43) from feature/64e5bb19-c9a2-4c6a-8f0c-aceae43fb732-frontend-foundation into dev
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (pull_request) Successful in 1m32s
CI / ci (push) Successful in 1m36s
2026-10-04 09:18:57 -04:00
dev_agent 9ace3aba9f Provider presets and factory (#44)
CI / ci (push) Successful in 1m28s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-04 09:18:34 -04:00
dev_agent 5758f198e4 Keep the CSRF token on same-origin API calls and lint colours in the build (#45)
CI / ci (push) Successful in 1m33s
CI / ci (pull_request) Successful in 1m31s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-04 09:08:42 -04:00
dev_agent 32ab0c0b6d Ollama native adapter (#41)
CI / ci (push) Successful in 1m29s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-04 08:47:05 -04:00
dev_agent 048d5dd71a API client, auth bootstrap and job polling (#42)
CI / ci (push) Successful in 1m32s
CI / ci (pull_request) Successful in 1m33s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-04 08:46:40 -04:00
dev_agent 15886f2c55 Anthropic Messages adapter (#40)
CI / ci (push) Successful in 1m27s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-04 08:09:20 -04:00
dev_agent acd29c8221 Feature 08 task 2: OpenAI-compatible adapter (#35)
CI / ci (push) Successful in 1m28s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-04 07:41:45 -04:00
dev_agent c899f07b1f Nav pill slide animation (#39)
CI / ci (push) Successful in 1m29s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 18:52:57 -04:00
dev_agent 998658b42c Responsive app shell, routes and navigation (#38)
CI / ci (push) Successful in 1m30s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 18:40:34 -04:00
dev_agent a453a84231 Photo and Icon components (#37)
CI / ci (push) Successful in 1m30s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 18:29:16 -04:00
dev_agent 732aa92a8c Sheet, ErrorBanner, Skeleton and EmptyState (#36)
CI / ci (push) Successful in 1m30s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 18:22:59 -04:00
dev_agent 02f1dd6491 Feature 05 task 2: Core UI primitives (#34)
CI / ci (push) Successful in 1m31s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 16:34:59 -04:00
dev_agent 72042516e2 Feature 08 task 1: Provider interface, typed errors and Fake provider (#33)
CI / ci (push) Successful in 1m29s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 16:14:12 -04:00
147 changed files with 12183 additions and 33 deletions
+2
View File
@@ -3,6 +3,8 @@ design/.DS_Store
# Secrets and local environment
.env*
!deploy/.env.example
deploy/.env
deploy/secrets/
# Build artifacts and dependencies
+80
View File
@@ -0,0 +1,80 @@
# MealQuest production environment. Copy to deploy/.env (never committed)
# and replace the placeholders. The service fails fast: a missing or invalid
# required variable stops it at boot with the variable named.
#
# Secrets are never set here. They live in deploy/secrets/ (never committed),
# one value per file, owned by the container user and readable only by it:
#
# mkdir -p deploy/secrets && chmod 700 deploy/secrets
# head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key
# printf '%s\n' '<Authentik client secret>' > deploy/secrets/oidc_client_secret
# sudo chown 10001:10001 deploy/secrets/* && sudo chmod 600 deploy/secrets/*
#
# deploy/compose.yaml mounts them at /run/secrets/<name> and points the
# *_FILE variables at them.
# --- Compose -----------------------------------------------------------------
# Host address the service is published on, as <address>:8090. Production
# binds the LAN address 10.60.1.14 only; 127.0.0.1 suits a dev machine.
MEALQUEST_BIND_ADDRESS=127.0.0.1
# --- Service -----------------------------------------------------------------
# Public origin the household opens. Fixes the OIDC redirect URI
# (<url>/auth/callback) and the Secure cookies. Must be https:// (plain
# http:// is accepted only for localhost, 127.0.0.1 and [::1]).
MEALQUEST_PUBLIC_URL=https://mealquest.cubecraftlabs.com
# How long a graceful shutdown may take before in-flight requests are cut.
MEALQUEST_SHUTDOWN_TIMEOUT=10s
# Optional. Comma-separated IPs/CIDRs of reverse proxies whose
# X-Forwarded-For/-Proto headers are honoured. Production lists Traefik;
# unset trusts no peer.
MEALQUEST_TRUSTED_PROXY=10.60.1.33
# IANA zone the household settings are created with on first boot. Required
# until those settings exist; ignored afterwards (change the zone in
# household settings instead). Confirm the household's zone before the first
# deploy.
MEALQUEST_DEFAULT_TZ=America/New_York
# --- Authentik OIDC ----------------------------------------------------------
# Issuer of the Authentik application "mealquest". Discovered at boot, so it
# must be reachable from the container. Must be https://.
MEALQUEST_OIDC_ISSUER=https://auth.cubecraftlabs.com/application/o/mealquest/
MEALQUEST_OIDC_CLIENT_ID=mealquest
# Comma-separated Authentik groups whose members may sign in.
MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# Optional session lifetimes. A session ends after IDLE unused and always MAX
# after sign-in; MAX must be at least IDLE. Defaults: 720h and 4320h.
MEALQUEST_SESSION_IDLE=720h
MEALQUEST_SESSION_MAX=4320h
# --- Set by deploy/compose.yaml; do not set here -----------------------------
#
# MEALQUEST_HTTP_ADDR=:8080
# MEALQUEST_DATA_DIR=/data
# MEALQUEST_SECRET_KEY_FILE=/run/secrets/secret_key
# Base64 of 32 random bytes; encrypts secrets at rest (AES-256-GCM).
# MEALQUEST_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
# The Authentik provider's client secret.
# --- Planned; not read by the service yet -------------------------------------
#
# Added to deploy/compose.yaml and this file by the features that use them.
#
# Web Push (Feature 19). Generate the key pair with cmd/vapidgen.
# MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
# MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
#
# Google Calendar OAuth web client (Feature 16).
# MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret
#
# SearXNG recipe search sidecar on the compose network (Feature 07).
# MEALQUEST_SEARXNG_URL=http://searxng:8080
+61
View File
@@ -0,0 +1,61 @@
# Production stack for MealQuest on 10.60.1.14 (D-018). Build and start on
# the host with:
#
# docker compose -f deploy/compose.yaml up -d --build
#
# Configuration comes from deploy/.env (copy deploy/.env.example) and secrets
# from deploy/secrets/; neither is committed. Compose bind-mounts file secrets
# with their host owner and mode, so each file in deploy/secrets/ must be
# owned by the container user 10001:10001 with mode 0600.
name: mealquest
services:
mealquest:
build:
context: ..
dockerfile: Dockerfile
user: "10001:10001"
restart: unless-stopped
env_file:
- path: .env
required: true
# Fixed by this file's container shape (listen port, volume, secret
# mounts); deploy/.env must not set them.
environment:
MEALQUEST_HTTP_ADDR: ":8080"
MEALQUEST_DATA_DIR: /data
MEALQUEST_SECRET_KEY_FILE: /run/secrets/secret_key
MEALQUEST_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client_secret
secrets:
- secret_key
- oidc_client_secret
volumes:
- mealquest-data:/data
ports:
- "${MEALQUEST_BIND_ADDRESS:?set MEALQUEST_BIND_ADDRESS in deploy/.env}:8090:8080"
networks:
- mealquest
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/health"]
interval: 30s
timeout: 3s
start_period: 10s
retries: 3
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
secrets:
secret_key:
file: ./secrets/secret_key
oidc_client_secret:
file: ./secrets/oidc_client_secret
volumes:
mealquest-data:
name: mealquest-data
networks:
mealquest:
name: mealquest
+22
View File
@@ -0,0 +1,22 @@
# Applies the MealQuest DOCKER-USER rules (/usr/local/sbin/mealquest-firewall).
#
# PartOf= re-runs this unit whenever docker.service is stopped or restarted,
# and WantedBy=docker.service starts it whenever docker.service starts. Both
# are one-way: a failure here fails this unit visibly and never fails
# docker.service.
#
# Start MealQuest only after this unit has succeeded:
# systemctl start mealquest-firewall && docker compose up -d
[Unit]
Description=Restrict MealQuest port 8090 to Traefik (DOCKER-USER)
After=docker.service
Requires=docker.service
PartOf=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/mealquest-firewall
[Install]
WantedBy=multi-user.target docker.service
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
# Restrict the MealQuest published host port (10.60.1.14:8090) to Traefik
# (10.60.1.33) and the Docker host itself (10.60.1.14); drop every other
# source.
#
# DOCKER-USER runs after Docker's DNAT, so the destination there is the
# container address and port, not the published ones. Match the original
# destination address and port with conntrack instead of -d/--dport.
#
# The rules must sit at the top of DOCKER-USER, ahead of any other project's
# rules (an earlier RETURN or DROP would decide MealQuest traffic first).
# Every run deletes all rules tagged with the mealquest-firewall comment and
# re-inserts them at positions 1-3, so re-runs leave the same ordered rules
# and no duplicates. Installed as /usr/local/sbin/mealquest-firewall.
set -eu
tag="-m comment --comment mealquest-firewall"
match="-m conntrack --ctorigdst 10.60.1.14 --ctorigdstport 8090 --ctdir ORIGINAL"
rules=$(iptables -w -S DOCKER-USER)
printf '%s\n' "$rules" | while read -r _ chain spec; do
case " $spec " in
*" --comment mealquest-firewall "*) iptables -w -D "$chain" $spec ;;
esac
done
iptables -w -I DOCKER-USER 1 -s 10.60.1.33/32 $match $tag -j RETURN
iptables -w -I DOCKER-USER 2 -s 10.60.1.14/32 $match $tag -j RETURN
iptables -w -I DOCKER-USER 3 $match $tag -j DROP
+560
View File
@@ -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
View File
@@ -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. |
+1
View File
@@ -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}
}
+18
View File
@@ -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
}
}
+13
View File
@@ -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"
}
+18
View File
@@ -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"
}
+168
View File
@@ -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 }
+212
View File
@@ -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)
}
}
+127
View File
@@ -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
}
+138
View File
@@ -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)
}
}
+133
View File
@@ -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
}
+44
View File
@@ -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)
}
}
+325
View File
@@ -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}
}
+615
View File
@@ -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
View File
@@ -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.
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
]
}
+632
View File
@@ -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
View File
@@ -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}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "chatcmpl-419",
"object": "chat.completion",
"created": 1741570340,
"model": "llama3.2",
"system_fingerprint": "fp_ollama",
"choices": [
{"index": 0, "message": {"role": "assistant", "content": "Try a lemon orzo bake tonight."}, "finish_reason": "stop"}
],
"usage": {"prompt_tokens": 40, "completion_tokens": 9, "total_tokens": 49}
}
@@ -0,0 +1,15 @@
{
"id": "chatcmpl-418",
"object": "chat.completion",
"created": 1741570330,
"model": "llama3.2",
"system_fingerprint": "fp_ollama",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "{\"dinners\": [{\"name\": \"Crock pot ch"},
"finish_reason": "length"
}
],
"usage": {"prompt_tokens": 512, "completion_tokens": 16, "total_tokens": 528}
}
@@ -0,0 +1 @@
{"error": {"message": "model \"llama9\" not found, try pulling it first", "type": "api_error", "param": null, "code": null}}
+7
View File
@@ -0,0 +1,7 @@
{
"object": "list",
"data": [
{"id": "llama3.2:latest", "object": "model", "created": 1741000000, "owned_by": "library"},
{"id": "qwen2.5:7b", "object": "model", "created": 1740000000, "owned_by": "library"}
]
}
@@ -0,0 +1,28 @@
{
"id": "chatcmpl-B9MHDbslfkBeAs8l4bebGdFOJ6PeG",
"object": "chat.completion",
"created": 1741570283,
"model": "gpt-4o-2024-08-06",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "{\"dinners\":[{\"name\":\"Sheet-pan gnocchi\",\"why\":\"Like your Regular tray bake, but quicker.\"}]}",
"refusal": null,
"annotations": []
},
"logprobs": null,
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 1117,
"completion_tokens": 46,
"total_tokens": 1163,
"prompt_tokens_details": {"cached_tokens": 0, "audio_tokens": 0},
"completion_tokens_details": {"reasoning_tokens": 0, "audio_tokens": 0, "accepted_prediction_tokens": 0, "rejected_prediction_tokens": 0}
},
"service_tier": "default",
"system_fingerprint": "fp_fc9f1d7035"
}
@@ -0,0 +1,16 @@
{
"id": "chatcmpl-B9MHDbslfkBeAs8l4bebGdFOJ6PeH",
"object": "chat.completion",
"created": 1741570290,
"model": "gpt-4o-2024-08-06",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": null, "refusal": "I'm sorry, I can't help with that request.", "annotations": []},
"logprobs": null,
"finish_reason": "stop"
}
],
"usage": {"prompt_tokens": 210, "completion_tokens": 11, "total_tokens": 221},
"system_fingerprint": "fp_fc9f1d7035"
}
@@ -0,0 +1,8 @@
{
"error": {
"message": "Incorrect API key provided: {{KEY}}. You can find your API key at https://platform.openai.com/account/api-keys.",
"type": "invalid_request_error",
"param": null,
"code": "invalid_api_key"
}
}
@@ -0,0 +1 @@
{"error": {"message": "The server had an error while processing your request. Authorization: Bearer {{KEY}}", "type": "server_error", "param": null, "code": null}}
+7
View File
@@ -0,0 +1,7 @@
{
"object": "list",
"data": [
{"id": "gpt-4o-2024-08-06", "object": "model", "created": 1722814719, "owned_by": "system"},
{"id": "gpt-4o-mini", "object": "model", "created": 1721172741, "owned_by": "system"}
]
}
@@ -0,0 +1,22 @@
{
"id": "gen-1741570300-xQmT9vWb4kR2nP7sL3aE",
"provider": "OpenAI",
"model": "openai/gpt-4o-mini",
"object": "chat.completion",
"created": 1741570300,
"choices": [
{
"logprobs": null,
"finish_reason": "stop",
"native_finish_reason": "stop",
"index": 0,
"message": {
"role": "assistant",
"content": "{\n \"dinners\": [{\"name\": \"Chicken tinga tacos\", \"why\": \"Close to your taco-night Regular.\"}]\n}",
"refusal": null,
"reasoning": null
}
}
],
"usage": {"prompt_tokens": 842, "completion_tokens": 31, "total_tokens": 873}
}
@@ -0,0 +1,5 @@
{
"id": "gen-1741570311-aB3dE5fG7hJ9kL1mN2pQ",
"error": {"code": 502, "message": "Upstream error from provider", "metadata": {"provider_name": "Lambda", "raw": "upstream returned 502"}},
"user_id": "user_2abcDEF"
}
@@ -0,0 +1 @@
{"error": {"code": 429, "message": "Rate limit exceeded: free-models-per-min. Key {{KEY}}", "metadata": {"headers": {"X-RateLimit-Limit": "20", "X-RateLimit-Remaining": "0"}}}}
+22
View File
@@ -0,0 +1,22 @@
{
"data": [
{
"id": "openai/gpt-4o-mini",
"canonical_slug": "openai/gpt-4o-mini",
"name": "OpenAI: GPT-4o-mini",
"created": 1721260800,
"description": "GPT-4o mini is OpenAI's newest model after GPT-4 Omni.",
"context_length": 128000,
"architecture": {"modality": "text+image->text", "input_modalities": ["text", "image"], "output_modalities": ["text"], "tokenizer": "GPT"},
"pricing": {"prompt": "0.00000015", "completion": "0.0000006"},
"supported_parameters": ["max_tokens", "temperature", "response_format", "structured_outputs"]
},
{
"id": "anthropic/claude-sonnet-5",
"name": "Anthropic: Claude Sonnet 5",
"created": 1767225600,
"context_length": 200000,
"supported_parameters": ["max_tokens", "temperature"]
}
]
}
+329
View File
@@ -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}
}
+662
View File
@@ -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)
}
}
+124
View File
@@ -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()
}
}
+359
View File
@@ -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)
}
}
+68
View File
@@ -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")
)
+181 -4
View File
@@ -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,0003_jobs_status" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", 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,0003_jobs_status" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", 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")
@@ -309,3 +309,180 @@ func TestMigrateJobsStatusMapsLegacyRows(t *testing.T) {
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)
}
}
}
+19 -2
View File
@@ -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"},
@@ -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"} {
+68
View File
@@ -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;
+64
View File
@@ -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;
+18 -2
View File
@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@tanstack/react-query": "^5.104.1",
"lucide-react": "^1.51.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router": "^7.18.4"
@@ -736,7 +737,7 @@
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1261,7 +1262,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/data-urls": {
@@ -1760,6 +1761,21 @@
"node": "20 || >=22"
}
},
"node_modules/lucide-react": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.51.0.tgz",
"integrity": "sha512-9929jimol5y7VbXUlVKcgURMSeIxFJB7dtWCkRAaNnF7KiCRkSkhmqWPUmAmUFYPezNawFk6qvz3Mu0gEnfI4g==",
"license": "ISC",
"peerDependencies": {
"@types/react": "*",
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+2 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build && npm run check:dist-fonts",
"build": "npm run lint:colors && tsc --noEmit && vite build && npm run check:dist-fonts",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "npm run lint:colors && vitest run",
@@ -14,6 +14,7 @@
},
"dependencies": {
"@tanstack/react-query": "^5.104.1",
"lucide-react": "^1.51.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router": "^7.18.4"
+96 -10
View File
@@ -1,15 +1,101 @@
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { describe, expect, it } from 'vitest'
import App from './App.tsx'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApiClient } from './api/index.ts'
import { mockViewport } from './test/matchMedia.ts'
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts'
import App, { routes } from './App.tsx'
describe('App', () => {
it('renders the MealQuest shell', () => {
render(
<MemoryRouter>
<App />
</MemoryRouter>,
)
expect(screen.getByRole('heading', { name: 'MealQuest' })).toBeInTheDocument()
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
vi.unstubAllGlobals()
})
function renderAt(path: string, location = fakeLocation(path)) {
render(
<MemoryRouter initialEntries={[path]}>
<App api={createApiClient(location)} />
</MemoryRouter>,
)
return location
}
describe('App routes', () => {
it('registers every handoff route plus the extra settings pages', () => {
expect(routes.map((route) => route.path)).toEqual([
'/',
'/onboarding',
'/plan',
'/plan/shopping',
'/plan/shopping/archive',
'/discover',
'/quick',
'/cook-from-pantry',
'/dinner/:id',
'/settings',
'/settings/notifications',
'/settings/ai',
'/settings/household',
'/settings/regulars',
'/settings/calendar',
])
})
it.each(routes.map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
'%s renders its placeholder page inside the shell',
async (path, title) => {
mockViewport(390)
stubFetch(jsonResponse(200, ME))
renderAt(path)
const heading = await screen.findByRole('heading', { level: 1, name: title })
expect(screen.getByRole('main')).toContainElement(heading)
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
},
)
})
describe('App bootstrap', () => {
it('shows the skeleton until /me loads, then the routes', async () => {
mockViewport(390)
const fetchMock = stubFetch(jsonResponse(200, ME))
renderAt('/plan')
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
})
it('sends a signed-out visitor to sign-in and never renders the routes', async () => {
const fetchMock = stubFetch(errorResponse(401, 'unauthenticated'))
const location = renderAt('/discover', fakeLocation('/discover', '?swipe=1'))
await vi.waitFor(() => expect(location.assign).toHaveBeenCalledWith('/auth/login?return=%2Fdiscover%3Fswipe%3D1'))
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toBeInTheDocument()
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
})
it('shows the not-in-this-household screen on a 403', async () => {
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
const location = renderAt('/')
expect(await screen.findByText(/not in this household/)).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Not in this household' })).toBeInTheDocument()
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(location.assign).not.toHaveBeenCalled()
})
it('reports a /me failure with a retry instead of rendering the routes', async () => {
mockViewport(390)
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME))
renderAt('/')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
screen.getByRole('button', { name: 'Retry' }).click()
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
})
})
+46 -13
View File
@@ -1,25 +1,58 @@
import { useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { QueryClientProvider } from '@tanstack/react-query'
import { Route, Routes } from 'react-router'
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
import { AuthBootstrap } from './AuthBootstrap.tsx'
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
import { AppShell } from './shell/AppShell.tsx'
// Placeholder shell only: no product UI, auth or API calls yet.
function Shell() {
// Every route the app serves, with the heading its placeholder page shows.
export const routes = [
{ path: '/', title: 'Home' },
{ path: '/onboarding', title: 'Onboarding' },
{ path: '/plan', title: 'Plan' },
{ path: '/plan/shopping', title: 'Supply run' },
{ path: '/plan/shopping/archive', title: 'Supply archive' },
{ path: '/discover', title: 'Discover' },
{ path: '/quick', title: 'Quick dinners' },
{ path: '/cook-from-pantry', title: 'Cook from what I have' },
{ path: '/dinner/:id', title: 'Dinner' },
{ path: '/settings', title: 'Settings' },
{ path: '/settings/notifications', title: 'Notifications' },
{ path: '/settings/ai', title: 'AI assistant' },
{ path: '/settings/household', title: 'Household' },
{ path: '/settings/regulars', title: 'Regulars' },
{ path: '/settings/calendar', title: 'Calendar' },
] as const
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
export function AppRoutes() {
return (
<main>
<h1>MealQuest</h1>
</main>
<Routes>
<Route element={<AppShell />}>
{routes.map(({ path, title }) => (
<Route key={path} path={path} element={<PlaceholderPage title={title} />} />
))}
</Route>
</Routes>
)
}
export type AppProps = {
api: ApiClient
}
// App expects to be rendered inside a router (BrowserRouter in main.tsx,
// MemoryRouter in tests).
export default function App() {
const [queryClient] = useState(() => new QueryClient())
export default function App({ api }: AppProps) {
const [queryClient] = useState(createQueryClient)
return (
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="*" element={<Shell />} />
</Routes>
</QueryClientProvider>
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>
<AuthBootstrap>
<AppRoutes />
</AuthBootstrap>
</QueryClientProvider>
</ApiProvider>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { useSyncExternalStore, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ApiError, ME_PATH, useApi } from './api/index.ts'
import { NotInHouseholdPage } from './pages/NotInHouseholdPage.tsx'
import { ErrorBanner } from './ui/ErrorBanner.tsx'
import { Skeleton } from './ui/Skeleton.tsx'
import styles from './pages/FullScreen.module.css'
export const meQueryKey = [ME_PATH] as const
// AuthBootstrap loads GET /api/v1/me before rendering children. A 401 is
// already navigating to sign-in, so the skeleton stays up; an account outside
// the household sees only the "not in this household" screen.
export function AuthBootstrap({ children }: { children: ReactNode }) {
const api = useApi()
const forbidden = useSyncExternalStore(api.subscribe, api.forbidden)
const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity })
if (forbidden) return <NotInHouseholdPage />
if (me.isSuccess) return children
if (me.isError && !(me.error instanceof ApiError && me.error.status === 401)) {
return (
<main className={styles.screen}>
<ErrorBanner onRetry={() => void me.refetch()}>MealQuest could not load: {me.error.message}</ErrorBanner>
</main>
)
}
return (
<main className={styles.screen} aria-busy="true" aria-label="Loading MealQuest">
<div className={styles.loading}>
<Skeleton className={styles.title} />
<Skeleton />
<Skeleton />
<Skeleton />
</div>
</main>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { createContext, useContext, type ReactNode } from 'react'
import type { ApiClient } from './client.ts'
const ApiContext = createContext<ApiClient | null>(null)
export function ApiProvider({ client, children }: { client: ApiClient; children: ReactNode }) {
return <ApiContext.Provider value={client}>{children}</ApiContext.Provider>
}
// useApi returns the app's API client. It must be rendered inside ApiProvider.
export function useApi(): ApiClient {
const client = useContext(ApiContext)
if (client === null) throw new Error('useApi: no ApiProvider above this component')
return client
}
+158
View File
@@ -0,0 +1,158 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from '../test/fetchStub.ts'
import { ApiError, createApiClient, CSRF_HEADER } from './index.ts'
afterEach(() => {
vi.unstubAllGlobals()
})
describe('apiFetch', () => {
it('sends same-origin JSON and the cached CSRF token on non-GET requests only', async () => {
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, { ok: 1 }), jsonResponse(200, { ok: 2 }))
const api = createApiClient(fakeLocation())
await api.loadMe()
await expect(api.apiFetch('/api/v1/settings/household')).resolves.toEqual({ ok: 1 })
await expect(
api.apiFetch('/api/v1/settings/household', { method: 'PATCH', body: { dinnerTime: '18:00' } }),
).resolves.toEqual({ ok: 2 })
const get = request(fetchMock, 1)
expect(get).toMatchObject({ path: '/api/v1/settings/household', method: 'GET', credentials: 'same-origin' })
expect(get.headers.has(CSRF_HEADER)).toBe(false)
const patch = request(fetchMock, 2)
expect(patch).toMatchObject({ method: 'PATCH', credentials: 'same-origin', body: '{"dinnerTime":"18:00"}' })
expect(patch.headers.get(CSRF_HEADER)).toBe('token-1')
expect(patch.headers.get('Content-Type')).toBe('application/json')
})
it('refuses a non-GET request before /me has loaded', async () => {
const fetchMock = stubFetch()
const api = createApiClient(fakeLocation())
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
expect(fetchMock).not.toHaveBeenCalled()
})
it.each([
'https://evil.example/api/v1/me',
'//evil.example/api/v1/me',
// The placeholder origin an earlier guard compared against.
'//same-origin.invalid/api/v1/me',
'https://same-origin.invalid/api/v1/me',
'/\\evil.example/api/v1/me',
'/auth/logout',
'/api/v1/../auth/logout',
'/api/v1/%2e%2e/auth/logout',
'api/v1/me',
])('refuses %s without sending it or the CSRF token', async (path) => {
const fetchMock = stubFetch(jsonResponse(200, ME))
const api = createApiClient(fakeLocation())
await api.loadMe()
await expect(api.apiFetch(path, { method: 'POST', body: {} })).rejects.toThrow(
`POST ${path}: not a same-origin /api/v1/ path`,
)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' })
expect(request(fetchMock, 0).headers.has(CSRF_HEADER)).toBe(false)
})
it('rejects with a typed ApiError from the error body', async () => {
stubFetch(errorResponse(503, 'unavailable', true))
const api = createApiClient(fakeLocation())
const error = await api.apiFetch('/api/v1/jobs/j1').catch((e: unknown) => e)
expect(error).toBeInstanceOf(ApiError)
expect(error).toMatchObject({ status: 503, code: 'unavailable', message: 'unavailable message', retryable: true })
})
it('rejects a response without the API error body', async () => {
stubFetch(new Response('<html>bad gateway</html>', { status: 502 }))
const api = createApiClient(fakeLocation())
await expect(api.apiFetch('/api/v1/jobs/j1')).rejects.toThrow('GET /api/v1/jobs/j1: 502 response is not JSON')
})
it('on 401 navigates to sign-in with the current path and search to return to', async () => {
stubFetch(errorResponse(401, 'unauthenticated'))
const location = fakeLocation('/plan/shopping', '?week=2026-10-05')
const api = createApiClient(location)
await expect(api.apiFetch('/api/v1/settings/household')).rejects.toMatchObject({ status: 401 })
expect(location.assign).toHaveBeenCalledExactlyOnceWith(
'/auth/login?return=%2Fplan%2Fshopping%3Fweek%3D2026-10-05',
)
})
it('on a csrf 403 refreshes /me once and retries with the new token', async () => {
const fetchMock = stubFetch(
jsonResponse(200, ME),
errorResponse(403, 'csrf'),
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
jsonResponse(201, { id: 'j1' }),
)
const api = createApiClient(fakeLocation())
await api.loadMe()
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: { kind: 'noop' } })).resolves.toEqual({
id: 'j1',
})
expect(fetchMock).toHaveBeenCalledTimes(4)
expect(request(fetchMock, 1).headers.get(CSRF_HEADER)).toBe('token-1')
expect(request(fetchMock, 2)).toMatchObject({ path: '/api/v1/me', method: 'GET' })
expect(request(fetchMock, 3)).toMatchObject({ path: '/api/v1/jobs', method: 'POST', body: '{"kind":"noop"}' })
expect(request(fetchMock, 3).headers.get(CSRF_HEADER)).toBe('token-2')
expect(api.forbidden()).toBe(false)
})
it('does not loop when the retry gets a csrf 403 too', async () => {
const fetchMock = stubFetch(
jsonResponse(200, ME),
errorResponse(403, 'csrf'),
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
errorResponse(403, 'csrf'),
)
const api = createApiClient(fakeLocation())
await api.loadMe()
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toMatchObject({
status: 403,
code: 'csrf',
})
expect(fetchMock).toHaveBeenCalledTimes(4)
expect(api.forbidden()).toBe(false)
})
it('marks any other 403 as not in this household and notifies subscribers', async () => {
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
const api = createApiClient(fakeLocation())
const listener = vi.fn()
api.subscribe(listener)
await expect(api.apiFetch('/api/v1/me')).rejects.toMatchObject({ status: 403, code: 'forbidden' })
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(api.forbidden()).toBe(true)
expect(listener).toHaveBeenCalledTimes(1)
})
it('does not take a 403 without the API error body as not in this household', async () => {
stubFetch(new Response('<html>denied by proxy</html>', { status: 403 }))
const api = createApiClient(fakeLocation())
await expect(api.apiFetch('/api/v1/me')).rejects.toThrow('GET /api/v1/me: 403 response is not JSON')
expect(api.forbidden()).toBe(false)
})
it('does not refresh /me for a csrf 403 on a GET, which carries no token', async () => {
const fetchMock = stubFetch(errorResponse(403, 'csrf'))
const api = createApiClient(fakeLocation())
await expect(api.loadMe()).rejects.toMatchObject({ status: 403, code: 'csrf' })
expect(fetchMock).toHaveBeenCalledTimes(1)
})
})
describe('loadMe', () => {
it('rejects a /me response without a CSRF token', async () => {
stubFetch(jsonResponse(200, { ...ME, csrfToken: '' }))
const api = createApiClient(fakeLocation())
await expect(api.loadMe()).rejects.toThrow('response is not a member with a CSRF token')
})
})
+155
View File
@@ -0,0 +1,155 @@
import { ApiError, isRecord, parseApiError } from './errors.ts'
// CSRF_HEADER carries the session's CSRF token on every non-GET request.
export const CSRF_HEADER = 'X-MealQuest-CSRF'
export const ME_PATH = '/api/v1/me'
// API_PREFIX is the only path apiFetch sends to.
const API_PREFIX = '/api/v1/'
// apiURL resolves path against the page origin and returns it as the
// path-absolute URL to fetch. It refuses any path that would not resolve to a
// same-origin URL under /api/v1/, so the CSRF token is never sent anywhere
// else: absolute and protocol-relative URLs on another origin, and dot
// segments that climb out of /api/v1/.
function apiURL(origin: string, method: string, path: string): string {
const url = new URL(path, origin)
if (!path.startsWith('/') || url.origin !== origin || !url.pathname.startsWith(API_PREFIX)) {
throw new Error(`${method} ${path}: not a same-origin ${API_PREFIX} path`)
}
return url.pathname + url.search
}
// Me is the GET /api/v1/me response: the signed-in member, the household and
// the session's CSRF token.
export type Me = {
name: string
email: string
household: { label: string }
csrfToken: string
}
// ApiLocation is the part of window.location the client reads and navigates.
// origin is the page origin every request must stay on.
export type ApiLocation = Pick<Location, 'origin' | 'pathname' | 'search' | 'assign'>
export type ApiRequest = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
// Sent as JSON.
body?: unknown
}
export type ApiClient = {
// apiFetch sends a same-origin JSON request to path, which must be under
// /api/v1/, and resolves with the decoded response body, or rejects with an
// ApiError.
apiFetch<T>(path: string, request?: ApiRequest): Promise<T>
// loadMe fetches GET /api/v1/me and caches its CSRF token.
loadMe(): Promise<Me>
// forbidden reports whether a request was refused because the signed-in
// account is not in this household; subscribe is notified when it changes.
forbidden(): boolean
subscribe(listener: () => void): () => void
}
// createApiClient builds the app's API client. location is window.location in
// the app: a 401 sends it to /auth/login with the current path to return to.
export function createApiClient(location: ApiLocation): ApiClient {
let csrfToken: string | null = null
let refreshing: Promise<Me> | null = null
let forbidden = false
const listeners = new Set<() => void>()
async function send(method: string, path: string, body: unknown): Promise<Response> {
const headers: Record<string, string> = { Accept: 'application/json' }
if (body !== undefined) headers['Content-Type'] = 'application/json'
if (method !== 'GET') {
if (csrfToken === null) {
throw new Error(`${method} ${path}: no CSRF token; ${ME_PATH} has not loaded`)
}
headers[CSRF_HEADER] = csrfToken
}
return fetch(path, {
method,
headers,
credentials: 'same-origin',
body: body === undefined ? undefined : JSON.stringify(body),
})
}
// refreshMe shares one in-flight /me request between every request that
// got a csrf 403 at the same time.
function refreshMe(): Promise<Me> {
refreshing ??= loadMe().finally(() => {
refreshing = null
})
return refreshing
}
async function apiFetch<T>(path: string, request: ApiRequest = {}): Promise<T> {
const method = request.method ?? 'GET'
const url = apiURL(location.origin, method, path)
let response = await send(method, url, request.body)
if (response.status === 403) {
const error = await parseForbidden(method, path, response)
// Only a non-GET carries the token, so only it can be refused for it.
if (error.code !== 'csrf' || method === 'GET') throw error
// The session's token changed: refresh it once and retry once.
await refreshMe()
response = await send(method, url, request.body)
if (response.status === 403) throw await parseForbidden(method, path, response)
}
return decode<T>(method, path, response)
}
// parseForbidden reads a 403. Any API error other than csrf means the
// account is not in this household.
async function parseForbidden(method: string, path: string, response: Response): Promise<ApiError> {
const error = await parseApiError(method, path, response)
if (error.code !== 'csrf') markForbidden()
return error
}
function markForbidden() {
if (forbidden) return
forbidden = true
listeners.forEach((listener) => listener())
}
async function decode<T>(method: string, path: string, response: Response): Promise<T> {
if (response.status === 401) {
location.assign(`/auth/login?return=${encodeURIComponent(location.pathname + location.search)}`)
}
if (!response.ok) throw await parseApiError(method, path, response)
if (response.status === 204) return undefined as T
return (await response.json()) as T
}
async function loadMe(): Promise<Me> {
const me = await apiFetch<unknown>(ME_PATH)
if (
!isRecord(me) ||
typeof me.name !== 'string' ||
typeof me.email !== 'string' ||
!isRecord(me.household) ||
typeof me.household.label !== 'string' ||
typeof me.csrfToken !== 'string' ||
me.csrfToken === ''
) {
throw new Error(`GET ${ME_PATH}: response is not a member with a CSRF token`)
}
csrfToken = me.csrfToken
return me as Me
}
return {
apiFetch,
loadMe,
forbidden: () => forbidden,
subscribe(listener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
}
+41
View File
@@ -0,0 +1,41 @@
// ApiError is a failed API response carrying the server's
// {"error":{"code","message","retryable"}} body.
export class ApiError extends Error {
readonly status: number
readonly code: string
readonly retryable: boolean
constructor(status: number, code: string, message: string, retryable: boolean) {
super(message)
this.name = 'ApiError'
this.status = status
this.code = code
this.retryable = retryable
}
}
// parseApiError reads an error response body. A body that is not the API
// error shape is itself an error: the server always sends one.
export async function parseApiError(method: string, path: string, response: Response): Promise<ApiError> {
const text = await response.text()
let body: unknown
try {
body = JSON.parse(text)
} catch {
throw new Error(`${method} ${path}: ${response.status} response is not JSON: ${text.slice(0, 200)}`)
}
const error = isRecord(body) ? body.error : undefined
if (
!isRecord(error) ||
typeof error.code !== 'string' ||
typeof error.message !== 'string' ||
typeof error.retryable !== 'boolean'
) {
throw new Error(`${method} ${path}: ${response.status} response has no API error body`)
}
return new ApiError(response.status, error.code, error.message, error.retryable)
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
+5
View File
@@ -0,0 +1,5 @@
export { ApiProvider, useApi } from './ApiContext.tsx'
export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiLocation, type ApiRequest, type Me } from './client.ts'
export { ApiError } from './errors.ts'
export { createQueryClient, shouldRetry } from './queryClient.ts'
export { useJob, type Job, type JobState, type JobStatus } from './useJob.ts'
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { ApiError, createQueryClient, shouldRetry } from './index.ts'
describe('query retries', () => {
it('retry only an ApiError the server marked retryable, a bounded number of times', () => {
expect(shouldRetry(0, new ApiError(503, 'unavailable', 'try later', true))).toBe(true)
expect(shouldRetry(3, new ApiError(503, 'unavailable', 'try later', true))).toBe(false)
expect(shouldRetry(0, new ApiError(401, 'unauthenticated', 'sign in required', false))).toBe(false)
expect(shouldRetry(0, new ApiError(403, 'csrf', 'bad token', false))).toBe(false)
expect(shouldRetry(0, new Error('network down'))).toBe(false)
})
it('are the QueryClient default for queries and mutations', () => {
const options = createQueryClient().getDefaultOptions()
expect(options.queries?.retry).toBe(shouldRetry)
expect(options.mutations?.retry).toBe(shouldRetry)
})
})
+20
View File
@@ -0,0 +1,20 @@
import { QueryClient } from '@tanstack/react-query'
import { ApiError } from './errors.ts'
// MAX_RETRIES bounds retries of a failure the server marked retryable.
export const MAX_RETRIES = 3
// shouldRetry retries only an ApiError the server marked retryable — never a
// 401, a 403 or a malformed response.
export function shouldRetry(failureCount: number, error: unknown): boolean {
return error instanceof ApiError && error.retryable && failureCount < MAX_RETRIES
}
export function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: shouldRetry, staleTime: 30_000 },
mutations: { retry: shouldRetry },
},
})
}
+169
View File
@@ -0,0 +1,169 @@
import { act, renderHook } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { errorResponse, fakeLocation, jsonResponse, request, stubFetch } from '../test/fetchStub.ts'
import { ApiProvider, createApiClient, useJob } from './index.ts'
type Draft = { dinners: string[] }
function job(status: string, result: Draft | null, error: string | null = null) {
return jsonResponse(200, { id: 'j1', kind: 'plan.draft', status, result, error })
}
function renderJob(jobId: string | null) {
const api = createApiClient(fakeLocation())
const wrapper = ({ children }: { children: ReactNode }) => <ApiProvider client={api}>{children}</ApiProvider>
return renderHook(({ id }) => useJob<Draft>(id), { wrapper, initialProps: { id: jobId } })
}
// advance runs the timers due in ms and lets the polls they start answer.
async function advance(ms: number) {
await act(() => vi.advanceTimersByTimeAsync(ms))
}
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
vi.unstubAllGlobals()
})
describe('useJob', () => {
it('exposes the partial result while running, then the terminal result, backing off 500 ms to 2 s', async () => {
const fetchMock = stubFetch(
job('queued', null),
job('running', { dinners: ['Tacos'] }),
job('running', { dinners: ['Tacos', 'Chili'] }),
job('running', { dinners: ['Tacos', 'Chili'] }),
job('succeeded', { dinners: ['Tacos', 'Chili', 'Soup'] }),
)
const { result } = renderJob('j1')
expect(result.current).toEqual({ status: undefined, result: null, error: null })
await advance(0)
expect(result.current.status).toBe('queued')
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/jobs/j1', method: 'GET' })
await advance(499)
expect(fetchMock).toHaveBeenCalledTimes(1)
await advance(1)
expect(result.current).toEqual({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
await advance(999)
expect(fetchMock).toHaveBeenCalledTimes(2)
await advance(1)
expect(result.current.result).toEqual({ dinners: ['Tacos', 'Chili'] })
await advance(2000)
expect(fetchMock).toHaveBeenCalledTimes(4)
await advance(2000)
expect(fetchMock).toHaveBeenCalledTimes(5)
expect(result.current).toEqual({
status: 'succeeded',
result: { dinners: ['Tacos', 'Chili', 'Soup'] },
error: null,
})
// Terminal: no timer left, no further polls.
expect(vi.getTimerCount()).toBe(0)
await advance(10_000)
expect(fetchMock).toHaveBeenCalledTimes(5)
})
it('exposes a failed job as its error and stops', async () => {
const fetchMock = stubFetch(job('running', { dinners: ['Tacos'] }), job('failed', null, 'model refused'))
const { result } = renderJob('j1')
await advance(0)
await advance(500)
expect(result.current.status).toBe('failed')
expect(result.current.error?.message).toBe('model refused')
expect(vi.getTimerCount()).toBe(0)
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('exposes a failed poll as the error and keeps polling to the terminal status', async () => {
const fetchMock = stubFetch(
job('running', { dinners: ['Tacos'] }),
errorResponse(503, 'unavailable', true),
job('succeeded', { dinners: ['Tacos', 'Chili'] }),
)
const { result } = renderJob('j1')
await advance(0)
await advance(500)
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] } })
expect(result.current.error).toMatchObject({ status: 503, code: 'unavailable' })
expect(vi.getTimerCount()).toBe(1)
// The backoff carries on from the failed poll: the next one is 1 s later.
await advance(999)
expect(fetchMock).toHaveBeenCalledTimes(2)
await advance(1)
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(result.current).toEqual({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null })
expect(vi.getTimerCount()).toBe(0)
})
it('stops polling on unmount after a failed poll', async () => {
const fetchMock = stubFetch(errorResponse(503, 'unavailable', true))
const { result, unmount } = renderJob('j1')
await advance(0)
expect(result.current).toMatchObject({ status: undefined, result: null })
expect(result.current.error).toMatchObject({ status: 503 })
expect(vi.getTimerCount()).toBe(1)
unmount()
expect(vi.getTimerCount()).toBe(0)
await advance(10_000)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('stops polling the old job when the job id changes', async () => {
const fetchMock = stubFetch(job('running', null), job('running', null))
const { result, rerender } = renderJob('j1')
await advance(0)
rerender({ id: 'j2' })
expect(result.current).toEqual({ status: undefined, result: null, error: null })
await advance(0)
expect(request(fetchMock, 1).path).toBe('/api/v1/jobs/j2')
expect(vi.getTimerCount()).toBe(1)
})
it('stops polling on unmount', async () => {
const fetchMock = stubFetch(job('running', null))
const { unmount } = renderJob('j1')
await advance(0)
expect(vi.getTimerCount()).toBe(1)
unmount()
expect(vi.getTimerCount()).toBe(0)
await advance(10_000)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('ignores a poll that answers after unmount', async () => {
let answer!: (response: Response) => void
const fetchMock = stubFetch()
fetchMock.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)))
const { result, unmount } = renderJob('j1')
unmount()
await act(async () => answer(await Promise.resolve(job('running', null))))
expect(vi.getTimerCount()).toBe(0)
expect(result.current.status).toBeUndefined()
})
it('polls nothing without a job id', async () => {
const fetchMock = stubFetch()
const { result } = renderJob(null)
await advance(5000)
expect(fetchMock).not.toHaveBeenCalled()
expect(result.current).toEqual({ status: undefined, result: null, error: null })
})
})
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useState } from 'react'
import { useApi } from './ApiContext.tsx'
export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed'
// Job is the GET /api/v1/jobs/{id} response. result may be set while the job
// is running (a partial result); error is set only when it failed.
export type Job<R> = {
id: string
kind: string
status: JobStatus
result: R | null
error: string | null
}
export type JobState<R> = {
// undefined until the first poll answers.
status: JobStatus | undefined
// The latest result, partial while the job runs.
result: R | null
// The job's failure, or the latest polling request's own failure (cleared
// by the next poll that answers).
error: Error | null
}
// Polling starts at FIRST_DELAY_MS and doubles up to MAX_DELAY_MS.
export const FIRST_DELAY_MS = 500
export const MAX_DELAY_MS = 2000
const idle = { status: undefined, result: null, error: null }
function isTerminal(status: JobStatus): boolean {
return status === 'succeeded' || status === 'failed'
}
// useJob polls GET /api/v1/jobs/{jobId} until the job succeeds or fails,
// backing off from 500 ms to 2 s. A failed poll is exposed as the error and
// polling continues. Polling stops only at a terminal status, when jobId
// changes and on unmount. A null jobId polls nothing.
export function useJob<R>(jobId: string | null): JobState<R> {
const { apiFetch } = useApi()
const [state, setState] = useState<{ jobId: string | null; job: JobState<R> }>({ jobId, job: idle })
useEffect(() => {
if (jobId === null) return
let active = true
let timer: ReturnType<typeof setTimeout> | undefined
let delay = FIRST_DELAY_MS
function next() {
timer = setTimeout(poll, delay)
delay = Math.min(delay * 2, MAX_DELAY_MS)
}
async function poll() {
try {
const job = await apiFetch<Job<R>>(`/api/v1/jobs/${encodeURIComponent(jobId!)}`)
if (!active) return
const error = job.status === 'failed' ? new Error(job.error ?? `job ${job.id} failed without an error message`) : null
setState({ jobId, job: { status: job.status, result: job.result, error } })
if (isTerminal(job.status)) return
} catch (error) {
if (!active) return
// A failed poll is not the job's end: keep the last known status and
// result, expose the request's error, and poll again.
setState((prev) => ({
jobId,
job: { ...(prev.jobId === jobId ? prev.job : idle), error: error instanceof Error ? error : new Error(String(error)) },
}))
}
next()
}
void poll()
return () => {
active = false
clearTimeout(timer)
}
}, [apiFetch, jobId])
// State left from a previous jobId is not this job's.
return state.jobId === jobId ? state.job : idle
}
+4 -1
View File
@@ -4,16 +4,19 @@ import { BrowserRouter } from 'react-router'
import './styles/tokens.css'
import './styles/base.css'
import App from './App.tsx'
import { createApiClient } from './api/index.ts'
const root = document.getElementById('root')
if (!root) {
throw new Error('MealQuest: #root element missing from index.html')
}
const api = createApiClient(window.location)
createRoot(root).render(
<StrictMode>
<BrowserRouter>
<App />
<App api={api} />
</BrowserRouter>
</StrictMode>,
)
+23
View File
@@ -0,0 +1,23 @@
/* A full-viewport screen shown instead of the app shell. */
.screen {
min-height: 100dvh;
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--space-3);
padding: var(--space-8) var(--space-4);
background: var(--color-bg);
}
.loading {
width: min(100%, 480px);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.title {
height: 32px;
width: 60%;
}
+14
View File
@@ -0,0 +1,14 @@
import { EmptyState } from '../ui/EmptyState.tsx'
import styles from './FullScreen.module.css'
// Shown when the server refuses a signed-in account that is not in this
// household. Nothing else renders: the account can do nothing here.
export function NotInHouseholdPage() {
return (
<main className={styles.screen}>
<EmptyState title="Not in this household">
You are signed in, but this account is not in this household. Ask someone in Our kitchen to add it.
</EmptyState>
</main>
)
}
+9
View File
@@ -0,0 +1,9 @@
export type PlaceholderPageProps = {
// The route's screen name, shown as the page heading.
title: string
}
// Stands in for a screen until its feature is built.
export function PlaceholderPage({ title }: PlaceholderPageProps) {
return <h1>{title}</h1>
}
+24
View File
@@ -0,0 +1,24 @@
.shell {
position: relative;
height: 100dvh;
display: flex;
background: var(--color-bg);
}
.main {
flex: 1;
min-width: 0;
overflow-y: auto;
}
/* The bottom bar overlays the last 94 px of the screen (plus the safe area),
so the scroll area reserves at least 100 px beneath its content. */
.mobile .main {
padding: 0 20px;
padding-bottom: calc(100px + env(safe-area-inset-bottom));
}
.tablet .main,
.desktop .main {
padding: 32px 36px;
}
+211
View File
@@ -0,0 +1,211 @@
import { render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it } from 'vitest'
import { AppRoutes } from '../App.tsx'
import { declaredStyle, outerSize } from '../test/cssRules.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { mockViewport } from '../test/matchMedia.ts'
import bottomBarStyles from './BottomBar.module.css'
import railStyles from './Rail.module.css'
import sidebarStyles from './Sidebar.module.css'
const ORDER = ['Home', 'Plan', 'Discover', 'Quick', 'Settings']
const MOBILE = 390
const TABLET = 1180
const DESKTOP = 1440
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
})
function renderAt(path: string, width: number) {
const resize = mockViewport(width)
render(
<MemoryRouter initialEntries={[path]}>
<AppRoutes />
</MemoryRouter>,
)
return resize
}
function nav() {
return screen.getByRole('navigation', { name: 'Primary' })
}
function navLinks() {
return within(nav()).getAllByRole('link')
}
function current() {
return navLinks()
.filter((link) => link.getAttribute('aria-current') === 'page')
.map((link) => link.textContent)
}
describe('AppShell', () => {
it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', (width) => {
renderAt('/', width)
expect(navLinks().map((link) => link.textContent)).toEqual(ORDER)
expect(navLinks().map((link) => link.getAttribute('href'))).toEqual([
'/',
'/plan',
'/discover',
'/quick',
'/settings',
])
})
it('shows only the mobile bottom bar below 768 px', () => {
renderAt('/', 767)
expect(screen.getAllByRole('navigation')).toHaveLength(1)
expect(nav()).toHaveClass(bottomBarStyles.bar!)
expect(screen.queryByText('M')).not.toBeInTheDocument()
expect(screen.queryByText('MealQuest')).not.toBeInTheDocument()
expect(screen.queryByText('Our kitchen')).not.toBeInTheDocument()
const bar = declaredStyle(nav())
expect(bar.position).toBe('absolute')
expect(bar.left).toBe('12px')
expect(bar.right).toBe('12px')
expect(bar.bottom).toBe('calc(16px + env(safe-area-inset-bottom))')
expect(bar.height).toBe('62px')
expect(outerSize(nav(), 'height')).toBe(62)
expect(bar.background).toBe('var(--color-text)')
expect(bar.borderRadius).toBe('var(--radius-pill)')
})
it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', () => {
renderAt('/discover', MOBILE)
const pills = nav().querySelectorAll(`.${bottomBarStyles.pill}`)
expect(pills).toHaveLength(1)
const pill = declaredStyle(pills[0]!)
expect(pill.position).toBe('absolute')
expect(pill.top).toBe('10px')
expect(pill.height).toBe('42px')
expect(pill.background).toBe('var(--color-accent)')
expect(Number(pill.zIndex)).toBeLessThan(Number(declaredStyle(navLinks()[0]!).zIndex))
const active = screen.getByRole('link', { name: 'Discover' })
expect(declaredStyle(active).color).toBe('var(--on-accent)')
expect(within(active).getByText('Discover')).toHaveClass(bottomBarStyles.label!)
expect(declaredStyle(within(active).getByText('Discover')).fontSize).toBe('13px')
const inactive = screen.getByRole('link', { name: 'Plan' })
expect(declaredStyle(inactive).color).toBe('var(--color-neutral-400)')
expect(within(inactive).getByText('Plan')).toHaveClass(bottomBarStyles.hiddenLabel!)
})
it('makes the full 62 px bar height the mobile tap target', () => {
renderAt('/', MOBILE)
for (const link of navLinks()) {
expect(declaredStyle(link).height).toBe('62px')
expectMinHitArea(link)
}
})
it('reserves at least 100 px below the mobile scroll area', () => {
renderAt('/', MOBILE)
const padding = declaredStyle(screen.getByRole('main')).paddingBottom
const reserved = /^calc\((\d+)px \+ env\(safe-area-inset-bottom\)\)$/.exec(padding)
expect(reserved, `padding-bottom ${JSON.stringify(padding)}`).not.toBeNull()
expect(Number(reserved![1])).toBeGreaterThanOrEqual(100)
})
it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', (width) => {
renderAt('/plan', width)
expect(screen.getAllByRole('navigation')).toHaveLength(1)
expect(nav()).toHaveClass(railStyles.rail!)
expect(declaredStyle(nav()).width).toBe('96px')
expect(outerSize(nav(), 'width')).toBe(96)
expect(declaredStyle(nav()).background).toBe('var(--color-surface)')
const mark = within(nav()).getByText('M')
expect(declaredStyle(mark).width).toBe('48px')
expect(outerSize(mark, 'width')).toBe(48)
expect(outerSize(mark, 'height')).toBe(48)
expect(declaredStyle(mark).background).toBe('var(--color-accent)')
expect(screen.queryByText('MealQuest')).not.toBeInTheDocument()
expect(screen.queryByText('Our kitchen')).not.toBeInTheDocument()
for (const link of navLinks()) {
const pill = link.firstElementChild!
expect(declaredStyle(pill).width).toBe('56px')
expect(declaredStyle(pill).height).toBe('36px')
expect(outerSize(pill, 'width')).toBe(56)
expect(outerSize(pill, 'height')).toBe(36)
expect(declaredStyle(link).fontSize).toBe('11px')
expect(declaredStyle(link).fontWeight).toBe('700')
expectMinHitArea(link)
}
expect(screen.getByRole('link', { name: 'Plan' }).firstElementChild).toHaveClass(railStyles.active!)
expect(screen.getByRole('link', { name: 'Home' }).firstElementChild).not.toHaveClass(railStyles.active!)
})
it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', (width) => {
renderAt('/quick', width)
expect(screen.getAllByRole('navigation')).toHaveLength(1)
const sidebar = nav().closest('aside')!
expect(sidebar).toHaveClass(sidebarStyles.sidebar!)
expect(declaredStyle(sidebar).width).toBe('208px')
expect(declaredStyle(sidebar).background).toBe('var(--color-surface)')
expect(declaredStyle(sidebar).padding).toBe('28px 16px')
// The 16 px side padding sits inside the 208 px, not outside it.
expect(outerSize(sidebar, 'width')).toBe(208)
const wordmark = within(sidebar).getByText('MealQuest')
expect(declaredStyle(wordmark).fontFamily).toBe('var(--font-heading)')
expect(declaredStyle(wordmark).fontSize).toBe('28px')
expect(declaredStyle(wordmark).color).toBe('var(--color-accent)')
expect(within(sidebar).getByText('Our kitchen')).toBeInTheDocument()
expect(within(sidebar).getByText('Shared household')).toBeInTheDocument()
expect(screen.queryByText('M')).not.toBeInTheDocument()
const active = screen.getByRole('link', { name: 'Quick' })
expect(active).toHaveClass(sidebarStyles.active!)
expect(declaredStyle(active).background).toBe('var(--color-accent)')
expect(declaredStyle(active).color).toBe('var(--on-accent)')
expect(declaredStyle(active).fontSize).toBe('15px')
expect(screen.getByRole('link', { name: 'Home' })).not.toHaveClass(sidebarStyles.active!)
// Rows stretch across the sidebar, so only their height needs declaring.
for (const link of navLinks()) expect(declaredStyle(link).minHeight).toBe('44px')
})
it('switches layout when the viewport crosses a breakpoint', () => {
const resize = renderAt('/', MOBILE)
expect(nav()).toHaveClass(bottomBarStyles.bar!)
resize(TABLET)
expect(nav()).toHaveClass(railStyles.rail!)
resize(DESKTOP)
expect(nav().closest('aside')).toHaveClass(sidebarStyles.sidebar!)
resize(MOBILE)
expect(nav()).toHaveClass(bottomBarStyles.bar!)
expect(screen.getAllByRole('navigation')).toHaveLength(1)
})
describe.each([
['mobile', MOBILE],
['tablet', TABLET],
['desktop', DESKTOP],
])('aria-current (%s)', (_layout, width) => {
it.each([
['/', ['Home']],
['/plan', ['Plan']],
['/plan/shopping', ['Plan']],
['/plan/shopping/archive', ['Plan']],
['/discover', ['Discover']],
['/quick', ['Quick']],
['/settings', ['Settings']],
['/settings/notifications', ['Settings']],
['/settings/ai', ['Settings']],
['/settings/household', ['Settings']],
['/settings/regulars', ['Settings']],
['/settings/calendar', ['Settings']],
['/onboarding', []],
['/cook-from-pantry', []],
['/dinner/abc123', []],
])('%s marks %j current', (path, expected) => {
renderAt(path, width)
expect(current()).toEqual(expected)
})
})
})
+22
View File
@@ -0,0 +1,22 @@
import { Outlet } from 'react-router'
import { BottomBar } from './BottomBar.tsx'
import { Rail } from './Rail.tsx'
import { Sidebar } from './Sidebar.tsx'
import { useBreakpoint } from './useBreakpoint.ts'
import styles from './AppShell.module.css'
// The responsive frame around every page: one nav layout per breakpoint and a
// scrolling main area for the routed page.
export function AppShell() {
const breakpoint = useBreakpoint()
return (
<div className={`${styles.shell} ${styles[breakpoint]}`}>
{breakpoint === 'mobile' && <BottomBar />}
{breakpoint === 'tablet' && <Rail />}
{breakpoint === 'desktop' && <Sidebar />}
<main className={styles.main}>
<Outlet />
</main>
</div>
)
}
+65
View File
@@ -0,0 +1,65 @@
.bar {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(16px + env(safe-area-inset-bottom));
height: 62px;
padding: 0 6px;
box-sizing: border-box;
display: flex;
justify-content: space-around;
align-items: center;
border-radius: var(--radius-pill);
background: var(--color-text);
}
/* The one grape pill, shared by every item: BottomBar sets its left and width
to the active item's and slides it between items. */
.pill {
position: absolute;
top: 10px;
height: 42px;
z-index: 0;
border-radius: var(--radius-pill);
background: var(--color-accent);
pointer-events: none;
}
/* The whole bar height is the tap target; the 42 px pill sits behind it.
Items stay above the pill. */
.item {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 62px;
min-width: 44px;
padding: 0 13px;
box-sizing: border-box;
color: var(--color-neutral-400);
text-decoration: none;
white-space: nowrap;
transition: color .2s;
}
.item[aria-current='page'] {
color: var(--on-accent);
}
.label {
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
}
/* Inactive items are icon-only on screen but keep their name for assistive tech. */
.hiddenLabel {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
+282
View File
@@ -0,0 +1,282 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { AppRoutes } from '../App.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { mockViewport } from '../test/matchMedia.ts'
import styles from './BottomBar.module.css'
const MOBILE = 390
const SPRING = 'cubic-bezier(.34, 1.4, .64, 1)'
// jsdom does no layout, so lay the bar out here: items sit side by side from
// 6 px in, inactive items 48 px wide and the active one (icon + label) 110 px.
function layOut(element: HTMLElement): { left: number; width: number } {
const bar = element.parentElement!
const items = Array.from(bar.querySelectorAll<HTMLElement>(`.${styles.item}`))
let left = 6
for (const item of items) {
const width = item.getAttribute('aria-current') === 'page' ? 110 : 48
if (item === element) return { left, width }
left += width
}
return { left: 0, width: 0 }
}
// jsdom paints nothing either, so bounding boxes come from the laid-out
// offsets with the bar at the viewport's left edge. A test mid-slide sets
// where the pill is drawn and how far each item is still translated.
let drawnPill: { left: number; width: number } | null
let translated: Map<Element, number>
function box(left: number, width: number): DOMRect {
return { left, width, x: left, right: left + width, top: 0, y: 0, bottom: 0, height: 0 } as DOMRect
}
let animate: Mock<Element['animate']>
beforeEach(() => {
drawnPill = null
translated = new Map()
// jsdom has no Web Animations API.
animate = vi.fn<Element['animate']>(() => ({ cancel: vi.fn() }) as unknown as Animation)
Element.prototype.animate = animate
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (!(this instanceof HTMLElement)) return box(0, 0)
if (this.classList.contains(styles.pill!)) {
return drawnPill
? box(drawnPill.left, drawnPill.width)
: box(parseFloat(this.style.left), parseFloat(this.style.width))
}
if (this.classList.contains(styles.item!)) {
return box(this.offsetLeft + (translated.get(this) ?? 0), this.offsetWidth)
}
return box(0, 0)
})
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockImplementation(function (this: HTMLElement) {
return layOut(this).left
})
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) {
return layOut(this).width
})
})
afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(Element.prototype, 'animate')
Reflect.deleteProperty(window, 'matchMedia')
})
function renderAt(path: string, reducedMotion = false) {
mockViewport(MOBILE, { reducedMotion })
render(
<MemoryRouter initialEntries={[path]}>
<AppRoutes />
</MemoryRouter>,
)
}
function nav() {
return screen.getByRole('navigation', { name: 'Primary' })
}
function pill(): HTMLElement {
return nav().querySelector<HTMLElement>(`.${styles.pill}`)!
}
function link(name: string): HTMLElement {
return within(nav()).getByRole('link', { name })
}
function runningOn(element: Element): Animation[] {
return animate.mock.contexts
.map((target, i) => ({ target, animation: animate.mock.results[i]!.value as Animation }))
.filter(({ target }) => target === element)
.map(({ animation }) => animation)
}
function animationsOf(element: Element) {
return animate.mock.contexts
.map((target, i) => ({ target, args: animate.mock.calls[i]! }))
.filter(({ target }) => target === element)
.map(({ args }) => ({ keyframes: args[0], options: args[1] as KeyframeAnimationOptions }))
}
describe('BottomBar', () => {
it('places the pill on the active item without animating on first render', () => {
renderAt('/plan')
expect(pill().style.left).toBe('54px')
expect(pill().style.width).toBe('110px')
expect(pill()).toBeVisible()
expect(animate).not.toHaveBeenCalled()
})
it('hides the pill when no item is active', () => {
renderAt('/onboarding')
expect(pill()).not.toBeVisible()
})
it('slides the pill from the old item to the new one over 420 ms on the spring curve', () => {
renderAt('/')
fireEvent.click(link('Quick'))
expect(animationsOf(pill())).toEqual([
{
keyframes: [
{ left: '6px', width: '110px' },
{ left: '150px', width: '110px' },
],
options: { duration: 420, easing: SPRING },
},
])
expect(pill().style.left).toBe('150px')
expect(pill().style.width).toBe('110px')
})
it('FLIPs every item that moved from its old left to its new one with the same timing', () => {
renderAt('/')
fireEvent.click(link('Quick'))
// Home shrinks from 110 to 48 px, so Plan and Discover shift 62 px left
// and Quick grows in place; Settings ends where it started.
for (const name of ['Plan', 'Discover']) {
expect(animationsOf(link(name))).toEqual([
{
keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }],
options: { duration: 420, easing: SPRING },
},
])
}
expect(animationsOf(link('Quick'))).toEqual([
{
keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }],
options: { duration: 420, easing: SPRING },
},
])
expect(animationsOf(link('Home'))).toEqual([])
expect(animationsOf(link('Settings'))).toEqual([])
})
it('fades the new label in over 220 ms ease-out after 120 ms', () => {
renderAt('/')
fireEvent.click(link('Discover'))
const label = within(link('Discover')).getByText('Discover')
expect(label).toHaveClass(styles.label!)
expect(animationsOf(label)).toEqual([
{
keyframes: [
{ opacity: 0, transform: 'translateX(-4px)' },
{ opacity: 1, transform: 'none' },
],
options: { duration: 220, delay: 120, easing: 'ease-out', fill: 'backwards' },
},
])
})
it('does not animate when the active item stays the same', () => {
renderAt('/plan')
fireEvent.click(link('Plan'))
expect(animate).not.toHaveBeenCalled()
})
it('jumps the pill to the new item with no animation under reduced motion', () => {
renderAt('/', true)
fireEvent.click(link('Settings'))
expect(link('Settings')).toHaveAttribute('aria-current', 'page')
expect(animate).not.toHaveBeenCalled()
expect(pill().style.left).toBe('198px')
expect(pill().style.width).toBe('110px')
})
it('re-places the pill when the viewport resizes', () => {
renderAt('/discover')
expect(pill().style.left).toBe('102px')
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(120)
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96)
fireEvent(window, new Event('resize'))
expect(pill().style.left).toBe('120px')
expect(pill().style.width).toBe('96px')
expect(animate).not.toHaveBeenCalled()
})
it('starts a second slide from where the first one is on screen, not from its destination', () => {
renderAt('/')
fireEvent.click(link('Quick'))
const [firstPill] = runningOn(pill())
const firstPlan = runningOn(link('Plan'))[0]!
// Midway: the pill is drawn at 78 px on its way from 6 to 150, and Plan
// still carries 31 of its 62 px FLIP translation.
drawnPill = { left: 78, width: 110 }
translated.set(link('Plan'), 31)
animate.mockClear()
fireEvent.click(link('Settings'))
expect(firstPill!.cancel).toHaveBeenCalled()
expect(firstPlan.cancel).toHaveBeenCalled()
expect(animationsOf(pill())).toEqual([
{
keyframes: [
{ left: '78px', width: '110px' },
{ left: '198px', width: '110px' },
],
options: { duration: 420, easing: SPRING },
},
])
// Plan's layout does not move between Quick and Settings, so it only
// finishes the 31 px it had left. Settings moves 62 px left.
expect(animationsOf(link('Plan'))).toEqual([
{
keyframes: [{ transform: 'translateX(31px)' }, { transform: 'translateX(0)' }],
options: { duration: 420, easing: SPRING },
},
])
expect(animationsOf(link('Settings'))).toEqual([
{
keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }],
options: { duration: 420, easing: SPRING },
},
])
})
it('stops a running slide and places the pill on the active item when the viewport resizes', () => {
renderAt('/')
fireEvent.click(link('Quick'))
const slide = [pill(), link('Plan'), link('Discover'), link('Quick')].flatMap(runningOn)
expect(slide).toHaveLength(4)
animate.mockClear()
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(140)
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96)
fireEvent(window, new Event('resize'))
for (const animation of slide) expect(animation.cancel).toHaveBeenCalled()
expect(animate).not.toHaveBeenCalled()
expect(pill().style.left).toBe('140px')
expect(pill().style.width).toBe('96px')
})
it('re-places the pill once the web fonts are ready', async () => {
let fontsReady!: () => void
const ready = new Promise<void>((resolve) => (fontsReady = resolve))
Object.defineProperty(document, 'fonts', { configurable: true, value: { ready } })
try {
renderAt('/discover')
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(124)
fontsReady()
await ready
expect(pill().style.width).toBe('124px')
} finally {
Reflect.deleteProperty(document, 'fonts')
}
})
it('crossfades the icon colour with a .2s colour transition', () => {
renderAt('/')
for (const item of within(nav()).getAllByRole('link')) {
expect(declaredStyle(item).transition).toBe('color .2s')
}
})
})
+152
View File
@@ -0,0 +1,152 @@
import { useLayoutEffect, useRef } from 'react'
import { NavLink, useLocation } from 'react-router'
import { Icon } from '../ui/Icon.tsx'
import { navItems } from './navItems.ts'
import styles from './BottomBar.module.css'
// The handoff's Motion §1 timing. SPRING is the --ease-spring token's value.
const SLIDE_MS = 420
const SPRING = 'cubic-bezier(.34, 1.4, .64, 1)'
const LABEL_MS = 220
const LABEL_DELAY_MS = 120
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
// The active item and every item's layout offset within the bar when the pill
// was last placed (offsets ignore transforms, so a running FLIP does not skew
// them).
type Geometry = {
active: HTMLElement | null
itemLefts: number[]
}
// Mobile nav: a dark pill bar floating over the bottom of the screen. Inactive
// items show only their icon; the active item adds its label. One shared
// grape pill sits behind the items and slides to the active one.
export function BottomBar() {
const barRef = useRef<HTMLElement>(null)
const pillRef = useRef<HTMLSpanElement>(null)
const placed = useRef<Geometry | null>(null)
// The pill slide and item FLIPs currently running.
const sliding = useRef<Animation[]>([])
const stopSlide = () => {
sliding.current.forEach((animation) => animation.cancel())
sliding.current = []
}
const { pathname } = useLocation()
// After each navigation: if the active item changed, move the pill to it and,
// motion allowed, slide the pill, FLIP the items and fade the label in. A
// slide still running is measured where it is on screen, then stopped, so
// the next one starts from there instead of from its destination.
useLayoutEffect(() => {
const bar = barRef.current!
const pill = pillRef.current!
const active = activeItem(bar)
const before = placed.current
if (before && before.active === active) return
const from = before && onScreen(bar, pill, before)
stopSlide()
placed.current = place(bar, pill, active)
if (!before || !from || !active || window.matchMedia(REDUCED_MOTION_QUERY).matches) return
const after = placed.current
if (before.active) {
sliding.current.push(
pill.animate(
[
{ left: `${from.pill.left}px`, width: `${from.pill.width}px` },
{ left: `${active.offsetLeft}px`, width: `${active.offsetWidth}px` },
],
{ duration: SLIDE_MS, easing: SPRING },
),
)
}
items(bar).forEach((item, i) => {
const dx = from.itemLefts[i]! - after.itemLefts[i]!
if (dx === 0) return
sliding.current.push(
item.animate([{ transform: `translateX(${dx}px)` }, { transform: 'translateX(0)' }], {
duration: SLIDE_MS,
easing: SPRING,
}),
)
})
active.querySelector(`.${styles.label}`)!.animate(
[
{ opacity: 0, transform: 'translateX(-4px)' },
{ opacity: 1, transform: 'none' },
],
{ duration: LABEL_MS, delay: LABEL_DELAY_MS, easing: 'ease-out', fill: 'backwards' },
)
}, [pathname])
// Item widths change with the viewport and once the web fonts arrive, so
// stop any running slide and re-place the pill (without animating) on both. A failed font load
// rejects fonts.ready and is left to surface as an unhandled rejection.
useLayoutEffect(() => {
const bar = barRef.current!
const pill = pillRef.current!
let mounted = true
const replace = () => {
if (!mounted) return
stopSlide()
placed.current = place(bar, pill, activeItem(bar))
}
window.addEventListener('resize', replace)
// jsdom has no document.fonts; every browser the app targets does.
if (document.fonts) void document.fonts.ready.then(replace)
return () => {
mounted = false
window.removeEventListener('resize', replace)
}
}, [])
return (
<nav ref={barRef} aria-label="Primary" className={styles.bar}>
<span ref={pillRef} className={styles.pill} aria-hidden="true" />
{navItems.map((item) => (
<NavLink key={item.to} to={item.to} end={item.end} className={styles.item}>
{({ isActive }) => (
<>
<Icon icon={item.icon} size="nav" />
<span className={isActive ? styles.label : styles.hiddenLabel}>{item.label}</span>
</>
)}
</NavLink>
))}
</nav>
)
}
function items(bar: HTMLElement): HTMLElement[] {
return Array.from(bar.querySelectorAll<HTMLElement>(`.${styles.item}`))
}
// NavLink's aria-current is the one record of which item is active.
function activeItem(bar: HTMLElement): HTMLElement | null {
return bar.querySelector<HTMLElement>(`.${styles.item}[aria-current="page"]`)
}
// Where the pill and items are on screen right now, as offsets within the
// bar. Bounding boxes include a running slide: the pill's box is its animated
// left and width, and an item's box is its current layout plus whatever FLIP
// translation remains, which applies to the layout it had when last placed.
function onScreen(bar: HTMLElement, pill: HTMLElement, before: Geometry) {
const origin = bar.getBoundingClientRect().left + bar.clientLeft
const pillBox = pill.getBoundingClientRect()
return {
pill: { left: pillBox.left - origin, width: pillBox.width },
itemLefts: items(bar).map(
(item, i) => before.itemLefts[i]! + item.getBoundingClientRect().left - origin - item.offsetLeft,
),
}
}
// Puts the pill on the active item (hidden when no item is active) and
// returns the resulting geometry.
function place(bar: HTMLElement, pill: HTMLElement, active: HTMLElement | null): Geometry {
pill.hidden = !active
pill.style.left = `${active?.offsetLeft ?? 0}px`
pill.style.width = `${active?.offsetWidth ?? 0}px`
return { active, itemLefts: items(bar).map((item) => item.offsetLeft) }
}
+51
View File
@@ -0,0 +1,51 @@
.rail {
width: 96px;
padding: 24px 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
background: var(--color-surface);
}
.mark {
width: 48px;
height: 48px;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--color-accent);
color: var(--on-accent);
font-family: var(--font-heading);
font-size: 22px;
}
.item {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
min-width: 56px;
min-height: 44px;
color: var(--color-neutral-700);
font-size: 11px;
font-weight: 700;
text-decoration: none;
}
.pill {
width: 56px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-pill);
}
.active {
background: var(--color-accent);
color: var(--on-accent);
}
+27
View File
@@ -0,0 +1,27 @@
import { NavLink } from 'react-router'
import { Icon } from '../ui/Icon.tsx'
import { navItems } from './navItems.ts'
import styles from './Rail.module.css'
// Tablet nav: a narrow icon rail with the "M" mark on top.
export function Rail() {
return (
<nav aria-label="Primary" className={styles.rail}>
<div className={styles.mark} aria-hidden="true">
M
</div>
{navItems.map((item) => (
<NavLink key={item.to} to={item.to} end={item.end} className={styles.item}>
{({ isActive }) => (
<>
<span className={isActive ? `${styles.pill} ${styles.active}` : styles.pill}>
<Icon icon={item.icon} size="nav" />
</span>
{item.label}
</>
)}
</NavLink>
))}
</nav>
)
}
+78
View File
@@ -0,0 +1,78 @@
.sidebar {
width: 208px;
padding: 28px 16px;
box-sizing: border-box;
display: flex;
flex-direction: column;
background: var(--color-surface);
}
.wordmark {
margin: 0;
padding: 0 12px 22px;
font-family: var(--font-heading);
font-size: 28px;
font-weight: 400;
color: var(--color-accent);
}
.nav {
display: flex;
flex-direction: column;
gap: 6px;
}
.item {
display: flex;
align-items: center;
gap: 12px;
min-height: 44px;
padding: 12px 16px;
box-sizing: border-box;
border-radius: var(--radius-pill);
color: var(--color-neutral-800);
font-size: 15px;
font-weight: 700;
text-decoration: none;
}
.active {
background: var(--color-accent);
color: var(--on-accent);
}
.household {
margin-top: auto;
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border-radius: 22px;
background: var(--color-bg);
}
.avatar {
width: 36px;
height: 36px;
flex: none;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--color-accent-2-300);
color: var(--color-accent-2-900);
font-family: var(--font-heading);
font-size: 15px;
}
.householdText {
margin: 0;
display: flex;
flex-direction: column;
font-size: 13px;
line-height: 1.3;
}
.householdNote {
color: var(--color-neutral-700);
}
+36
View File
@@ -0,0 +1,36 @@
import { NavLink } from 'react-router'
import { Icon } from '../ui/Icon.tsx'
import { navItems } from './navItems.ts'
import styles from './Sidebar.module.css'
// Desktop nav: the MealQuest wordmark, labelled pill rows and the shared
// household card at the foot.
export function Sidebar() {
return (
<aside className={styles.sidebar}>
<p className={styles.wordmark}>MealQuest</p>
<nav aria-label="Primary" className={styles.nav}>
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) => (isActive ? `${styles.item} ${styles.active}` : styles.item)}
>
<Icon icon={item.icon} size="nav" />
{item.label}
</NavLink>
))}
</nav>
<div className={styles.household}>
<span className={styles.avatar} aria-hidden="true">
K
</span>
<p className={styles.householdText}>
<strong>Our kitchen</strong>
<span className={styles.householdNote}>Shared household</span>
</p>
</div>
</aside>
)
}
+20
View File
@@ -0,0 +1,20 @@
import type { LucideIcon } from 'lucide-react'
import { Calendar, Compass, House, SlidersHorizontal, Zap } from 'lucide-react'
export type NavItem = {
to: string
label: string
icon: LucideIcon
// Match the path exactly; otherwise nested paths (/plan/shopping,
// /settings/ai) also mark the item current.
end: boolean
}
// The primary nav, in the same order in every layout.
export const navItems: readonly NavItem[] = [
{ to: '/', label: 'Home', icon: House, end: true },
{ to: '/plan', label: 'Plan', icon: Calendar, end: false },
{ to: '/discover', label: 'Discover', icon: Compass, end: false },
{ to: '/quick', label: 'Quick', icon: Zap, end: false },
{ to: '/settings', label: 'Settings', icon: SlidersHorizontal, end: false },
]
+31
View File
@@ -0,0 +1,31 @@
import { useCallback, useState, useSyncExternalStore } from 'react'
export type Breakpoint = 'mobile' | 'tablet' | 'desktop'
// The handoff's three layouts: mobile < 768 px, tablet 768–1199 px,
// desktop ≥ 1200 px.
export const TABLET_QUERY = '(min-width: 768px)'
export const DESKTOP_QUERY = '(min-width: 1200px)'
// The layout the current viewport calls for, updated as the viewport crosses
// a breakpoint.
export function useBreakpoint(): Breakpoint {
const [lists] = useState(() => ({
tablet: window.matchMedia(TABLET_QUERY),
desktop: window.matchMedia(DESKTOP_QUERY),
}))
const subscribe = useCallback(
(onChange: () => void) => {
lists.tablet.addEventListener('change', onChange)
lists.desktop.addEventListener('change', onChange)
return () => {
lists.tablet.removeEventListener('change', onChange)
lists.desktop.removeEventListener('change', onChange)
}
},
[lists],
)
return useSyncExternalStore(subscribe, () =>
lists.desktop.matches ? 'desktop' : lists.tablet.matches ? 'tablet' : 'mobile',
)
}
+1
View File
@@ -1,5 +1,6 @@
/* App-wide base styles. Values come only from tokens.css. */
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
+75
View File
@@ -0,0 +1,75 @@
// Reads the CSS Modules' rules straight from the stylesheets Vitest injects
// (css: true). jsdom's getComputedStyle cannot answer two things these
// primitives need checked: it resolves var() colours to transparent, and it
// evaluates only "all" and "screen" media queries, so styles inside
// @media (prefers-reduced-motion: reduce) never apply.
function cssRules(): CSSRule[] {
return Array.from(document.styleSheets).flatMap((sheet) => Array.from(sheet.cssRules))
}
function collect(element: Element, rules: CSSRule[], style: CSSStyleDeclaration) {
for (const rule of rules) {
if (!(rule instanceof CSSStyleRule) || !element.matches(rule.selectorText)) continue
for (const property of Array.from(rule.style)) {
style.setProperty(property, rule.style.getPropertyValue(property))
}
}
}
// The declarations the stylesheets give the element, as written. Read the
// shorthands the CSS uses (background, animation): jsdom does not expand
// animation, nor any shorthand holding a var(). Later rules win; specificity
// is not considered.
export function declaredStyle(element: Element): CSSStyleDeclaration {
const style = document.createElement('div').style
collect(element, cssRules(), style)
return style
}
// Only the declarations @media (prefers-reduced-motion: reduce) gives the element.
export function reducedMotionStyle(element: Element): CSSStyleDeclaration {
const style = document.createElement('div').style
for (const rule of cssRules()) {
if (rule instanceof CSSMediaRule && rule.conditionText.includes('prefers-reduced-motion: reduce')) {
collect(element, Array.from(rule.cssRules), style)
}
}
return style
}
// The @keyframes the element's animation runs. CSS Modules hash keyframe
// names, so this follows the element's own animation-name.
export function animationKeyframes(element: Element): CSSKeyframesRule {
const [name] = declaredStyle(element).animation.split(' ')
const keyframes = cssRules().find(
(rule): rule is CSSKeyframesRule => rule instanceof CSSKeyframesRule && rule.name === name,
)
if (!keyframes) throw new Error(`no @keyframes named ${JSON.stringify(name)}`)
return keyframes
}
function px(value: string, what: string): number {
if (value === '') return 0
const match = /^(\d+(?:\.\d+)?)px$/.exec(value)
if (!match) throw new Error(`${what} ${JSON.stringify(value)} is not a px length`)
return Number(match[1])
}
// The border-box size the element's declared rules give it on one axis:
// width or height, plus padding and border unless box-sizing is border-box.
// jsdom does no layout (getBoundingClientRect is all zeros), so this is the
// way to catch padding that would push a fixed box past its specified size.
export function outerSize(element: Element, axis: 'width' | 'height'): number {
const style = declaredStyle(element)
const size = px(style.getPropertyValue(axis), axis)
if (style.boxSizing === 'border-box') return size
const sides = axis === 'width' ? ['left', 'right'] : ['top', 'bottom']
return sides.reduce(
(total, side) =>
total +
px(style.getPropertyValue(`padding-${side}`), `padding-${side}`) +
px(style.getPropertyValue(`border-${side}-width`), `border-${side}-width`),
size,
)
}
+49
View File
@@ -0,0 +1,49 @@
import { vi, type Mock } from 'vitest'
import type { ApiLocation, Me } from '../api/index.ts'
export const ME: Me = {
name: 'Sam',
email: 'sam@example.com',
household: { label: 'Our kitchen' },
csrfToken: 'token-1',
}
export function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
}
export function errorResponse(status: number, code: string, retryable = false): Response {
return jsonResponse(status, { error: { code, message: `${code} message`, retryable } })
}
// stubFetch replaces the global fetch with a mock answered from responses in
// order; a request beyond them fails the test. Undo with vi.unstubAllGlobals.
export function stubFetch(...responses: Response[]): Mock<typeof fetch> {
const fetchMock = vi.fn<typeof fetch>()
for (const response of responses) fetchMock.mockResolvedValueOnce(response)
fetchMock.mockImplementation((input, init) => {
throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${String(input)}`)
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// request returns the method, path and headers of the fetch mock's nth call.
export function request(fetchMock: Mock<typeof fetch>, n: number) {
const call = fetchMock.mock.calls[n]
if (call === undefined) throw new Error(`fetch was called ${fetchMock.mock.calls.length} times, not ${n + 1}`)
const [input, init] = call
return {
path: String(input),
method: init?.method,
credentials: init?.credentials,
headers: new Headers(init?.headers),
body: init?.body,
}
}
// fakeLocation stands in for window.location on https://mealquest.test,
// recording navigations.
export function fakeLocation(pathname = '/', search = ''): ApiLocation & { assign: Mock<(url: string | URL) => void> } {
return { origin: 'https://mealquest.test', pathname, search, assign: vi.fn<(url: string | URL) => void>() }
}
+26
View File
@@ -0,0 +1,26 @@
import { expect } from 'vitest'
// Minimum tap target from the handoff's accessibility rules.
export const MIN_HIT_AREA = 44
function px(value: string): number {
return value.endsWith('px') ? Number.parseFloat(value) : 0
}
// The tap target an element's stylesheet guarantees: the larger of its
// declared size and declared minimum size, per axis. jsdom does no layout, so
// this reads the CSS Modules' computed values (Vitest runs with css: true).
// A dimension the CSS leaves unsized counts as 0 and fails the check.
export function hitArea(element: Element): { width: number; height: number } {
const style = getComputedStyle(element)
return {
width: Math.max(px(style.width), px(style.minWidth)),
height: Math.max(px(style.height), px(style.minHeight)),
}
}
export function expectMinHitArea(element: Element) {
const { width, height } = hitArea(element)
expect(width, 'hit area width').toBeGreaterThanOrEqual(MIN_HIT_AREA)
expect(height, 'hit area height').toBeGreaterThanOrEqual(MIN_HIT_AREA)
}
+51
View File
@@ -0,0 +1,51 @@
import { act } from '@testing-library/react'
type Listener = (event: MediaQueryListEvent) => void
// Replaces window.matchMedia with one that answers min-width / max-width
// queries for a pretend viewport width, and prefers-reduced-motion: reduce
// with reducedMotion (jsdom has no matchMedia and no layout). Returns a
// function that resizes the pretend viewport and notifies every query whose
// answer changed.
export function mockViewport(initialWidth: number, { reducedMotion = false } = {}): (width: number) => void {
let width = initialWidth
const lists: { list: MediaQueryList; listeners: Set<Listener> }[] = []
function evaluate(query: string): boolean {
if (query === '(prefers-reduced-motion: reduce)') return reducedMotion
const min = /\(min-width:\s*(\d+)px\)/.exec(query)
const max = /\(max-width:\s*(\d+)px\)/.exec(query)
if (!min && !max) throw new Error(`mockViewport cannot evaluate ${JSON.stringify(query)}`)
return (!min || width >= Number(min[1])) && (!max || width <= Number(max[1]))
}
window.matchMedia = (query: string) => {
const listeners = new Set<Listener>()
const list = {
media: query,
get matches() {
return evaluate(query)
},
onchange: null,
addEventListener: (_type: string, listener: Listener) => listeners.add(listener),
removeEventListener: (_type: string, listener: Listener) => listeners.delete(listener),
addListener: (listener: Listener) => listeners.add(listener),
removeListener: (listener: Listener) => listeners.delete(listener),
dispatchEvent: () => true,
} as unknown as MediaQueryList
lists.push({ list, listeners })
return list
}
return (next: number) => {
const before = lists.map(({ list }) => list.matches)
width = next
act(() => {
lists.forEach(({ list, listeners }, i) => {
if (list.matches === before[i]) return
const event = { matches: list.matches, media: list.media } as MediaQueryListEvent
listeners.forEach((listener) => listener(event))
})
})
}
}
+63
View File
@@ -0,0 +1,63 @@
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-width: 44px;
min-height: 48px;
padding: 0 22px;
border: 1.5px solid transparent;
border-radius: var(--radius-pill);
font-family: var(--font-heading);
font-size: 15px;
font-weight: 400;
line-height: 1;
cursor: pointer;
transition: transform 100ms var(--ease-out-soft), background-color 100ms var(--ease-out-soft);
}
.button:active:not(:disabled) {
transform: scale(0.97);
}
.button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.primary {
background: var(--color-accent);
color: var(--on-accent);
}
.primary:hover:not(:disabled),
.primary:active:not(:disabled) {
background: var(--color-accent-800);
}
.outline {
background: transparent;
border-color: var(--color-neutral-300);
color: var(--color-text);
}
.outline:hover:not(:disabled),
.outline:active:not(:disabled) {
background: var(--color-neutral-200);
}
.dark {
background: var(--color-text);
color: var(--color-bg);
}
.dark:hover:not(:disabled),
.dark:active:not(:disabled) {
background: var(--color-neutral-800);
}
@media (prefers-reduced-motion: reduce) {
.button {
transition: none;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { createRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { Button } from './Button.tsx'
import styles from './Button.module.css'
describe('Button', () => {
it('is a button that defaults to type="button" and the primary variant', () => {
render(<Button>Save changes</Button>)
const button = screen.getByRole('button', { name: 'Save changes' })
expect(button).toHaveAttribute('type', 'button')
expect(button).toHaveClass(styles.button!, styles.primary!)
})
it.each(['primary', 'outline', 'dark'] as const)('renders the %s variant with a ≥ 44 px hit area', (variant) => {
render(<Button variant={variant}>Go</Button>)
const button = screen.getByRole('button', { name: 'Go' })
expect(button).toHaveClass(styles[variant]!)
expectMinHitArea(button)
})
it('declares the 100 ms pressed scale transition', () => {
render(<Button>Go</Button>)
expect(getComputedStyle(screen.getByRole('button')).transition).toContain('transform 100ms')
})
it('does not fire onClick when disabled', () => {
const onClick = vi.fn()
render(
<Button disabled onClick={onClick}>
Go
</Button>,
)
const button = screen.getByRole('button', { name: 'Go' })
expect(button).toBeDisabled()
fireEvent.click(button)
expect(onClick).not.toHaveBeenCalled()
})
it('forwards the ref, rest props and an explicit type', () => {
const ref = createRef<HTMLButtonElement>()
render(
<Button ref={ref} type="submit" aria-describedby="hint">
Send
</Button>,
)
const button = screen.getByRole('button', { name: 'Send' })
expect(ref.current).toBe(button)
expect(button).toHaveAttribute('type', 'submit')
expect(button).toHaveAttribute('aria-describedby', 'hint')
})
})
+16
View File
@@ -0,0 +1,16 @@
import type { ComponentPropsWithRef } from 'react'
import styles from './Button.module.css'
export type ButtonVariant = 'primary' | 'outline' | 'dark'
export type ButtonProps = ComponentPropsWithRef<'button'> & {
variant?: ButtonVariant
}
// Pill button. Primary is grape with --on-accent text; outline and dark are the
// secondary treatments from the handoff. `type` defaults to "button" so a
// Button inside a form never submits by accident.
export function Button({ variant = 'primary', type = 'button', className, ...rest }: ButtonProps) {
const classes = [styles.button, styles[variant], className].filter(Boolean).join(' ')
return <button type={type} className={classes} {...rest} />
}
+13
View File
@@ -0,0 +1,13 @@
.card {
padding: var(--space-4);
border-radius: var(--radius-lg);
color: var(--color-text);
}
.surface {
background: var(--color-surface);
}
.muted {
background: var(--color-neutral-100);
}
+18
View File
@@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { Card } from './Card.tsx'
import styles from './Card.module.css'
describe('Card', () => {
it('renders its children on the surface fill by default', () => {
render(<Card aria-label="Tonight">Tacos</Card>)
const card = screen.getByLabelText('Tonight')
expect(card).toHaveTextContent('Tacos')
expect(card).toHaveClass(styles.card!, styles.surface!)
})
it('supports the muted tone', () => {
render(<Card data-testid="card" tone="muted" />)
expect(screen.getByTestId('card')).toHaveClass(styles.muted!)
})
})
+14
View File
@@ -0,0 +1,14 @@
import type { ComponentPropsWithRef } from 'react'
import styles from './Card.module.css'
export type CardTone = 'surface' | 'muted'
export type CardProps = ComponentPropsWithRef<'div'> & {
// surface = --color-surface; muted = the secondary neutral-100 fill.
tone?: CardTone
}
export function Card({ tone = 'surface', className, ...rest }: CardProps) {
const classes = [styles.card, styles[tone], className].filter(Boolean).join(' ')
return <div className={classes} {...rest} />
}

Some files were not shown because too many files have changed in this diff Show More