Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 92ffaae1de Separate ListRow trailing control and fail fast on out-of-range Slider
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (push) Successful in 1m30s
CI / ci (pull_request) Successful in 1m32s
ListRow with onClick now renders a container whose leading slot, title
and meta form one button, with the trailing slot as a sibling outside
it, so a trailing toggle or action is never nested in the row button
and never triggers the row's action.

Slider throws when value is outside [min, max] (including NaN) instead
of rendering a fill that disagrees with the clamped native input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:28:00 -04:00
dev_agentandClaude Opus 5.5 d9a26b81ec Add core UI primitives in web/src/ui
CI / ci (push) Successful in 2m32s
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (2 medium)
CI / ci (pull_request) Successful in 1m35s
Button, IconButton, Tag, Chip, TextInput, Segmented, Toggle, Checkbox,
Slider, Card and ListRow, styled with CSS Modules over the design tokens.
Every interactive primitive keeps a hit area of at least 44 px; visually
smaller controls draw their face inside a larger hit box.

Vitest now injects CSS Modules into jsdom (css: true) so tests assert the
sizes the stylesheets declare, alongside roles, aria states and keyboard
behaviour (Segmented arrow keys, Toggle Space/Enter, Chip "Remove X").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:23:42 -04:00
dev_agent 496f2e5375 Feature 05 task 1: Design tokens and self-hosted fonts (#32)
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:16:25 -04:00
101 changed files with 1807 additions and 6922 deletions
-560
View File
@@ -1,560 +0,0 @@
// 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 fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrTimeout)
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
@@ -1,41 +0,0 @@
# 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
@@ -1 +0,0 @@
{"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"}
@@ -1 +0,0 @@
{"type": "error", "error": {"type": "authentication_error", "message": "invalid x-api-key: {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7Fy"}
@@ -1 +0,0 @@
{"type": "error", "error": {"type": "invalid_request_error", "message": "max_tokens: Field required"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G3"}
@@ -1 +0,0 @@
{"type": "error", "error": {"type": "not_found_error", "message": "model: claude-nonexistent"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G2"}
@@ -1 +0,0 @@
{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G1"}
@@ -1 +0,0 @@
{"type": "error", "error": {"type": "permission_error", "message": "Your API key does not have permission to use the specified resource."}, "request_id": "req_011CSHoEeqs5C35K2UUqR7Fz"}
@@ -1 +0,0 @@
{"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"}
@@ -1,12 +0,0 @@
{
"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
@@ -1,18 +0,0 @@
{
"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
}
}
@@ -1,22 +0,0 @@
{
"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
@@ -1,13 +0,0 @@
{
"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
@@ -1,18 +0,0 @@
{
"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"
}
-139
View File
@@ -1,139 +0,0 @@
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 }
-198
View File
@@ -1,198 +0,0 @@
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)
}
}
}
-127
View File
@@ -1,127 +0,0 @@
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
@@ -1,138 +0,0 @@
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
@@ -1,133 +0,0 @@
// 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
@@ -1,44 +0,0 @@
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
@@ -1,325 +0,0 @@
// 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
@@ -1,615 +0,0 @@
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
@@ -1,36 +0,0 @@
# 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
@@ -1,16 +0,0 @@
{
"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
@@ -1,16 +0,0 @@
{
"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
}
@@ -1 +0,0 @@
{"error":"model \"llama9\" not found, try pulling it first"}
-34
View File
@@ -1,34 +0,0 @@
{
"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
@@ -1,632 +0,0 @@
// 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 fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrTimeout)
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
@@ -1,71 +0,0 @@
# 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. | — |
@@ -1,18 +0,0 @@
{
"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
@@ -1,11 +0,0 @@
{
"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}
}
@@ -1,15 +0,0 @@
{
"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}
}
@@ -1 +0,0 @@
{"error": {"message": "model \"llama9\" not found, try pulling it first", "type": "api_error", "param": null, "code": null}}
-7
View File
@@ -1,7 +0,0 @@
{
"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"}
]
}
@@ -1,28 +0,0 @@
{
"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"
}
@@ -1,16 +0,0 @@
{
"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"
}
@@ -1,8 +0,0 @@
{
"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"
}
}
@@ -1 +0,0 @@
{"error": {"message": "The server had an error while processing your request. Authorization: Bearer {{KEY}}", "type": "server_error", "param": null, "code": null}}
-7
View File
@@ -1,7 +0,0 @@
{
"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"}
]
}
@@ -1,22 +0,0 @@
{
"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}
}
@@ -1,5 +0,0 @@
{
"id": "gen-1741570311-aB3dE5fG7hJ9kL1mN2pQ",
"error": {"code": 502, "message": "Upstream error from provider", "metadata": {"provider_name": "Lambda", "raw": "upstream returned 502"}},
"user_id": "user_2abcDEF"
}
@@ -1 +0,0 @@
{"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
@@ -1,22 +0,0 @@
{
"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"]
}
]
}
-328
View File
@@ -1,328 +0,0 @@
// 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.
func FromSettings(s llm.Settings, key string) (llm.Provider, error) {
cp, err := fromSettings(s, key)
if err != nil {
return nil, redact(err, key)
}
return 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}
}
-520
View File
@@ -1,520 +0,0 @@
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
}
var errStop = errors.New("recorded")
// recordingTransport records each request and fails it, so no request
// leaves the process.
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 nil, errStop
}
// 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, errStop) || !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, errStop) {
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)
}
}
})
}
}
-68
View File
@@ -1,68 +0,0 @@
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")
)
+4 -181
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,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 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)
}
}
@@ -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,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 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 !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created")
@@ -309,180 +309,3 @@ 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)
}
}
}
+2 -19
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", "gemini"},
{"ai_runs", "provider", "openai"},
{"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", "gemini"},
{"ai_settings", "provider", "openai"},
{"recipe_search_settings", "provider", "google"},
{"chat_settings", "last_test_status", "pending"},
{"notification_prefs", "event", "weekly_digest"},
@@ -360,23 +360,6 @@ 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
@@ -1,68 +0,0 @@
-- 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
@@ -1,64 +0,0 @@
-- 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;
+4 -2
View File
@@ -5,10 +5,12 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"build": "tsc --noEmit && vite build && npm run check:dist-fonts",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run"
"test": "npm run lint:colors && vitest run",
"lint:colors": "node scripts/lint-colors.mjs",
"check:dist-fonts": "node scripts/check-dist-fonts.mjs"
},
"dependencies": {
"@tanstack/react-query": "^5.104.1",
+32
View File
@@ -0,0 +1,32 @@
// Fails when the production build references Google Fonts: fonts are
// self-hosted, so dist must never point at fonts.googleapis.com or
// fonts.gstatic.com.
import { readFileSync, readdirSync } from 'node:fs'
import { join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
const distDir = fileURLToPath(new URL('../dist', import.meta.url))
const hosts = ['fonts.googleapis.com', 'fonts.gstatic.com']
function* files(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name)
if (entry.isDirectory()) yield* files(path)
else yield path
}
}
const violations = []
for (const path of files(distDir)) {
const content = readFileSync(path, 'latin1')
for (const host of hosts) {
if (content.includes(host)) violations.push(`${relative(distDir, path)}: ${host}`)
}
}
if (violations.length > 0) {
console.error('Production build references Google Fonts; fonts must be self-hosted:')
for (const v of violations) console.error(` ${v}`)
process.exit(1)
}
console.log('check:dist-fonts: dist makes no Google Fonts requests')
+42
View File
@@ -0,0 +1,42 @@
// Fails when a hex colour literal appears in any CSS or TypeScript file under
// web/ outside the design tokens: every colour must come from a custom
// property in tokens.css. Dependencies, build output and hidden directories
// (tool caches) are not source and are skipped.
import { readFileSync, readdirSync } from 'node:fs'
import { extname, join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
const webRoot = fileURLToPath(new URL('..', import.meta.url))
const allowed = new Set(['src/styles/tokens.css'])
const skippedDirs = new Set([join(webRoot, 'node_modules'), join(webRoot, 'dist')])
const extensions = new Set(['.css', '.ts', '.tsx'])
const hexColour = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b/g
function* sourceFiles(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name)
if (entry.isDirectory()) {
if (!skippedDirs.has(path) && !entry.name.startsWith('.')) yield* sourceFiles(path)
} else if (extensions.has(extname(entry.name))) yield path
}
}
const violations = []
for (const path of sourceFiles(webRoot)) {
const name = relative(webRoot, path).split('\\').join('/')
if (allowed.has(name)) continue
readFileSync(path, 'utf8')
.split('\n')
.forEach((line, i) => {
for (const match of line.matchAll(hexColour)) {
violations.push(`${name}:${i + 1}: ${match[0]}`)
}
})
}
if (violations.length > 0) {
console.error('Hex colour literals found; use a token from src/styles/tokens.css:')
for (const v of violations) console.error(` ${v}`)
process.exit(1)
}
console.log('lint:colors: no hex colour literals outside src/styles/tokens.css')
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Bagel Fat Project Authors (https://github.com/JAMO-TYPEFACE/BagelFat)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Figtree Project Authors (https://github.com/erikdkennedy/figtree)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+2
View File
@@ -1,6 +1,8 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import './styles/tokens.css'
import './styles/base.css'
import App from './App.tsx'
const root = document.getElementById('root')
+11
View File
@@ -0,0 +1,11 @@
/* App-wide base styles. Values come only from tokens.css. */
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
}
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
+116
View File
@@ -0,0 +1,116 @@
/* MealQuest design tokens — final palette "B4: Grape + teal blue on off-white".
Built on the Organic design system (styles.css in this bundle); these values OVERRIDE its defaults. */
/* Fonts are self-hosted from web/src/assets/fonts (OFL; licence texts alongside)
in place of the handoff's Google Fonts @import, so the app never calls Google.
Bagel Fat One: Google Fonts latin and latin-ext woff2 subsets.
Figtree: upstream static woff2 (github.com/erikdkennedy/figtree). */
@font-face {
font-family: "Bagel Fat One";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("../assets/fonts/BagelFatOne-LatinExt.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Bagel Fat One";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("../assets/fonts/BagelFatOne-Latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Figtree";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("../assets/fonts/Figtree-Regular.woff2") format("woff2");
}
@font-face {
font-family: "Figtree";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("../assets/fonts/Figtree-SemiBold.woff2") format("woff2");
}
@font-face {
font-family: "Figtree";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("../assets/fonts/Figtree-Bold.woff2") format("woff2");
}
:root {
/* Ground + ink */
--color-bg: #f9f6f1; /* oklch(0.975 0.008 85) — page / phone background */
--color-surface: #eee9de; /* oklch(0.935 0.016 85) — cards, rows, inputs */
--color-text: #201e1d;
--color-divider: color-mix(in srgb, #201e1d 16%, transparent);
--on-accent: #f9f6f1; /* text/icons on --color-accent fills (never dark text on grape) */
/* Primary accent — Grape (hue 300) */
--color-accent: #5a358c; /* oklch(0.42 0.14 300) */
--color-accent-100: #f8f1ff;
--color-accent-200: #eedfff;
--color-accent-300: #dac3ff;
--color-accent-400: #c1a1f9;
--color-accent-500: #a47ee1;
--color-accent-600: #865fc0;
--color-accent-700: #68449b; /* accent-colored small text on light grounds */
--color-accent-800: #4a2d73;
--color-accent-900: #2d1a49;
/* Second accent — Teal blue (hue 215) */
--color-accent-2: #29889c; /* oklch(0.58 0.09 215) */
--color-accent-2-100: #e0fbff;
--color-accent-2-200: #bbf3ff;
--color-accent-2-300: #88e0f6;
--color-accent-2-400: #3fc7e4;
--color-accent-2-500: #00a9ca;
--color-accent-2-600: #008aaa; /* toggles ON, "Try it" button, + buttons */
--color-accent-2-700: #006b88;
--color-accent-2-800: #004c63; /* teal text on teal-100/200 fills */
--color-accent-2-900: #002f3e;
/* Neutrals (unchanged from Organic) */
--color-neutral-100: #f9f4ed;
--color-neutral-200: #eee7db;
--color-neutral-300: #dcd3c4;
--color-neutral-400: #c0b6a5;
--color-neutral-500: #a19786;
--color-neutral-600: #82796a;
--color-neutral-700: #645c50;
--color-neutral-800: #474238;
--color-neutral-900: #2e2b25;
/* Type */
--font-heading: "Bagel Fat One", system-ui, sans-serif; /* weight 400 only */
--font-body: "Figtree", system-ui, sans-serif; /* 400 / 600 / 700 */
--font-mono: ui-monospace, Menlo, monospace;
/* Radii */
--radius-sm: 8px;
--radius-md: 16px;
--radius-lg: 28px;
--radius-card: 32px; /* mobile hero cards */
--radius-pill: 999px; /* buttons, inputs, tags, nav */
/* Spacing (Organic, density 1.1) */
--space-1: 4.4px; --space-2: 8.8px; --space-3: 13.2px; --space-4: 17.6px; --space-6: 26.4px; --space-8: 35.2px;
/* Elevation */
--shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent);
--shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent);
--shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent);
/* Motion */
--ease-spring: cubic-bezier(.34, 1.4, .64, 1); /* nav pill, snap-back, bumps */
--ease-out-soft: cubic-bezier(.2, .8, .2, 1); /* card promotion */
--ease-throw: cubic-bezier(.55, 0, .8, .4); /* fly-off right */
}
@media (prefers-reduced-motion: reduce) {
:root { --motion-scale: 0; }
}
+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)
}
+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} />
}
+63
View File
@@ -0,0 +1,63 @@
.root {
display: inline-flex;
align-items: center;
gap: var(--space-3);
min-width: 44px;
min-height: 44px;
cursor: pointer;
}
.input {
appearance: none;
display: grid;
place-content: center;
flex: none;
width: 22px;
height: 22px;
margin: 0;
border: 2px solid var(--color-neutral-400);
border-radius: var(--radius-sm);
background: transparent;
cursor: pointer;
}
/* The check mark. */
.input::before {
content: "";
width: 12px;
height: 12px;
background: var(--on-accent);
clip-path: polygon(14% 44%, 0 59%, 40% 100%, 100% 21%, 85% 7%, 39% 69%);
opacity: 0;
transform: scale(0.8);
transition: transform 160ms var(--ease-spring), opacity 160ms var(--ease-out-soft);
}
.input:checked {
border-color: var(--color-accent);
background: var(--color-accent);
}
.input:checked::before {
opacity: 1;
transform: scale(1);
}
.input:disabled,
.input:disabled + .label {
opacity: 0.45;
cursor: not-allowed;
}
.label {
font-family: var(--font-body);
font-size: 15px;
font-weight: 600;
color: var(--color-text);
}
@media (prefers-reduced-motion: reduce) {
.input::before {
transition: none;
}
}
+36
View File
@@ -0,0 +1,36 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { Checkbox } from './Checkbox.tsx'
describe('Checkbox', () => {
it('is a native checkbox named by its label', () => {
render(<Checkbox label="Chicken thighs" />)
const box = screen.getByRole('checkbox', { name: 'Chicken thighs' })
expect(box.tagName).toBe('INPUT')
expect(box).toHaveAttribute('type', 'checkbox')
expect(box).not.toBeChecked()
})
it('toggles when the input or its label text is clicked', () => {
render(<Checkbox label="Limes" />)
const box = screen.getByRole('checkbox', { name: 'Limes' })
fireEvent.click(box)
expect(box).toBeChecked()
fireEvent.click(screen.getByText('Limes'))
expect(box).not.toBeChecked()
})
it('reflects a controlled checked state and disabled', () => {
render(<Checkbox label="Rice" checked disabled readOnly />)
const box = screen.getByRole('checkbox', { name: 'Rice' })
expect(box).toBeChecked()
expect(box).toBeDisabled()
})
it('has a ≥ 44 px hit area (the wrapping label)', () => {
render(<Checkbox label="Rice" />)
const hit = screen.getByRole('checkbox', { name: 'Rice' }).closest('label')!
expectMinHitArea(hit)
})
})
+17
View File
@@ -0,0 +1,17 @@
import type { ComponentPropsWithRef, ReactNode } from 'react'
import styles from './Checkbox.module.css'
export type CheckboxProps = Omit<ComponentPropsWithRef<'input'>, 'type'> & {
label: ReactNode
}
// Native checkbox, styled. The wrapping label is the hit area (at least
// 44×44), so tapping the text toggles it too.
export function Checkbox({ label, className, ...rest }: CheckboxProps) {
return (
<label className={[styles.root, className].filter(Boolean).join(' ')}>
<input type="checkbox" className={styles.input} {...rest} />
<span className={styles.label}>{label}</span>
</label>
)
}
+35
View File
@@ -0,0 +1,35 @@
.chip {
display: inline-flex;
align-items: center;
min-height: 36px;
padding: 0 14px;
border-radius: var(--radius-pill);
background: var(--color-accent-2-200);
color: var(--color-accent-2-900);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
white-space: nowrap;
}
.removable {
padding-right: 0;
}
/* 44 px hit box around a small cross; negative margins keep the chip 36 px
tall and the cross close to the label. */
.remove {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 44px;
height: 44px;
margin: -4px 0 -4px -14px;
padding: 0;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: inherit;
cursor: pointer;
}
+26
View File
@@ -0,0 +1,26 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { Chip } from './Chip.tsx'
describe('Chip', () => {
it('has no remove button unless onRemove is given', () => {
render(<Chip label="Beef tacos" />)
expect(screen.getByText('Beef tacos')).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('exposes an accessible "Remove {label}" button that calls onRemove', () => {
const onRemove = vi.fn()
render(<Chip label="Beef tacos" onRemove={onRemove} />)
const remove = screen.getByRole('button', { name: 'Remove Beef tacos' })
expect(remove).toHaveAttribute('type', 'button')
fireEvent.click(remove)
expect(onRemove).toHaveBeenCalledOnce()
})
it('gives the remove button a ≥ 44 px hit area', () => {
render(<Chip label="Pizza night" onRemove={() => {}} />)
expectMinHitArea(screen.getByRole('button', { name: 'Remove Pizza night' }))
})
})
+24
View File
@@ -0,0 +1,24 @@
import type { ComponentPropsWithRef } from 'react'
import styles from './Chip.module.css'
export type ChipProps = Omit<ComponentPropsWithRef<'span'>, 'children'> & {
label: string
// When set, the chip shows a "Remove {label}" button that calls it.
onRemove?: () => void
}
export function Chip({ label, onRemove, className, ...rest }: ChipProps) {
const classes = [styles.chip, onRemove && styles.removable, className].filter(Boolean).join(' ')
return (
<span className={classes} {...rest}>
{label}
{onRemove && (
<button type="button" className={styles.remove} aria-label={`Remove ${label}`} onClick={onRemove}>
<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">
<path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" strokeWidth="2.75" strokeLinecap="round" />
</svg>
</button>
)}
</span>
)
}
+74
View File
@@ -0,0 +1,74 @@
/* The button is the hit box; .face is the visible circle. A 40 px face sits in
a 44 px hit box whose negative margin keeps the 40 px layout footprint. */
.button {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
padding: 0;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: inherit;
cursor: pointer;
}
.button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.size40 {
width: 44px;
height: 44px;
margin: -2px;
}
.size62 {
width: 62px;
height: 62px;
}
.face {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-pill);
transition: transform 100ms var(--ease-out-soft);
}
.size40 .face {
width: 40px;
height: 40px;
}
.size62 .face {
width: 62px;
height: 62px;
box-shadow: var(--shadow-md);
}
.button:active:not(:disabled) .face {
transform: scale(0.97);
}
.surface {
background: var(--color-surface);
color: var(--color-text);
}
.accent {
background: var(--color-accent);
color: var(--on-accent);
}
.teal {
background: var(--color-accent-2-600);
color: var(--on-accent);
}
@media (prefers-reduced-motion: reduce) {
.face {
transition: none;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
import { IconButton } from './IconButton.tsx'
describe('IconButton', () => {
it('is named by its label, not its icon', () => {
const onClick = vi.fn()
render(
<IconButton label="Notifications" onClick={onClick}>
<svg />
</IconButton>,
)
const button = screen.getByRole('button', { name: 'Notifications' })
expect(button).toHaveAttribute('type', 'button')
fireEvent.click(button)
expect(onClick).toHaveBeenCalledOnce()
})
it('keeps a 44 px hit area around the 40 px circle', () => {
render(<IconButton label="Back" size={40} />)
const button = screen.getByRole('button', { name: 'Back' })
expectMinHitArea(button)
const face = button.firstElementChild!
expect(hitArea(face)).toEqual({ width: 40, height: 40 })
})
it('is 62 px at the large size', () => {
render(<IconButton label="Try it" size={62} tone="teal" />)
const button = screen.getByRole('button', { name: 'Try it' })
expectMinHitArea(button)
expect(hitArea(button)).toEqual({ width: 62, height: 62 })
})
})
+33
View File
@@ -0,0 +1,33 @@
import type { ComponentPropsWithRef } from 'react'
import styles from './IconButton.module.css'
export type IconButtonSize = 40 | 62
export type IconButtonTone = 'surface' | 'accent' | 'teal'
export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'aria-label'> & {
// Accessible name; an icon alone never names a button.
label: string
size?: IconButtonSize
tone?: IconButtonTone
}
// Round icon-only button. The 40 px size keeps a 44 px hit box (see the CSS);
// the 62 px size is the round action button with --shadow-md.
export function IconButton({
label,
size = 40,
tone = 'surface',
type = 'button',
className,
children,
...rest
}: IconButtonProps) {
const classes = [styles.button, styles[`size${size}`], className].filter(Boolean).join(' ')
return (
<button type={type} aria-label={label} className={classes} {...rest}>
<span className={[styles.face, styles[tone]].join(' ')} aria-hidden="true">
{children}
</span>
</button>
)
}
+69
View File
@@ -0,0 +1,69 @@
.row {
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
min-height: 44px;
padding: 14px 16px;
color: var(--color-text);
text-align: left;
}
.interactive {
padding: 0;
border-radius: var(--radius-md);
}
.interactive:hover {
background: var(--color-neutral-200);
}
.main {
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--space-3);
flex: 1 1 auto;
min-width: 44px;
min-height: 44px;
padding: 14px 16px;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.interactive > .trailing {
padding-right: 16px;
}
.leading,
.trailing {
display: inline-flex;
align-items: center;
flex: none;
}
.body {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
}
.title {
font-family: var(--font-body);
font-size: 15px;
font-weight: 700;
}
.meta {
font-family: var(--font-body);
font-size: 12px;
font-weight: 600;
color: var(--color-neutral-700);
}
+57
View File
@@ -0,0 +1,57 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { ListRow } from './ListRow.tsx'
import { Toggle } from './Toggle.tsx'
describe('ListRow', () => {
it('renders leading, title, meta and trailing slots without being interactive', () => {
render(
<ListRow
leading={<span>icon</span>}
title="Notifications"
meta="Push and Google Chat"
trailing={<span>chevron</span>}
/>,
)
for (const text of ['icon', 'Notifications', 'Push and Google Chat', 'chevron']) {
expect(screen.getByText(text)).toBeInTheDocument()
}
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('is a single button with a ≥ 44 px hit area when it has onClick', () => {
const onClick = vi.fn()
render(<ListRow title="10 new side quests to swipe" onClick={onClick} />)
const row = screen.getByRole('button', { name: '10 new side quests to swipe' })
expect(row).toHaveAttribute('type', 'button')
expectMinHitArea(row)
fireEvent.click(row)
expect(onClick).toHaveBeenCalledOnce()
})
it('keeps a trailing toggle outside the row button so each control fires only its own action', () => {
const onClick = vi.fn()
const onCheckedChange = vi.fn()
render(
<ListRow
title="Dinner reminders"
meta="Push at 4 pm"
trailing={<Toggle aria-label="Dinner reminders" checked={false} onCheckedChange={onCheckedChange} />}
onClick={onClick}
/>,
)
const row = screen.getByRole('button', { name: /^Dinner reminders/ })
const toggle = screen.getByRole('switch', { name: 'Dinner reminders' })
expect(row).not.toContainElement(toggle)
expect(row.querySelector('button')).toBeNull()
fireEvent.click(toggle)
expect(onCheckedChange).toHaveBeenCalledExactlyOnceWith(true)
expect(onClick).not.toHaveBeenCalled()
fireEvent.click(row)
expect(onClick).toHaveBeenCalledOnce()
expect(onCheckedChange).toHaveBeenCalledOnce()
})
})
+45
View File
@@ -0,0 +1,45 @@
import type { ReactNode } from 'react'
import styles from './ListRow.module.css'
export type ListRowProps = {
title: ReactNode
meta?: ReactNode
// Leading slot: icon, photo or avatar.
leading?: ReactNode
// Trailing slot: chevron, toggle, count or action.
trailing?: ReactNode
// When set, the leading slot, title and meta form one button. The trailing
// slot stays a sibling outside it, so a trailing toggle or action is never
// nested in the row's button and never triggers the row's action.
onClick?: () => void
className?: string
}
export function ListRow({ title, meta, leading, trailing, onClick, className }: ListRowProps) {
const main = (
<>
{leading && <span className={styles.leading}>{leading}</span>}
<span className={styles.body}>
<span className={styles.title}>{title}</span>
{meta && <span className={styles.meta}>{meta}</span>}
</span>
</>
)
const trailingSlot = trailing && <span className={styles.trailing}>{trailing}</span>
if (onClick) {
return (
<div className={[styles.row, styles.interactive, className].filter(Boolean).join(' ')}>
<button type="button" className={styles.main} onClick={onClick}>
{main}
</button>
{trailingSlot}
</div>
)
}
return (
<div className={[styles.row, className].filter(Boolean).join(' ')}>
{main}
{trailingSlot}
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
.track {
display: flex;
gap: 4px;
padding: 4px;
border-radius: var(--radius-pill);
background: var(--color-surface);
}
.option {
flex: 1 1 0;
min-width: 44px;
min-height: 44px;
padding: 0 var(--space-4);
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-neutral-700);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: background-color 150ms var(--ease-out-soft), box-shadow 150ms var(--ease-out-soft);
}
.selected {
background: var(--color-bg);
color: var(--color-text);
box-shadow: var(--shadow-sm);
}
@media (prefers-reduced-motion: reduce) {
.option {
transition: none;
}
}
+91
View File
@@ -0,0 +1,91 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { describe, expect, it } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { Segmented } from './Segmented.tsx'
import styles from './Segmented.module.css'
const options = [
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
{ value: 'year', label: 'Year' },
] as const
type Period = (typeof options)[number]['value']
function Harness({ initial = 'week' }: { initial?: Period }) {
const [value, setValue] = useState<Period>(initial)
return <Segmented label="Archive period" options={options} value={value} onValueChange={setValue} />
}
function radio(name: string) {
return screen.getByRole('radio', { name })
}
describe('Segmented', () => {
it('is a named radiogroup of radios with one checked', () => {
render(<Harness />)
expect(screen.getByRole('radiogroup', { name: 'Archive period' })).toBeInTheDocument()
expect(screen.getAllByRole('radio')).toHaveLength(3)
expect(radio('Week')).toHaveAttribute('aria-checked', 'true')
expect(radio('Month')).toHaveAttribute('aria-checked', 'false')
expect(radio('Week')).toHaveClass(styles.selected!)
})
it('has a single tab stop on the selected option', () => {
render(<Harness initial="month" />)
expect(radio('Month')).toHaveAttribute('tabindex', '0')
expect(radio('Week')).toHaveAttribute('tabindex', '-1')
expect(radio('Year')).toHaveAttribute('tabindex', '-1')
})
it('moves selection and focus with the arrow keys, wrapping at the ends', () => {
render(<Harness />)
radio('Week').focus()
fireEvent.keyDown(radio('Week'), { key: 'ArrowRight' })
expect(radio('Month')).toHaveAttribute('aria-checked', 'true')
expect(radio('Month')).toHaveFocus()
fireEvent.keyDown(radio('Month'), { key: 'ArrowDown' })
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
fireEvent.keyDown(radio('Year'), { key: 'ArrowRight' })
expect(radio('Week')).toHaveAttribute('aria-checked', 'true')
expect(radio('Week')).toHaveFocus()
fireEvent.keyDown(radio('Week'), { key: 'ArrowLeft' })
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
fireEvent.keyDown(radio('Year'), { key: 'ArrowUp' })
expect(radio('Month')).toHaveAttribute('aria-checked', 'true')
expect(radio('Month')).toHaveFocus()
expect(radio('Year')).toHaveAttribute('aria-checked', 'false')
})
it('jumps to the first and last option with Home and End', () => {
render(<Harness initial="month" />)
fireEvent.keyDown(radio('Month'), { key: 'End' })
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
fireEvent.keyDown(radio('Year'), { key: 'Home' })
expect(radio('Week')).toHaveAttribute('aria-checked', 'true')
})
it('selects an option on click', () => {
render(<Harness />)
fireEvent.click(radio('Year'))
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
expect(radio('Week')).toHaveAttribute('aria-checked', 'false')
})
it('gives every option a ≥ 44 px hit area', () => {
render(<Harness />)
for (const option of screen.getAllByRole('radio')) expectMinHitArea(option)
})
it('fails fast when the value is not one of the options', () => {
expect(() =>
render(<Segmented label="Archive period" options={options} value={'decade' as Period} onValueChange={() => {}} />),
).toThrow('value "decade" is not one of its options')
})
})
+76
View File
@@ -0,0 +1,76 @@
import { useRef, type KeyboardEvent } from 'react'
import styles from './Segmented.module.css'
export type SegmentedOption<T extends string> = {
value: T
label: string
}
export type SegmentedProps<T extends string> = {
// Accessible name of the radiogroup.
label: string
options: readonly SegmentedOption<T>[]
value: T
onValueChange: (value: T) => void
className?: string
}
const step: Record<string, number> = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }
// Segmented control with radiogroup semantics: one tab stop (the selected
// option); arrow keys move the selection and focus, wrapping at the ends;
// Home and End jump to the first and last option.
export function Segmented<T extends string>({ label, options, value, onValueChange, className }: SegmentedProps<T>) {
const refs = useRef<(HTMLButtonElement | null)[]>([])
const selected = options.findIndex((option) => option.value === value)
if (selected === -1) {
throw new Error(`Segmented "${label}": value "${value}" is not one of its options`)
}
function select(index: number) {
const option = options[index]
if (!option) throw new Error(`Segmented "${label}": no option at index ${index}`)
onValueChange(option.value)
refs.current[index]?.focus()
}
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const delta = step[event.key]
let next: number
if (delta !== undefined) next = (selected + delta + options.length) % options.length
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = options.length - 1
else return
event.preventDefault()
select(next)
}
return (
<div
role="radiogroup"
aria-label={label}
className={[styles.track, className].filter(Boolean).join(' ')}
onKeyDown={onKeyDown}
>
{options.map((option, index) => {
const checked = index === selected
return (
<button
key={option.value}
ref={(el) => {
refs.current[index] = el
}}
type="button"
role="radio"
aria-checked={checked}
tabIndex={checked ? 0 : -1}
className={checked ? `${styles.option} ${styles.selected}` : styles.option}
onClick={() => select(index)}
>
{option.label}
</button>
)
})}
</div>
)
}
+61
View File
@@ -0,0 +1,61 @@
/* The input is the 44 px tall hit area; the track and knob are drawn by the
vendor pseudo-elements. --slider-fill is set inline from the value. */
.slider {
appearance: none;
display: block;
width: 100%;
min-width: 44px;
height: 44px;
margin: 0;
background: transparent;
cursor: pointer;
}
.slider:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.slider::-webkit-slider-runnable-track {
height: 8px;
border-radius: var(--radius-pill);
background: linear-gradient(
to right,
var(--color-accent) var(--slider-fill),
var(--color-neutral-300) var(--slider-fill)
);
}
.slider::-webkit-slider-thumb {
appearance: none;
box-sizing: border-box;
width: 24px;
height: 24px;
margin-top: -8px;
border: 3px solid var(--color-accent);
border-radius: var(--radius-pill);
background: var(--color-bg);
box-shadow: var(--shadow-sm);
}
.slider::-moz-range-track {
height: 8px;
border-radius: var(--radius-pill);
background: var(--color-neutral-300);
}
.slider::-moz-range-progress {
height: 8px;
border-radius: var(--radius-pill);
background: var(--color-accent);
}
.slider::-moz-range-thumb {
box-sizing: border-box;
width: 24px;
height: 24px;
border: 3px solid var(--color-accent);
border-radius: var(--radius-pill);
background: var(--color-bg);
box-shadow: var(--shadow-sm);
}
+69
View File
@@ -0,0 +1,69 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { describe, expect, it } from 'vitest'
import { expectMinHitArea } from '../test/hitArea.ts'
import { Slider } from './Slider.tsx'
function Harness() {
const [value, setValue] = useState(0.7)
return (
<Slider
aria-label="Creativity (temperature)"
min={0}
max={1}
step={0.1}
value={value}
onChange={(event) => setValue(Number(event.target.value))}
/>
)
}
describe('Slider', () => {
it('is a native slider exposing its range and value', () => {
render(<Harness />)
const slider = screen.getByRole('slider', { name: 'Creativity (temperature)' })
expect(slider).toHaveAttribute('type', 'range')
expect(slider).toHaveAttribute('min', '0')
expect(slider).toHaveAttribute('max', '1')
expect(slider).toHaveValue('0.7')
})
it('updates the grape fill with the value', () => {
render(<Harness />)
const slider = screen.getByRole('slider')
expect(slider.style.getPropertyValue('--slider-fill')).toBe('70%')
fireEvent.change(slider, { target: { value: '0.2' } })
expect(slider).toHaveValue('0.2')
expect(slider.style.getPropertyValue('--slider-fill')).toBe('20%')
})
it('has a ≥ 44 px hit area', () => {
render(<Harness />)
expectMinHitArea(screen.getByRole('slider'))
})
it('fails fast on an empty range', () => {
expect(() => render(<Slider aria-label="Bad" min={1} max={1} value={1} readOnly />)).toThrow(
'max (1) must be greater than min (1)',
)
})
it('accepts values at both ends of the range', () => {
render(
<>
<Slider aria-label="At min" min={0} max={1} value={0} readOnly />
<Slider aria-label="At max" min={0} max={1} value={1} readOnly />
</>,
)
expect(screen.getByRole('slider', { name: 'At min' }).style.getPropertyValue('--slider-fill')).toBe('0%')
expect(screen.getByRole('slider', { name: 'At max' }).style.getPropertyValue('--slider-fill')).toBe('100%')
})
it.each([
[2, 'value (2) must be within [0, 1]'],
[-0.1, 'value (-0.1) must be within [0, 1]'],
[Number.NaN, 'value (NaN) must be within [0, 1]'],
])('fails fast when the value %s is outside the range', (value, message) => {
expect(() => render(<Slider aria-label="Bad" min={0} max={1} value={value} readOnly />)).toThrow(message)
})
})
+30
View File
@@ -0,0 +1,30 @@
import type { CSSProperties, ComponentPropsWithRef } from 'react'
import styles from './Slider.module.css'
export type SliderProps = Omit<ComponentPropsWithRef<'input'>, 'type' | 'value' | 'min' | 'max'> & {
value: number
min: number
max: number
}
// Native range input, styled: 8 px track, grape fill up to the value, 24 px
// knob with a 3 px grape ring. Controlled, because the fill follows `value`.
// Name it with aria-label or aria-labelledby.
export function Slider({ value, min, max, className, style, ...rest }: SliderProps) {
if (!(max > min)) throw new Error(`Slider: max (${max}) must be greater than min (${min})`)
if (!(value >= min && value <= max)) {
throw new Error(`Slider: value (${value}) must be within [${min}, ${max}]`)
}
const fill = `${((value - min) / (max - min)) * 100}%`
return (
<input
type="range"
value={value}
min={min}
max={max}
className={[styles.slider, className].filter(Boolean).join(' ')}
style={{ ...style, '--slider-fill': fill } as CSSProperties}
{...rest}
/>
)
}
+21
View File
@@ -0,0 +1,21 @@
.tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: var(--radius-pill);
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
.grape {
background: var(--color-accent-100);
color: var(--color-accent-800);
}
.teal {
background: var(--color-accent-2-200);
color: var(--color-accent-2-800);
}
+18
View File
@@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { Tag } from './Tag.tsx'
import styles from './Tag.module.css'
describe('Tag', () => {
it('renders a static, non-interactive label', () => {
render(<Tag>Draft</Tag>)
const tag = screen.getByText('Draft')
expect(tag).toHaveClass(styles.grape!)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('supports the teal tone', () => {
render(<Tag tone="teal">Synced</Tag>)
expect(screen.getByText('Synced')).toHaveClass(styles.teal!)
})
})
+14
View File
@@ -0,0 +1,14 @@
import type { ComponentPropsWithRef } from 'react'
import styles from './Tag.module.css'
export type TagTone = 'grape' | 'teal'
export type TagProps = ComponentPropsWithRef<'span'> & {
tone?: TagTone
}
// Static label such as "Draft", "Side quest" or "Synced". Not interactive.
export function Tag({ tone = 'grape', className, ...rest }: TagProps) {
const classes = [styles.tag, styles[tone], className].filter(Boolean).join(' ')
return <span className={classes} {...rest} />
}
+46
View File
@@ -0,0 +1,46 @@
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.label {
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
.hiddenLabel {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.input {
box-sizing: border-box;
min-width: 44px;
height: 48px;
padding: 0 20px;
border: 1px solid var(--color-neutral-300);
border-radius: var(--radius-pill);
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-body);
font-size: 16px;
}
.input::placeholder {
color: var(--color-neutral-700);
}
.input:disabled {
opacity: 0.45;
cursor: not-allowed;
}
+34
View File
@@ -0,0 +1,34 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { createRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
import { TextInput } from './TextInput.tsx'
describe('TextInput', () => {
it('is a textbox named by its label', () => {
const onChange = vi.fn()
render(<TextInput label="Dinner name" placeholder="e.g. Chicken enchiladas" onChange={onChange} />)
const input = screen.getByRole('textbox', { name: 'Dinner name' })
fireEvent.change(input, { target: { value: 'Chili' } })
expect(onChange).toHaveBeenCalledOnce()
expect(input).toHaveValue('Chili')
})
it('keeps the accessible name when the label is visually hidden', () => {
render(<TextInput label="Add a regular" labelHidden />)
expect(screen.getByRole('textbox', { name: 'Add a regular' })).toBeInTheDocument()
})
it('is a 48 px pill with a ≥ 44 px hit area', () => {
render(<TextInput label="Dinner name" />)
const input = screen.getByRole('textbox', { name: 'Dinner name' })
expectMinHitArea(input)
expect(hitArea(input).height).toBe(48)
})
it('forwards the ref', () => {
const ref = createRef<HTMLInputElement>()
render(<TextInput label="Dinner name" ref={ref} />)
expect(ref.current).toBe(screen.getByRole('textbox', { name: 'Dinner name' }))
})
})
+21
View File
@@ -0,0 +1,21 @@
import { useId, type ComponentPropsWithRef } from 'react'
import styles from './TextInput.module.css'
export type TextInputProps = Omit<ComponentPropsWithRef<'input'>, 'id'> & {
label: string
// Hide the label visually; it stays the input's accessible name.
labelHidden?: boolean
}
// 48 px pill input on the surface fill, always associated with its label.
export function TextInput({ label, labelHidden = false, type = 'text', className, ...rest }: TextInputProps) {
const id = useId()
return (
<div className={styles.field}>
<label htmlFor={id} className={labelHidden ? styles.hiddenLabel : styles.label}>
{label}
</label>
<input id={id} type={type} className={[styles.input, className].filter(Boolean).join(' ')} {...rest} />
</div>
)
}
+57
View File
@@ -0,0 +1,57 @@
/* The button is a 44 px hit box around the 40×24 track; negative margins keep
the 40×24 layout footprint. */
.toggle {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 44px;
height: 44px;
margin: -10px -2px;
padding: 0;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
cursor: pointer;
}
.toggle:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.track {
position: relative;
width: 40px;
height: 24px;
border-radius: var(--radius-pill);
background: var(--color-neutral-300);
transition: background-color 150ms var(--ease-out-soft);
}
.knob {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: var(--radius-pill);
background: var(--color-bg);
box-shadow: var(--shadow-sm);
transition: transform 180ms var(--ease-spring);
}
.toggle[aria-checked='true'] .track {
background: var(--color-accent-2-600);
}
.toggle[aria-checked='true'] .knob {
transform: translateX(16px);
}
@media (prefers-reduced-motion: reduce) {
.track,
.knob {
transition: none;
}
}
+63
View File
@@ -0,0 +1,63 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
import { Toggle } from './Toggle.tsx'
function Harness() {
const [checked, setChecked] = useState(false)
return <Toggle aria-label="Push: New weekly plan ready" checked={checked} onCheckedChange={setChecked} />
}
function toggle() {
return screen.getByRole('switch', { name: 'Push: New weekly plan ready' })
}
describe('Toggle', () => {
it('is a switch reflecting its state in aria-checked', () => {
render(<Harness />)
expect(toggle()).toHaveAttribute('aria-checked', 'false')
fireEvent.click(toggle())
expect(toggle()).toHaveAttribute('aria-checked', 'true')
fireEvent.click(toggle())
expect(toggle()).toHaveAttribute('aria-checked', 'false')
})
it.each([' ', 'Enter'])('toggles with the %j key', (key) => {
render(<Harness />)
fireEvent.keyDown(toggle(), { key })
expect(toggle()).toHaveAttribute('aria-checked', 'true')
fireEvent.keyDown(toggle(), { key })
expect(toggle()).toHaveAttribute('aria-checked', 'false')
})
it('suppresses native activation for Space and Enter so a key press toggles once', () => {
render(<Harness />)
expect(fireEvent.keyDown(toggle(), { key: ' ' })).toBe(false)
expect(fireEvent.keyUp(toggle(), { key: ' ' })).toBe(false)
expect(fireEvent.keyDown(toggle(), { key: 'Enter' })).toBe(false)
expect(fireEvent.keyDown(toggle(), { key: 'a' })).toBe(true)
})
it('ignores other keys', () => {
const onCheckedChange = vi.fn()
render(<Toggle aria-label="Chat" checked={false} onCheckedChange={onCheckedChange} />)
fireEvent.keyDown(screen.getByRole('switch', { name: 'Chat' }), { key: 'a' })
expect(onCheckedChange).not.toHaveBeenCalled()
})
it('is disabled when asked', () => {
const onCheckedChange = vi.fn()
render(<Toggle aria-label="Chat" checked={false} disabled onCheckedChange={onCheckedChange} />)
const sw = screen.getByRole('switch', { name: 'Chat' })
expect(sw).toBeDisabled()
fireEvent.click(sw)
expect(onCheckedChange).not.toHaveBeenCalled()
})
it('draws a 40×24 track inside a ≥ 44 px hit area', () => {
render(<Harness />)
expectMinHitArea(toggle())
expect(hitArea(toggle().firstElementChild!)).toEqual({ width: 40, height: 24 })
})
})
+42
View File
@@ -0,0 +1,42 @@
import type { ComponentPropsWithRef, KeyboardEvent } from 'react'
import styles from './Toggle.module.css'
export type ToggleProps = Omit<
ComponentPropsWithRef<'button'>,
'type' | 'role' | 'aria-checked' | 'onChange' | 'onClick' | 'onKeyDown' | 'onKeyUp' | 'children'
> & {
checked: boolean
onCheckedChange: (checked: boolean) => void
}
// 40×24 pill switch (role="switch"). Name it with aria-label or
// aria-labelledby. Space and Enter are handled here and their native button
// activation is suppressed, so each key press toggles exactly once.
export function Toggle({ checked, onCheckedChange, className, ...rest }: ToggleProps) {
function onKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
if (event.key !== ' ' && event.key !== 'Enter') return
event.preventDefault()
onCheckedChange(!checked)
}
function onKeyUp(event: KeyboardEvent<HTMLButtonElement>) {
if (event.key === ' ') event.preventDefault()
}
return (
<button
type="button"
role="switch"
aria-checked={checked}
className={[styles.toggle, className].filter(Boolean).join(' ')}
onClick={() => onCheckedChange(!checked)}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
{...rest}
>
<span className={styles.track} aria-hidden="true">
<span className={styles.knob} />
</span>
</button>
)
}
+11
View File
@@ -0,0 +1,11 @@
export { Button, type ButtonProps, type ButtonVariant } from './Button.tsx'
export { Card, type CardProps, type CardTone } from './Card.tsx'
export { Checkbox, type CheckboxProps } from './Checkbox.tsx'
export { Chip, type ChipProps } from './Chip.tsx'
export { IconButton, type IconButtonProps, type IconButtonSize, type IconButtonTone } from './IconButton.tsx'
export { ListRow, type ListRowProps } from './ListRow.tsx'
export { Segmented, type SegmentedOption, type SegmentedProps } from './Segmented.tsx'
export { Slider, type SliderProps } from './Slider.tsx'
export { Tag, type TagProps, type TagTone } from './Tag.tsx'
export { TextInput, type TextInputProps } from './TextInput.tsx'
export { Toggle, type ToggleProps } from './Toggle.tsx'

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