From fcbdebd6e8bb8ad38f09601fb172452b8ea96a16 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 12:45:05 -0400 Subject: [PATCH] Feature 11: Settings home and Admin AI assistant (#86) Co-authored-by: dev_agent --- README.md | 8 +- cmd/server/main.go | 10 +- deploy/.env.example | 8 +- deploy/RUNBOOK.md | 24 +- docs/ARCHITECTURE.md | 26 + internal/ai/aisettings/aisettings.go | 478 +++++++++++ internal/ai/aisettings/aisettings_test.go | 463 +++++++++++ internal/ai/llm/presets/presets.go | 40 +- internal/ai/llm/presets/presets_test.go | 33 + internal/config/config.go | 11 + internal/config/config_test.go | 5 + internal/household/defaults.go | 3 +- internal/recipes/searchsettings.go | 57 +- internal/recipes/searchsettings_test.go | 77 +- internal/server/aisettings.go | 145 ++++ internal/server/aisettings_test.go | 261 ++++++ internal/server/login_test.go | 2 +- internal/server/media_test.go | 2 +- internal/server/origin_test.go | 2 +- internal/server/recipesearch_test.go | 4 +- internal/server/server.go | 16 +- internal/server/server_test.go | 55 +- internal/server/settings.go | 18 +- internal/server/settings_test.go | 2 +- web/src/App.test.tsx | 56 +- web/src/App.tsx | 19 +- web/src/main.tsx | 15 +- web/src/settings/AISettingsPage.module.css | 313 +++++++ web/src/settings/AISettingsPage.test.tsx | 782 ++++++++++++++++++ web/src/settings/AISettingsPage.tsx | 546 ++++++++++++ .../settings/HouseholdSettingsPage.module.css | 237 ++++++ .../settings/HouseholdSettingsPage.test.tsx | 394 +++++++++ web/src/settings/HouseholdSettingsPage.tsx | 412 +++++++++ web/src/settings/LeaveGuard.module.css | 38 + web/src/settings/LeaveGuard.tsx | 80 ++ web/src/settings/RecipeSearchCard.tsx | 275 ++++++ web/src/settings/SettingsPage.module.css | 153 ++++ web/src/settings/SettingsPage.test.tsx | 267 ++++++ web/src/settings/SettingsPage.tsx | 167 ++++ web/src/settings/SettingsTabs.module.css | 118 +++ web/src/settings/SettingsTabs.test.tsx | 193 +++++ web/src/settings/SettingsTabs.tsx | 138 ++++ web/src/settings/api.ts | 546 ++++++++++++ web/src/settings/timezones.ts | 46 ++ web/src/shell/AppShell.test.tsx | 16 +- web/src/shell/BottomBar.test.tsx | 14 +- web/src/test/ApiProviders.tsx | 80 ++ web/src/test/cssRules.ts | 6 +- web/src/test/dataRouter.tsx | 10 + web/src/test/fetchStub.ts | 14 + web/src/ui/Select.module.css | 50 ++ web/src/ui/Select.test.tsx | 38 + web/src/ui/Select.tsx | 27 + web/src/ui/index.ts | 1 + 54 files changed, 6712 insertions(+), 89 deletions(-) create mode 100644 internal/ai/aisettings/aisettings.go create mode 100644 internal/ai/aisettings/aisettings_test.go create mode 100644 internal/server/aisettings.go create mode 100644 internal/server/aisettings_test.go create mode 100644 web/src/settings/AISettingsPage.module.css create mode 100644 web/src/settings/AISettingsPage.test.tsx create mode 100644 web/src/settings/AISettingsPage.tsx create mode 100644 web/src/settings/HouseholdSettingsPage.module.css create mode 100644 web/src/settings/HouseholdSettingsPage.test.tsx create mode 100644 web/src/settings/HouseholdSettingsPage.tsx create mode 100644 web/src/settings/LeaveGuard.module.css create mode 100644 web/src/settings/LeaveGuard.tsx create mode 100644 web/src/settings/RecipeSearchCard.tsx create mode 100644 web/src/settings/SettingsPage.module.css create mode 100644 web/src/settings/SettingsPage.test.tsx create mode 100644 web/src/settings/SettingsPage.tsx create mode 100644 web/src/settings/SettingsTabs.module.css create mode 100644 web/src/settings/SettingsTabs.test.tsx create mode 100644 web/src/settings/SettingsTabs.tsx create mode 100644 web/src/settings/api.ts create mode 100644 web/src/settings/timezones.ts create mode 100644 web/src/test/ApiProviders.tsx create mode 100644 web/src/test/dataRouter.tsx create mode 100644 web/src/ui/Select.module.css create mode 100644 web/src/ui/Select.test.tsx create mode 100644 web/src/ui/Select.tsx diff --git a/README.md b/README.md index f9f43e2..97d7c82 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin # X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset # trusts no peer, which is right for local development. # export MEALQUEST_TRUSTED_PROXY=10.60.1.33 +# Optional: a SearXNG instance the Settings screen suggests as the recipe +# search URL until one is saved. +# export MEALQUEST_SEARXNG_URL=http://localhost:8888 export MEALQUEST_DEFAULT_TZ=America/New_York make dev ``` @@ -53,8 +56,9 @@ make dev http://localhost:5173. Configuration is environment-only and fails fast: every variable above is required except the commented-out session lifetimes (when set they must be positive, with MAX at least IDLE), -trusted proxy list (every entry must be an IP or CIDR) and -`MEALQUEST_DEFAULT_TZ`, and +trusted proxy list (every entry must be an IP or CIDR), the SearXNG URL +(an absolute `http://` or `https://` URL without credentials, a query or a +fragment) and `MEALQUEST_DEFAULT_TZ`, and secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the endpoints the issuer advertises must be `https://`; plain `http://` is accepted only for `localhost`, `127.0.0.1` and `[::1]`. diff --git a/cmd/server/main.go b/cmd/server/main.go index 01c4ee5..4b78236 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,6 +13,7 @@ import ( "syscall" "time" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" @@ -78,7 +79,12 @@ func run(cfg config.Config, logger *slog.Logger) error { if err != nil { return err } - search, err := recipes.NewSearchSettingsService(db, box) + search, err := recipes.NewSearchSettingsService(db, box, cfg.SearXNGURL) + if err != nil { + return fmt.Errorf("%s: %w", config.EnvSearXNGURL, err) + } + + ai, err := aisettings.New(db, box, cfg.PublicURL.String()) if err != nil { return err } @@ -99,7 +105,7 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, runner, photos, frontend.Assets()) + router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, runner, photos, frontend.Assets()) if err != nil { return err } diff --git a/deploy/.env.example b/deploy/.env.example index a21cf4a..a908b3f 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -68,10 +68,10 @@ MEALQUEST_SESSION_MAX=4320h # MEALQUEST_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret # The Authentik provider's client secret. # MEALQUEST_SEARXNG_URL=http://searxng:8080 -# The SearXNG recipe-search sidecar on the compose network. Planned; not -# read by the service yet. The app takes its recipe-search provider and -# base URL from PATCH /api/v1/settings/recipe-search; see -# deploy/RUNBOOK.md "Recipe search (SearXNG)". +# The SearXNG recipe-search sidecar on the compose network. The Settings +# screen pre-fills it as the SearXNG URL until one is saved; the saved +# provider and base URL come from PATCH /api/v1/settings/recipe-search. +# See deploy/RUNBOOK.md "Recipe search (SearXNG)". # --- Planned; not read by the service yet ------------------------------------- # diff --git a/deploy/RUNBOOK.md b/deploy/RUNBOOK.md index fe12662..14adb79 100644 --- a/deploy/RUNBOOK.md +++ b/deploy/RUNBOOK.md @@ -417,15 +417,23 @@ The `searxng` service in `deploy/compose.yaml` is a private SearXNG instance for recipe search. On the compose network it is reachable at `http://searxng:8080`; that is the address to configure in the app. -Starting the sidecar does not connect the app to it. The service does not -read `MEALQUEST_SEARXNG_URL`: `deploy/compose.yaml` sets it for a future -wiring, and today it does nothing. The recipe-search provider and base URL -live in the `recipe_search_settings` row and are set through -`GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the Settings -screen's recipe search; the screen itself is not built yet). Until that row -exists, recipe search answers 409 `not_configured`. +Starting the sidecar does not connect the app to it. The recipe-search +provider and base URL live in the `recipe_search_settings` row and are set +through `GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the +recipe search card on Settings → AI assistant). Until that row exists, +recipe search answers 409 `not_configured`. -After the first deploy, from a signed-in session (every non-`GET` request +`deploy/compose.yaml` sets `MEALQUEST_SEARXNG_URL=http://searxng:8080`. +The service only suggests it: `GET` returns it as `default_base_url`, and +the recipe search card pre-fills the SearXNG URL with it while no URL is +saved. Nothing is stored until the card is saved, and a saved `base_url` +always wins. A set value must be an absolute `http://` or `https://` URL +without credentials, a query or a fragment, or the service refuses to +start; unset, `default_base_url` is `null`. + +To set it up, open Settings → AI assistant, check that the recipe search +card shows SearXNG with `http://searxng:8080`, select **Save recipe search** +and then **Test**. Or, from a signed-in session (every non-`GET` request needs the `X-MealQuest-CSRF` header set to `csrfToken` from `GET /api/v1/me`): diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fbbfdd5..226ab04 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -149,9 +149,35 @@ automatic** (D-021). the Brave key (`api_key`, write-only: responses carry only `api_key_set` and `api_key_last4`; an empty string removes it). The result must be usable: SearXNG needs a base_url, Brave a key. + Responses also carry the read-only `default_base_url`, the deployment's + `MEALQUEST_SEARXNG_URL` (or null), which the client pre-fills while no + base_url is saved; only a PATCH of base_url stores a URL. `POST /api/v1/settings/recipe-search/test` searches "chicken tikka masala" and returns `{query, count}`, 409 `not_configured`, or 502 `search_failed` with the provider's error. +- `GET`/`PATCH /api/v1/settings/ai` (`aisettings.Service`) — provider, + base_url, model, temperature, system_prompt, plus the read-only + `api_key_last4` and `key_verified_at`. PATCH requires a preset name as + provider, temperature 0–1 in 0.1 steps, a non-empty model and a + system_prompt of at most 8000 characters; a fixed-URL preset's base_url + must be empty or its own. Provider and base_url cannot change while a + key is stored (remove the key first), so a stored key is never sent to + an endpoint it was not stored for. + `PUT /api/v1/settings/ai/api-key` takes `{api_key}` (write-only, at least + 8 characters, sealed with the secret box; an empty string removes it) and + clears `key_verified_at`; a preset without a fixed base_url takes a key + only when its base_url passes `secureurl.Check`. `POST /api/v1/settings/ai/verify` calls + `Provider.Verify` and records `key_verified_at`; + `GET /api/v1/settings/ai/models` returns `{models: [{id, display_name}]}` + live from the provider, with the key redacted from every id and + display_name. Both work before a model is chosen + (`presets.ForAccount`), answer 409 `not_configured` when the settings + cannot build a provider, and 502 with the provider's typed error + (`auth_failed`, `rate_limited`, `bad_output`, `provider_error`; 504 + `timeout`) when the call fails. `POST /api/v1/settings/ai/reset-prompt` + restores `household.DefaultPersonaPrompt` (CTXD PROMPTS.MD §4). No + response holds the key. `Service.HarnessConfig` reads the settings for + each skill run, so a saved change applies to the next run. - `/health` — liveness. - Everything else serves the embedded PWA with SPA fallback. diff --git a/internal/ai/aisettings/aisettings.go b/internal/ai/aisettings/aisettings.go new file mode 100644 index 0000000..cfc7dcd --- /dev/null +++ b/internal/ai/aisettings/aisettings.go @@ -0,0 +1,478 @@ +// Package aisettings reads and changes the household's AI settings — the +// ai_settings row seeded by household.SeedAISettings — and builds the +// provider and harness.Config they describe. The API key is sealed with the +// secret box and never leaves the package except to the provider. +package aisettings + +import ( + "bytes" + "context" + "database/sql" + "errors" + "fmt" + "math" + "net/url" + "strings" + "unicode/utf8" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/presets" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +const ( + // MaxSystemPromptChars is the longest system prompt accepted, in + // characters. + MaxSystemPromptChars = 8000 + // MinKeyLength is the shortest API key accepted, so its last four + // characters never give away most of it. + MinKeyLength = 8 +) + +var ( + // ErrNotConfigured is returned when the stored AI settings cannot + // build a provider, e.g. there is no API key yet. It is joined with + // the llm configuration error that says why. + ErrNotConfigured = errors.New("aisettings: AI provider is not configured") + // ErrProviderFailed is returned when a call to the provider fails. It + // is joined with the provider's error, so the llm typed error it + // matches says why. + ErrProviderFailed = errors.New("aisettings: AI provider call failed") + // ErrChanged is returned when the AI settings changed while + // their key was being verified, so the result applies to old settings. + ErrChanged = errors.New("aisettings: AI settings changed during verification") +) + +// Settings is the household's AI configuration as the API shows it. The +// API key is never part of it: only its last four characters. +type Settings struct { + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + Model string `json:"model"` + Temperature float64 `json:"temperature"` + // SystemPrompt is the admin persona, nil when none is stored. + SystemPrompt *string `json:"system_prompt"` + // APIKeyLast4 is nil when no key is stored. + APIKeyLast4 *string `json:"api_key_last4"` + // KeyVerifiedAt is when the stored key was last verified with these + // provider settings, nil when it has not been. + KeyVerifiedAt *string `json:"key_verified_at"` + UpdatedAt string `json:"updated_at"` +} + +// Patch changes the settings. A nil field is left as it is. +type Patch struct { + Provider *string `json:"provider"` + BaseURL *string `json:"base_url"` + Model *string `json:"model"` + Temperature *float64 `json:"temperature"` + SystemPrompt *string `json:"system_prompt"` +} + +// Model is one model the provider offers. +type Model struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` +} + +// Problem is one invalid field, named by its JSON key. +type Problem struct { + Field string + Reason string +} + +// ValidationError lists every problem with an Patch or API +// key, or with the settings it would leave. No problem quotes the key. +type ValidationError struct { + Problems []Problem +} + +func (e *ValidationError) Error() string { + parts := make([]string, len(e.Problems)) + for i, p := range e.Problems { + parts[i] = p.Field + ": " + p.Reason + } + return "invalid AI settings: " + strings.Join(parts, "; ") +} + +// Service reads and changes the ai_settings row and builds the provider it +// names. API keys are sealed with its secret box and never leave it except +// to the provider. +type Service struct { + st *store.Store + box *secretbox.Box + publicURL string + // newProvider and newAccount are presets.FromSettings and + // presets.ForAccount; tests substitute a fake provider. + newProvider harness.NewProvider + newAccount func(llm.Settings, string) (presets.Account, error) +} + +// New returns the service over st, sealing keys with box. +// publicURL is MEALQUEST_PUBLIC_URL, which some presets send. +func New(st *store.Store, box *secretbox.Box, publicURL string) (*Service, error) { + switch { + case st == nil: + return nil, errors.New("aisettings: nil store") + case box == nil: + return nil, errors.New("aisettings: nil secret box") + case publicURL == "": + return nil, errors.New("aisettings: empty public URL") + } + return &Service{st: st, box: box, publicURL: publicURL, newProvider: presets.FromSettings, newAccount: presets.ForAccount}, nil +} + +// Get returns the settings. +func (s *Service) Get(ctx context.Context) (Settings, error) { + row, err := s.read(ctx, db.New(s.st.Reader)) + if err != nil { + return Settings{}, err + } + return settingsFrom(row), nil +} + +// Update applies patch and returns the settings it leaves. The provider +// and base URL cannot change while an API key is stored, so the key never +// reaches an endpoint it was not stored for. Every problem is reported in +// a *ValidationError and nothing changes. +func (s *Service) Update(ctx context.Context, patch Patch) (Settings, error) { + return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) { + return applyPatch(row, patch) + }) +} + +// SetAPIKey seals key and records its last four characters, clearing +// key_verified_at. An empty key removes the stored one. A preset without +// a fixed base URL takes a key only when its base URL is secure (see +// secureurl.Check). +func (s *Service) SetAPIKey(ctx context.Context, key string) (Settings, error) { + return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) { + row.ApiKeyCt, row.ApiKeyLast4, row.KeyVerifiedAt = nil, sql.NullString{}, sql.NullString{} + if key == "" { + return row, nil + } + p, _ := presets.Lookup(row.Provider) + var reason string + switch { + case p.Key == presets.KeyNone: + reason = row.Provider + " takes no API key" + case p.BaseURL == "" && !secureBaseURL(row.BaseUrl): + reason = "is sent only over https://, or http:// to localhost: set such a base_url first" + case !validHeaderValue(key): + reason = "has a character not allowed in a header" + case len(key) < MinKeyLength: + reason = fmt.Sprintf("must be at least %d characters", MinKeyLength) + } + if reason != "" { + return db.AiSetting{}, &ValidationError{Problems: []Problem{{Field: "api_key", Reason: reason}}} + } + ct, err := s.box.Encrypt([]byte(key)) + if err != nil { + return db.AiSetting{}, fmt.Errorf("aisettings: seal AI API key: %w", err) + } + row.ApiKeyCt = ct + row.ApiKeyLast4 = sql.NullString{String: key[len(key)-4:], Valid: true} + return row, nil + }) +} + +// ResetPrompt restores household.DefaultPersonaPrompt as the system prompt. +func (s *Service) ResetPrompt(ctx context.Context) (Settings, error) { + return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) { + row.SystemPrompt = sql.NullString{String: household.DefaultPersonaPrompt, Valid: true} + return row, nil + }) +} + +// Verify checks the stored key with the provider and, when it is +// accepted, records key_verified_at. Settings that cannot build a +// provider fail with ErrNotConfigured; a provider failure matches +// ErrProviderFailed and the llm typed error the provider returned. If the +// settings change while the provider is asked, nothing is recorded and the +// error is ErrChanged. +func (s *Service) Verify(ctx context.Context) (Settings, error) { + row, err := s.read(ctx, db.New(s.st.Reader)) + if err != nil { + return Settings{}, err + } + acct, key, err := s.account(row) + if err != nil { + return Settings{}, err + } + if err := acct.Verify(ctx); err != nil { + return Settings{}, redact(fmt.Errorf("%w: verify: %w", ErrProviderFailed, err), key) + } + verified := store.Now().String() + return s.change(ctx, func(now db.AiSetting) (db.AiSetting, error) { + if now.Provider != row.Provider || now.BaseUrl != row.BaseUrl || !bytes.Equal(now.ApiKeyCt, row.ApiKeyCt) { + return db.AiSetting{}, ErrChanged + } + now.KeyVerifiedAt = sql.NullString{String: verified, Valid: true} + return now, nil + }) +} + +// Models returns the provider's live model list with the API key removed +// from every model's ID and display name. Its errors are Verify's. +func (s *Service) Models(ctx context.Context) ([]Model, error) { + row, err := s.read(ctx, db.New(s.st.Reader)) + if err != nil { + return nil, err + } + acct, key, err := s.account(row) + if err != nil { + return nil, err + } + models, err := acct.ListModels(ctx) + if err != nil { + return nil, redact(fmt.Errorf("%w: list models: %w", ErrProviderFailed, err), key) + } + out := make([]Model, len(models)) + for i, m := range models { + out[i] = Model{ID: llm.Redact(m.ID, key), DisplayName: llm.Redact(m.DisplayName, key)} + } + return out, nil +} + +// HarnessConfig returns the harness.Config for a skill run with the +// current settings, sharing guard. Read it for each run, so a run always +// uses the latest saved provider, model, temperature and persona. +func (s *Service) HarnessConfig(ctx context.Context, guard *harness.Guard) (harness.Config, error) { + row, err := s.read(ctx, db.New(s.st.Reader)) + if err != nil { + return harness.Config{}, err + } + key, err := s.key(row) + if err != nil { + return harness.Config{}, err + } + return harness.Config{ + Settings: s.llmSettings(row), + APIKey: key, + NewProvider: s.newProvider, + Persona: row.SystemPrompt.String, + Store: s.st, + Guard: guard, + }, nil +} + +// account builds the provider's Account from row, returning the key too +// so the caller can redact it. +func (s *Service) account(row db.AiSetting) (presets.Account, string, error) { + key, err := s.key(row) + if err != nil { + return nil, "", err + } + acct, err := s.newAccount(s.llmSettings(row), key) + if err != nil { + return nil, "", redact(fmt.Errorf("%w: %w", ErrNotConfigured, err), key) + } + return acct, key, nil +} + +func (s *Service) llmSettings(row db.AiSetting) llm.Settings { + return llm.Settings{ + Provider: row.Provider, + BaseURL: row.BaseUrl, + Model: row.Model, + Temperature: row.Temperature, + PublicURL: s.publicURL, + } +} + +// key opens row's API key; "" when none is stored. +func (s *Service) key(row db.AiSetting) (string, error) { + if len(row.ApiKeyCt) == 0 { + return "", nil + } + key, err := s.box.Decrypt(row.ApiKeyCt) + if err != nil { + return "", fmt.Errorf("aisettings: open AI API key: %w", err) + } + return string(key), nil +} + +func (s *Service) read(ctx context.Context, q *db.Queries) (db.AiSetting, error) { + row, err := q.GetAISettings(ctx) + if errors.Is(err, sql.ErrNoRows) { + return db.AiSetting{}, errors.New("aisettings: AI settings are not seeded") + } + if err != nil { + return db.AiSetting{}, fmt.Errorf("aisettings: read AI settings: %w", err) + } + return row, nil +} + +// change saves edit's result for the current row in one transaction. +func (s *Service) change(ctx context.Context, edit func(db.AiSetting) (db.AiSetting, error)) (Settings, error) { + var saved db.AiSetting + err := s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + row, err := s.read(ctx, q) + if err != nil { + return err + } + next, err := edit(row) + if err != nil { + return err + } + saved, err = q.UpsertAISettings(ctx, db.UpsertAISettingsParams{ + Provider: next.Provider, + BaseUrl: next.BaseUrl, + Model: next.Model, + Temperature: next.Temperature, + SystemPrompt: next.SystemPrompt, + ApiKeyCt: next.ApiKeyCt, + ApiKeyLast4: next.ApiKeyLast4, + KeyVerifiedAt: next.KeyVerifiedAt, + CreatedAt: row.CreatedAt, + UpdatedAt: store.Now().String(), + }) + if err != nil { + return fmt.Errorf("aisettings: save AI settings: %w", err) + } + return nil + }) + if err != nil { + return Settings{}, err + } + return settingsFrom(saved), nil +} + +// applyPatch returns row with patch applied, validated. +func applyPatch(row db.AiSetting, patch Patch) (db.AiSetting, error) { + var problems []Problem + fail := func(field, reason string) { + problems = append(problems, Problem{Field: field, Reason: reason}) + } + if patch == (Patch{}) { + fail("settings", "no settings to change") + } + before := row + if patch.Provider != nil { + if _, ok := presets.Lookup(*patch.Provider); ok { + row.Provider = *patch.Provider + } else { + fail("provider", "must be one of "+strings.Join(presets.Names(), ", ")) + } + } + if patch.BaseURL != nil { + row.BaseUrl = *patch.BaseURL + } + if patch.Model != nil { + switch m := *patch.Model; { + case strings.TrimSpace(m) == "": + fail("model", "must not be empty") + case !validHeaderValue(m): + fail("model", "must not contain control characters") + default: + row.Model = m + } + } + if patch.Temperature != nil { + t := *patch.Temperature + tenths := math.Round(t * 10) + if t < 0 || t > 1 || math.Abs(t*10-tenths) > 1e-9 { + fail("temperature", "must be between 0 and 1 in steps of 0.1") + } else { + row.Temperature = tenths / 10 + } + } + if patch.SystemPrompt != nil { + if n := utf8.RuneCountInString(*patch.SystemPrompt); n > MaxSystemPromptChars { + fail("system_prompt", fmt.Sprintf("must be at most %d characters, got %d", MaxSystemPromptChars, n)) + } else { + row.SystemPrompt = sql.NullString{String: *patch.SystemPrompt, Valid: true} + } + } + if p, ok := presets.Lookup(row.Provider); ok && len(problems) == 0 { + switch { + case p.BaseURL != "" && row.BaseUrl != "" && row.BaseUrl != p.BaseURL: + fail("base_url", fmt.Sprintf("is fixed for %s: must be empty or %s", p.Name, p.BaseURL)) + case p.BaseURL == "" && row.BaseUrl != "": + // The preset's own check of the URL; its errors never quote it. + if _, err := presets.ForAccount(llm.Settings{Provider: p.Name, BaseURL: row.BaseUrl}, ""); err != nil { + fail("base_url", err.Error()) + } + } + } + // The stored key is only ever sent where it was stored for: moving it + // to another provider or server would let a changed base URL receive + // it. Remove the key, change the endpoint, then store the new key. + if len(problems) == 0 && len(row.ApiKeyCt) > 0 { + if row.Provider != before.Provider { + fail("provider", "cannot change while an API key is stored: remove the key first") + } + if row.BaseUrl != before.BaseUrl { + fail("base_url", "cannot change while an API key is stored: remove the key first") + } + } + if len(problems) > 0 { + return db.AiSetting{}, &ValidationError{Problems: problems} + } + return row, nil +} + +func settingsFrom(row db.AiSetting) Settings { + return Settings{ + Provider: row.Provider, + BaseURL: row.BaseUrl, + Model: row.Model, + Temperature: row.Temperature, + SystemPrompt: nullString(row.SystemPrompt), + APIKeyLast4: nullString(row.ApiKeyLast4), + KeyVerifiedAt: nullString(row.KeyVerifiedAt), + UpdatedAt: row.UpdatedAt, + } +} + +func nullString(s sql.NullString) *string { + if !s.Valid { + return nil + } + return &s.String +} + +// secureBaseURL reports whether base is a URL a key may be sent to. +func secureBaseURL(base string) bool { + u, err := url.Parse(base) + return base != "" && err == nil && secureurl.Check(u) == nil +} + +// validHeaderValue reports whether value has no control character other +// than tab. +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 +} + +// redactedError is an error with the API key removed from its text. It +// does not unwrap, because the original may hold the key; Is still matches +// the sentinels the original wraps. +type redactedError struct { + text string + err error +} + +func (e *redactedError) Error() 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; an empty key returns +// err unchanged. +func redact(err error, key string) error { + if key == "" { + return err + } + return &redactedError{text: llm.Redact(err.Error(), key), err: err} +} diff --git a/internal/ai/aisettings/aisettings_test.go b/internal/ai/aisettings/aisettings_test.go new file mode 100644 index 0000000..3bf9fe8 --- /dev/null +++ b/internal/ai/aisettings/aisettings_test.go @@ -0,0 +1,463 @@ +package aisettings + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "reflect" + "slices" + "strings" + "sync" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/presets" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +const testKey = "sk-ant-test-secret-key-41ab" + +// newService returns a Service over a migrated store with the seed AI +// settings. +func newService(t *testing.T) (*Service, *store.Store) { + t.Helper() + ctx := context.Background() + st, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + if err := household.SeedAISettings(ctx, st); err != nil { + t.Fatal(err) + } + raw := make([]byte, secretbox.KeySize) + rand.Read(raw) + box, err := secretbox.New(base64.StdEncoding.EncodeToString(raw)) + if err != nil { + t.Fatal(err) + } + s, err := New(st, box, "https://mealquest.example") + if err != nil { + t.Fatal(err) + } + return s, st +} + +// useFake makes s build f for every run, verification and model list, and +// returns a func reporting the settings and key each build was given. +func useFake(s *Service, f *llm.Fake) func() ([]llm.Settings, []string) { + var mu sync.Mutex + var settings []llm.Settings + var keys []string + record := func(set llm.Settings, key string) { + mu.Lock() + defer mu.Unlock() + settings = append(settings, set) + keys = append(keys, key) + } + s.newProvider = func(set llm.Settings, key string) (llm.Provider, error) { + record(set, key) + return f, nil + } + s.newAccount = func(set llm.Settings, key string) (presets.Account, error) { + record(set, key) + return f, nil + } + return func() ([]llm.Settings, []string) { + mu.Lock() + defer mu.Unlock() + return settings, keys + } +} + +func ptr[T any](v T) *T { return &v } + +// echoSkill asks the model to repeat a word; its prompt is the harness's +// test template prompts/echo.v1.tmpl. +type echoSkill struct{} + +type echoInput struct{ Word string } + +type echoOutput struct { + Echo string `json:"echo"` +} + +func (echoSkill) Name() string { return "echo" } +func (echoSkill) Version() int { return 1 } +func (echoSkill) PromptInput(in echoInput) (harness.PromptInput, error) { + return harness.PromptInput{Data: in}, nil +} +func (echoSkill) Schema() llm.JSONSchema { + return llm.JSONSchema{Name: "echo", Schema: json.RawMessage( + `{"type":"object","properties":{"echo":{"type":"string"}},"required":["echo"],"additionalProperties":false}`)} +} +func (echoSkill) Validate(echoOutput) []string { return nil } +func (echoSkill) PostProcess(out echoOutput) (echoOutput, error) { return out, nil } +func (echoSkill) Timeout() time.Duration { return harness.DefaultTimeout } + +// A PATCHed model and temperature are what the next skill run uses: the +// provider is built with them, the request carries the temperature and the +// ai_runs row records both. A second PATCH changes the run after it. +func TestPatchedModelAndTemperatureUsedByNextRun(t *testing.T) { + ctx := context.Background() + s, st := newService(t) + f := &llm.Fake{} + built := useFake(s, f) + guard, err := harness.NewGuard(harness.DefaultMaxCalls) + if err != nil { + t.Fatal(err) + } + if _, err := s.SetAPIKey(ctx, testKey); err != nil { + t.Fatal(err) + } + + for i, want := range []struct { + model string + temp float64 + }{{"claude-patched", 0.3}, {"claude-repatched", 1}} { + if _, err := s.Update(ctx, Patch{Model: ptr(want.model), Temperature: ptr(want.temp)}); err != nil { + t.Fatal(err) + } + cfg, err := s.HarnessConfig(ctx, guard) + if err != nil { + t.Fatal(err) + } + f.QueueGenerate(llm.GenerateResult{Response: llm.Response{JSON: json.RawMessage(`{"echo":"hi"}`)}}) + if _, err := harness.Run(ctx, cfg, echoSkill{}, "test", echoInput{Word: "hi"}); err != nil { + t.Fatalf("run %d: %v", i, err) + } + + settings, keys := built() + if got := settings[i]; got.Provider != "anthropic" || got.Model != want.model || got.Temperature != want.temp || keys[i] != testKey { + t.Errorf("run %d provider built with %+v, key ok %v; want model %s at %v", i, got, keys[i] == testKey, want.model, want.temp) + } + req := f.Requests()[i] + if req.Temperature == nil || *req.Temperature != want.temp { + t.Errorf("run %d request temperature = %v, want %v", i, req.Temperature, want.temp) + } + if !strings.HasPrefix(req.System, household.DefaultPersonaPrompt[:40]) { + t.Errorf("run %d system prompt does not start with the persona: %.80q", i, req.System) + } + var model string + var temp float64 + err = st.Reader.QueryRowContext(ctx, `SELECT model, temperature FROM ai_runs ORDER BY created_at DESC, rowid DESC LIMIT 1`).Scan(&model, &temp) + if err != nil { + t.Fatal(err) + } + if model != want.model || temp != want.temp { + t.Errorf("run %d ai_runs row = %s at %v, want %s at %v", i, model, temp, want.model, want.temp) + } + } +} + +func TestUpdateValidates(t *testing.T) { + ctx := context.Background() + for _, c := range []struct { + name string + patch Patch + field string + }{ + {"empty", Patch{}, "settings"}, + {"unknown provider", Patch{Provider: ptr("gemini")}, "provider"}, + {"temperature above 1", Patch{Temperature: ptr(1.1)}, "temperature"}, + {"negative temperature", Patch{Temperature: ptr(-0.1)}, "temperature"}, + {"temperature between steps", Patch{Temperature: ptr(0.35)}, "temperature"}, + {"empty model", Patch{Model: ptr(" ")}, "model"}, + {"long system prompt", Patch{SystemPrompt: ptr(strings.Repeat("ü", MaxSystemPromptChars+1))}, "system_prompt"}, + {"other base URL for a fixed preset", Patch{BaseURL: ptr("https://api.example/v1")}, "base_url"}, + {"bad base URL", Patch{Provider: ptr("ollama"), BaseURL: ptr("ftp://ollama:11434")}, "base_url"}, + } { + t.Run(c.name, func(t *testing.T) { + s, _ := newService(t) + before, err := s.Get(ctx) + if err != nil { + t.Fatal(err) + } + _, err = s.Update(ctx, c.patch) + var invalid *ValidationError + if !errors.As(err, &invalid) || len(invalid.Problems) != 1 || invalid.Problems[0].Field != c.field { + t.Fatalf("Update = %v, want one %s problem", err, c.field) + } + if after, err := s.Get(ctx); err != nil || !reflect.DeepEqual(after, before) { + t.Errorf("settings changed by a refused patch: %+v, %v", after, err) + } + }) + } +} + +func TestUpdateAccepts(t *testing.T) { + ctx := context.Background() + s, _ := newService(t) + for _, temp := range []float64{0, 0.1, 0.2, 0.3, 0.6, 0.7, 0.9, 1} { + got, err := s.Update(ctx, Patch{Temperature: ptr(temp)}) + if err != nil || got.Temperature != temp { + t.Errorf("temperature %v = %v, %v", temp, got.Temperature, err) + } + } + prompt := strings.Repeat("ü", MaxSystemPromptChars) + if got, err := s.Update(ctx, Patch{SystemPrompt: &prompt}); err != nil || *got.SystemPrompt != prompt { + t.Errorf("system prompt of %d characters: %v", MaxSystemPromptChars, err) + } + for _, p := range presets.Names() { + patch := Patch{Provider: ptr(p), BaseURL: ptr("")} + if pr, _ := presets.Lookup(p); pr.BaseURL == "" { + patch.BaseURL = ptr("http://llm.local:11434") + } + if got, err := s.Update(ctx, patch); err != nil || got.Provider != p { + t.Errorf("provider %s: %+v, %v", p, got, err) + } + } +} + +// While a key is stored the provider and base URL cannot change, so the +// key never reaches an endpoint it was not stored for; the model, +// temperature and system prompt can, keeping the verification. +func TestEndpointLockedWhileKeyStored(t *testing.T) { + ctx := context.Background() + s, _ := newService(t) + f := &llm.Fake{} + useFake(s, f) + if _, err := s.Update(ctx, Patch{Provider: ptr("openai_compatible"), BaseURL: ptr("https://llm.example/v1")}); err != nil { + t.Fatal(err) + } + if _, err := s.SetAPIKey(ctx, testKey); err != nil { + t.Fatal(err) + } + f.QueueVerify(nil) + if got, err := s.Verify(ctx); err != nil || got.KeyVerifiedAt == nil { + t.Fatalf("Verify = %+v, %v", got, err) + } + got, err := s.Update(ctx, Patch{Model: ptr("m"), Temperature: ptr(0.5), SystemPrompt: ptr("p")}) + if err != nil || got.KeyVerifiedAt == nil { + t.Errorf("model change = %+v, %v; want verification kept", got, err) + } + before, err := s.Get(ctx) + if err != nil { + t.Fatal(err) + } + for _, c := range []struct { + patch Patch + field string + }{ + {Patch{BaseURL: ptr("https://attacker.example/v1")}, "base_url"}, + {Patch{Provider: ptr("openai"), BaseURL: ptr("")}, "provider"}, + {Patch{Provider: ptr("ollama"), BaseURL: ptr("http://localhost:11434")}, "provider"}, + } { + var invalid *ValidationError + _, err := s.Update(ctx, c.patch) + if !errors.As(err, &invalid) || !slices.ContainsFunc(invalid.Problems, func(p Problem) bool { return p.Field == c.field }) { + t.Errorf("Update(%+v) with a key stored = %v, want a %s problem", c.patch, err, c.field) + } + } + if after, err := s.Get(ctx); err != nil || !reflect.DeepEqual(after, before) { + t.Errorf("refused endpoint change altered the settings: %+v, %v", after, err) + } + + // Removing the key frees the endpoint; a new key is unverified. + if _, err := s.SetAPIKey(ctx, ""); err != nil { + t.Fatal(err) + } + if _, err := s.Update(ctx, Patch{Provider: ptr("openai"), BaseURL: ptr("")}); err != nil { + t.Fatal(err) + } + got, err = s.SetAPIKey(ctx, "sk-another-key-9999") + if err != nil || got.KeyVerifiedAt != nil || *got.APIKeyLast4 != "9999" { + t.Errorf("new key = %+v, %v; want it unverified", got, err) + } +} + +// A key is never stored for a base URL it would cross the network to in +// plain text. +func TestSetAPIKeyNeedsSecureBaseURL(t *testing.T) { + ctx := context.Background() + for _, c := range []struct { + base string + ok bool + }{ + {"", false}, + {"http://llm.lan:8080/v1", false}, + {"http://127.0.0.1:8080/v1", true}, + {"https://llm.example/v1", true}, + } { + s, _ := newService(t) + patch := Patch{Provider: ptr("openai_compatible")} + if c.base != "" { + patch.BaseURL = ptr(c.base) + } + if _, err := s.Update(ctx, patch); err != nil { + t.Fatal(err) + } + _, err := s.SetAPIKey(ctx, testKey) + var invalid *ValidationError + if c.ok != (err == nil) || (!c.ok && !errors.As(err, &invalid)) { + t.Errorf("SetAPIKey with base URL %q = %v, want ok %v", c.base, err, c.ok) + } + } +} + +func TestVerifyReturnsProviderTypedError(t *testing.T) { + ctx := context.Background() + s, _ := newService(t) + f := &llm.Fake{} + useFake(s, f) + if _, err := s.SetAPIKey(ctx, testKey); err != nil { + t.Fatal(err) + } + for _, want := range []error{llm.ErrAuth, llm.NewRateLimitedError(time.Second, testKey), llm.NewProviderError(500, []byte("bad "+testKey), testKey)} { + f.QueueVerify(want) + _, err := s.Verify(ctx) + if !errors.Is(err, ErrProviderFailed) || !errors.Is(err, want) { + t.Errorf("Verify = %v, want ErrProviderFailed and %v", err, want) + } + if err != nil && strings.Contains(err.Error(), testKey) { + t.Errorf("Verify error holds the key: %v", err) + } + } + if got, err := s.Get(ctx); err != nil || got.KeyVerifiedAt != nil { + t.Errorf("failed verification recorded: %+v, %v", got, err) + } +} + +// A key error that quotes the key, as a misbehaving adapter might, is +// redacted by the service. +func TestVerifyRedactsKey(t *testing.T) { + ctx := context.Background() + s, _ := newService(t) + f := &llm.Fake{} + useFake(s, f) + if _, err := s.SetAPIKey(ctx, testKey); err != nil { + t.Fatal(err) + } + f.QueueVerify(errors.Join(llm.ErrAuth, errors.New("rejected "+testKey))) + _, err := s.Verify(ctx) + if !errors.Is(err, llm.ErrAuth) || strings.Contains(err.Error(), testKey) { + t.Errorf("Verify = %v, want ErrAuth without the key", err) + } + f.QueueListModels(llm.ListModelsResult{Err: errors.New("rejected " + testKey)}) + if _, err := s.Models(ctx); !errors.Is(err, ErrProviderFailed) || strings.Contains(err.Error(), testKey) { + t.Errorf("Models = %v, want ErrProviderFailed without the key", err) + } + f.QueueListModels(llm.ListModelsResult{Models: []llm.Model{{ID: "m-" + testKey, DisplayName: "Model " + testKey}}}) + models, err := s.Models(ctx) + if err != nil || len(models) != 1 || models[0] != (Model{ID: "m-[REDACTED]", DisplayName: "Model [REDACTED]"}) { + t.Errorf("Models = %+v, %v, want the key redacted", models, err) + } +} + +// Settings changed while the provider verifies the key are not marked +// verified. +func TestVerifyRefusesChangedSettings(t *testing.T) { + ctx := context.Background() + s, _ := newService(t) + if _, err := s.SetAPIKey(ctx, testKey); err != nil { + t.Fatal(err) + } + s.newAccount = func(llm.Settings, string) (presets.Account, error) { + return changingAccount{s: s}, nil + } + if _, err := s.Verify(ctx); !errors.Is(err, ErrChanged) { + t.Fatalf("Verify = %v, want ErrChanged", err) + } + if got, err := s.Get(ctx); err != nil || got.KeyVerifiedAt != nil || *got.APIKeyLast4 != "5678" { + t.Errorf("settings after a changed verification = %+v, %v", got, err) + } +} + +// changingAccount replaces the stored key while it verifies. +type changingAccount struct{ s *Service } + +func (a changingAccount) Verify(ctx context.Context) error { + _, err := a.s.SetAPIKey(ctx, "sk-replaced-5678") + return err +} + +func (changingAccount) ListModels(context.Context) ([]llm.Model, error) { return nil, nil } + +func TestNotConfigured(t *testing.T) { + ctx := context.Background() + s, _ := newService(t) + // The seed is anthropic with no key; the real presets refuse it. + if _, err := s.Verify(ctx); !errors.Is(err, ErrNotConfigured) || !errors.Is(err, llm.ErrMissingKey) { + t.Errorf("Verify = %v, want ErrNotConfigured and ErrMissingKey", err) + } + if _, err := s.Models(ctx); !errors.Is(err, ErrNotConfigured) { + t.Errorf("Models = %v, want ErrNotConfigured", err) + } +} + +func TestSetAPIKey(t *testing.T) { + ctx := context.Background() + s, st := newService(t) + got, err := s.SetAPIKey(ctx, testKey) + if err != nil || got.APIKeyLast4 == nil || *got.APIKeyLast4 != "41ab" { + t.Fatalf("SetAPIKey = %+v, %v", got, err) + } + var ct []byte + if err := st.Reader.QueryRowContext(ctx, `SELECT api_key_ct FROM ai_settings`).Scan(&ct); err != nil { + t.Fatal(err) + } + if strings.Contains(string(ct), testKey) { + t.Error("the key is stored in plain text") + } + if key, err := s.key(mustRow(t, s)); err != nil || key != testKey { + t.Errorf("stored key opens to %q, %v", key, err) + } + + for _, bad := range []string{"short", "sk-bad\r\nkey"} { + var invalid *ValidationError + if _, err := s.SetAPIKey(ctx, bad); !errors.As(err, &invalid) { + t.Errorf("SetAPIKey(%q) = %v, want a ValidationError", bad, err) + } + } + + // Ollama takes no key: neither storing one nor switching to it with one. + var invalid *ValidationError + if _, err := s.Update(ctx, Patch{Provider: ptr("ollama"), BaseURL: ptr("http://ollama:11434")}); !errors.As(err, &invalid) { + t.Errorf("switch to ollama with a key = %v, want a ValidationError", err) + } + if got, err := s.SetAPIKey(ctx, ""); err != nil || got.APIKeyLast4 != nil { + t.Fatalf("remove key = %+v, %v", got, err) + } + if _, err := s.Update(ctx, Patch{Provider: ptr("ollama"), BaseURL: ptr("http://ollama:11434")}); err != nil { + t.Fatal(err) + } + if _, err := s.SetAPIKey(ctx, testKey); !errors.As(err, &invalid) { + t.Errorf("SetAPIKey for ollama = %v, want a ValidationError", err) + } +} + +func TestResetPrompt(t *testing.T) { + ctx := context.Background() + s, _ := newService(t) + if _, err := s.Update(ctx, Patch{SystemPrompt: ptr("")}); err != nil { + t.Fatal(err) + } + got, err := s.ResetPrompt(ctx) + if err != nil || got.SystemPrompt == nil || *got.SystemPrompt != household.DefaultPersonaPrompt { + t.Errorf("ResetPrompt = %+v, %v", got, err) + } +} + +func mustRow(t *testing.T, s *Service) db.AiSetting { + t.Helper() + row, err := s.read(context.Background(), db.New(s.st.Reader)) + if err != nil { + t.Fatal(err) + } + return row +} diff --git a/internal/ai/llm/presets/presets.go b/internal/ai/llm/presets/presets.go index da86eda..26c7cdb 100644 --- a/internal/ai/llm/presets/presets.go +++ b/internal/ai/llm/presets/presets.go @@ -15,6 +15,7 @@ import ( "context" "errors" "fmt" + "maps" "math" "regexp" "slices" @@ -115,6 +116,11 @@ func Lookup(name string) (Preset, bool) { return p, ok } +// Names returns every preset's name, sorted. +func Names() []string { + return slices.Sorted(maps.Keys(all)) +} + // all is every preset, by Settings.Provider name. var all = map[string]Preset{ "anthropic": { @@ -181,19 +187,40 @@ var all = map[string]Preset{ // preset's OmitTemperature matches. It is wrapped in llm.NewRetrying, so // Generate retries transient vendor failures and has the default timeout. func FromSettings(s llm.Settings, key string) (llm.Provider, error) { - cp, err := fromSettings(s, key) + cp, err := fromSettings(s, key, true) if err != nil { return nil, redact(err, key) } return llm.NewRetrying(cp), nil } -func fromSettings(s llm.Settings, key string) (*configured, error) { +// Account is what a provider offers before a model is chosen: the live +// model list and a check of the credentials. +type Account interface { + ListModels(ctx context.Context) ([]llm.Model, error) + Verify(ctx context.Context) error +} + +// ForAccount returns the Account s describes, using key as its API key. It +// checks s and key as FromSettings does except that s.Model may be empty, +// so the household can verify its key and pick from the live model list +// before it has chosen a model. Neither call is retried. +func ForAccount(s llm.Settings, key string) (Account, error) { + cp, err := fromSettings(s, key, false) + if err != nil { + return nil, redact(err, key) + } + return cp, nil +} + +// fromSettings builds the provider s describes; needModel is whether +// s.Model is required. +func fromSettings(s llm.Settings, key string, needModel bool) (*configured, error) { p, ok := Lookup(s.Provider) if !ok { return nil, llm.ErrUnknownProvider } - if err := check(p, s, key); err != nil { + if err := check(p, s, key, needModel); err != nil { return nil, fmt.Errorf("%s: %w", p.Name, err) } base := p.BaseURL @@ -233,8 +260,9 @@ func fromSettings(s llm.Settings, key string) (*configured, error) { 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 { +// check validates s and key against p, and that s names a model when +// needModel is set. Its errors never quote a value. +func check(p Preset, s llm.Settings, key string, needModel bool) error { switch { case p.Key == KeyRequired && key == "": return llm.ErrMissingKey @@ -249,7 +277,7 @@ func check(p Preset, s llm.Settings, key string) error { 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 == "" { + if needModel && s.Model == "" { return llm.ErrMissingModel } if p.RequiresPublicURL && s.PublicURL == "" { diff --git a/internal/ai/llm/presets/presets_test.go b/internal/ai/llm/presets/presets_test.go index 41e4b3f..2b2645f 100644 --- a/internal/ai/llm/presets/presets_test.go +++ b/internal/ai/llm/presets/presets_test.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "slices" "strings" "testing" @@ -660,3 +661,35 @@ func TestFromSettingsRetriesTransportTimeout(t *testing.T) { t.Errorf("sent %d requests, want 2", st.sent) } } + +// ForAccount builds without a model, so a key can be verified and the +// model list read before a model is chosen, but keeps every other check. +func TestForAccount(t *testing.T) { + if _, err := ForAccount(llm.Settings{Provider: "openai"}, testKey); err != nil { + t.Errorf("ForAccount without a model = %v", err) + } + for _, c := range []struct { + s llm.Settings + key string + want error + }{ + {llm.Settings{Provider: "gemini"}, testKey, llm.ErrUnknownProvider}, + {llm.Settings{Provider: "openai"}, "", llm.ErrMissingKey}, + {llm.Settings{Provider: "ollama"}, "", llm.ErrMissingBaseURL}, + {llm.Settings{Provider: "openrouter"}, testKey, llm.ErrMissingPublicURL}, + } { + if _, err := ForAccount(c.s, c.key); !errors.Is(err, c.want) { + t.Errorf("ForAccount(%s) = %v, want %v", c.s.Provider, err, c.want) + } + } + if _, err := FromSettings(llm.Settings{Provider: "openai"}, testKey); !errors.Is(err, llm.ErrMissingModel) { + t.Errorf("FromSettings without a model = %v, want ErrMissingModel", err) + } +} + +func TestNames(t *testing.T) { + want := []string{"anthropic", "ollama", "openai", "openai_compatible", "openrouter", "xai"} + if got := Names(); !slices.Equal(got, want) { + t.Errorf("Names = %v, want %v", got, want) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 1c1dfd7..c838255 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,6 +35,8 @@ const ( EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY" EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ" + + EnvSearXNGURL = "MEALQUEST_SEARXNG_URL" ) // Session lifetime defaults (D-004): long enough that the kitchen tablet @@ -73,6 +75,13 @@ type Config struct { // validates it only while the settings do not exist yet, and ignores it // once they do. Empty when unset. DefaultTZ string + + // SearXNGURL is the deployment's SearXNG instance, which the Settings + // screen suggests while no recipe-search URL is saved. It is optional + // and validated by recipes.NewSearchSettingsService: the compose + // network's http://searxng:8080 is neither https nor loopback, so the + // secure-URL rule does not apply. Empty when unset. + SearXNGURL string } // PublicOrigin returns the scheme and host of PublicURL, the only parts of it @@ -107,6 +116,8 @@ func load(lookup func(string) (string, bool)) (Config, error) { TrustedProxy: r.trustedProxy(EnvTrustedProxy), DefaultTZ: r.optional(EnvDefaultTZ), + + SearXNGURL: r.optional(EnvSearXNGURL), } if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle { r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 937474d..91e24c7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -422,6 +422,8 @@ func TestLoadValues(t *testing.T) { EnvOIDCAllowedGroups: " mealquest-admin ,family", EnvDefaultTZ: "Europe/London", + + EnvSearXNGURL: "http://searxng:8080", } cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok }) if err != nil { @@ -480,6 +482,9 @@ func TestLoadValues(t *testing.T) { if cfg.DefaultTZ != "Europe/London" { t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ) } + if cfg.SearXNGURL != "http://searxng:8080" { + t.Errorf("SearXNGURL = %q, want http://searxng:8080", cfg.SearXNGURL) + } } func TestSecretRedacted(t *testing.T) { diff --git a/internal/household/defaults.go b/internal/household/defaults.go index c74ff80..34fbd51 100644 --- a/internal/household/defaults.go +++ b/internal/household/defaults.go @@ -11,7 +11,8 @@ import ( ) // DefaultPersonaPrompt is the default AI system prompt (CTXD PROMPTS.MD §4). -// SeedAISettings stores it on first boot and the Admin reset restores it. +// SeedAISettings stores it on first boot and aisettings.Service.ResetPrompt +// restores it. const DefaultPersonaPrompt = `You are MealQuest, a dinner planning assistant for one household. Suggest dinners similar to their regular rotation, nudging them to try something a little new each week. Prefer weeknight-friendly recipes with common grocery-store ingredients. diff --git a/internal/recipes/searchsettings.go b/internal/recipes/searchsettings.go index 55d148b..39c8f5c 100644 --- a/internal/recipes/searchsettings.go +++ b/internal/recipes/searchsettings.go @@ -30,10 +30,15 @@ var ErrSearchNotConfigured = errors.New("recipes: recipe search is not configure type SearchSettings struct { // Provider is "searxng" or "brave", nil until first chosen. Provider *string `json:"provider"` - // BaseURL is the SearXNG instance's URL, nil when not set. - BaseURL *string `json:"base_url"` - APIKeySet bool `json:"api_key_set"` - APIKeyLast4 *string `json:"api_key_last4"` + // BaseURL is the saved SearXNG instance's URL, nil when not set. + BaseURL *string `json:"base_url"` + // DefaultBaseURL is the deployment's SearXNG URL + // (MEALQUEST_SEARXNG_URL), for the client to suggest while BaseURL is + // not set. It is never saved by itself: only a PATCH of base_url saves + // a URL. Nil when the deployment names none. + DefaultBaseURL *string `json:"default_base_url"` + APIKeySet bool `json:"api_key_set"` + APIKeyLast4 *string `json:"api_key_last4"` // UpdatedAt is nil until the settings are first saved. UpdatedAt *string `json:"updated_at"` } @@ -75,32 +80,49 @@ func (e *SearchSettingsError) Error() string { type SearchSettingsService struct { st *store.Store box *secretbox.Box + // defaultBaseURL is SearchSettings.DefaultBaseURL, "" for none. + defaultBaseURL string // newProvider is NewSearchProvider; tests point Brave elsewhere. newProvider func(db.RecipeSearchSetting, *secretbox.Box) (SearchProvider, error) } // NewSearchSettingsService returns the service over st, sealing keys with -// box. -func NewSearchSettingsService(st *store.Store, box *secretbox.Box) (*SearchSettingsService, error) { +// box. defaultBaseURL is the deployment's SearXNG URL, "" for none; a set +// one must be a valid SearXNG URL. +func NewSearchSettingsService(st *store.Store, box *secretbox.Box, defaultBaseURL string) (*SearchSettingsService, error) { if st == nil { return nil, errors.New("recipes: nil store") } if box == nil { return nil, errors.New("recipes: nil secret box") } - return &SearchSettingsService{st: st, box: box, newProvider: NewSearchProvider}, nil + if defaultBaseURL != "" { + if _, err := NewSearXNG(defaultBaseURL); err != nil { + return nil, fmt.Errorf("recipes: default SearXNG URL: %s", strings.TrimPrefix(err.Error(), "recipes: ")) + } + } + return &SearchSettingsService{st: st, box: box, defaultBaseURL: defaultBaseURL, newProvider: NewSearchProvider}, nil } -// Get returns the settings; before any are saved every field is unset. +// Get returns the settings; before any are saved every field but +// DefaultBaseURL is unset. func (s *SearchSettingsService) Get(ctx context.Context) (SearchSettings, error) { row, err := db.New(s.st.Reader).GetRecipeSearchSettings(ctx) if errors.Is(err, sql.ErrNoRows) { - return SearchSettings{}, nil + return SearchSettings{DefaultBaseURL: s.defaultBase()}, nil } if err != nil { return SearchSettings{}, fmt.Errorf("recipes: read recipe search settings: %w", err) } - return searchSettingsFrom(row), nil + return s.settingsFrom(row), nil +} + +func (s *SearchSettingsService) defaultBase() *string { + if s.defaultBaseURL == "" { + return nil + } + u := s.defaultBaseURL + return &u } // Update applies patch and returns the settings it leaves. The first update @@ -141,7 +163,7 @@ func (s *SearchSettingsService) Update(ctx context.Context, patch SearchSettings if err != nil { return SearchSettings{}, err } - return searchSettingsFrom(saved), nil + return s.settingsFrom(saved), nil } // apply returns row with patch applied, validated. @@ -226,14 +248,15 @@ func (s *SearchSettingsService) TestSearch(ctx context.Context) (int, error) { return len(cands), nil } -func searchSettingsFrom(row db.RecipeSearchSetting) SearchSettings { +func (s *SearchSettingsService) settingsFrom(row db.RecipeSearchSetting) SearchSettings { provider, updated := row.Provider, row.UpdatedAt return SearchSettings{ - Provider: &provider, - BaseURL: nullString(row.BaseUrl), - APIKeySet: len(row.ApiKeyCt) > 0, - APIKeyLast4: nullString(row.ApiKeyLast4), - UpdatedAt: &updated, + Provider: &provider, + BaseURL: nullString(row.BaseUrl), + DefaultBaseURL: s.defaultBase(), + APIKeySet: len(row.ApiKeyCt) > 0, + APIKeyLast4: nullString(row.ApiKeyLast4), + UpdatedAt: &updated, } } diff --git a/internal/recipes/searchsettings_test.go b/internal/recipes/searchsettings_test.go index 0de2ad5..d2dcc66 100644 --- a/internal/recipes/searchsettings_test.go +++ b/internal/recipes/searchsettings_test.go @@ -19,6 +19,13 @@ import ( const testBraveKey = "BSA-secret-brave-key-9f3c" func newTestSearchSettings(t *testing.T) *SearchSettingsService { + t.Helper() + return newTestSearchSettingsWithDefault(t, "") +} + +// newTestSearchSettingsWithDefault is newTestSearchSettings with +// defaultBaseURL as the deployment's SearXNG URL. +func newTestSearchSettingsWithDefault(t *testing.T, defaultBaseURL string) *SearchSettingsService { t.Helper() st, err := store.Open(t.Context(), t.TempDir()) if err != nil { @@ -38,7 +45,7 @@ func newTestSearchSettings(t *testing.T) *SearchSettingsService { if err != nil { t.Fatal(err) } - svc, err := NewSearchSettingsService(st, box) + svc, err := NewSearchSettingsService(st, box, defaultBaseURL) if err != nil { t.Fatal(err) } @@ -124,6 +131,74 @@ func TestSearchSettingsUpdate(t *testing.T) { } } +func TestSearchSettingsDefaultBaseURL(t *testing.T) { + t.Parallel() + svc := newTestSearchSettingsWithDefault(t, "http://searxng:8080") + got, err := svc.Get(t.Context()) + if err != nil { + t.Fatal(err) + } + if got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" || got.BaseURL != nil || got.Provider != nil { + t.Errorf("Get before any update = %+v, want only default_base_url set", got) + } + if _, err := svc.Provider(t.Context()); !errors.Is(err, ErrSearchNotConfigured) { + t.Errorf("Provider with only a default error = %v, want ErrSearchNotConfigured", err) + } + + // The default is a suggestion: SearXNG still needs a saved URL. + if _, err := svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng")}); err == nil { + t.Error("choosing SearXNG without a URL succeeded with a default set") + } + + got, err = svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng"), BaseURL: ptr("https://search.example")}) + if err != nil { + t.Fatal(err) + } + if *got.BaseURL != "https://search.example" || got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" { + t.Errorf("Update result = %+v, want the saved URL beside the default", got) + } + got, err = svc.Get(t.Context()) + if err != nil { + t.Fatal(err) + } + if *got.BaseURL != "https://search.example" { + t.Errorf("Get base_url = %q, want the saved URL, not the default", *got.BaseURL) + } + row, err := db.New(svc.st.Reader).GetRecipeSearchSettings(t.Context()) + if err != nil { + t.Fatal(err) + } + if row.BaseUrl.String != "https://search.example" { + t.Errorf("stored base_url = %q", row.BaseUrl.String) + } +} + +func TestNewSearchSettingsServiceRejectsBadDefault(t *testing.T) { + t.Parallel() + st, err := store.Open(t.Context(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + key := make([]byte, secretbox.KeySize) + rand.Read(key) + box, err := secretbox.New(base64.StdEncoding.EncodeToString(key)) + if err != nil { + t.Fatal(err) + } + for _, u := range []string{"searxng:8080", "ftp://searxng", "http://user:pw@searxng:8080"} { + if _, err := NewSearchSettingsService(st, box, u); err == nil { + t.Errorf("NewSearchSettingsService(%q) succeeded", u) + } else if strings.Contains(err.Error(), "pw") { + t.Errorf("error for %q echoes the URL: %v", u, err) + } + } +} + func TestSearchSettingsTestSearch(t *testing.T) { t.Parallel() searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/aisettings.go b/internal/server/aisettings.go new file mode 100644 index 0000000..88db4fd --- /dev/null +++ b/internal/server/aisettings.go @@ -0,0 +1,145 @@ +package server + +import ( + "errors" + "log/slog" + "net/http" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" +) + +// apiKeyBody is the PUT /settings/ai/api-key body. An empty api_key removes +// the stored key. +type apiKeyBody struct { + APIKey *string `json:"api_key"` +} + +// modelsResponse is the GET /settings/ai/models body. +type modelsResponse struct { + Models []aisettings.Model `json:"models"` +} + +// No response from these handlers holds the API key: the settings carry +// only its last four characters, and provider errors and model lists are +// redacted by package aisettings. + +func getAISettings(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + settings, err := svc.Get(r.Context()) + if err != nil { + logger.ErrorContext(r.Context(), "get AI settings", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, settings) + } +} + +func patchAISettings(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + patch, err := decodePatch[aisettings.Patch](http.MaxBytesReader(w, r.Body, maxAISettingsBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + writeAISettings(w, r, logger, "update AI settings")(svc.Update(r.Context(), patch)) + } +} + +// putAIKey takes the API key in api_key; the response, like GET's, holds +// only its last four characters. +func putAIKey(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := decodePatch[apiKeyBody](http.MaxBytesReader(w, r.Body, maxSettingsBody)) + if err == nil && body.APIKey == nil { + err = errors.New("api_key: is required") + } + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + writeAISettings(w, r, logger, "store AI API key")(svc.SetAPIKey(r.Context(), *body.APIKey)) + } +} + +func postAIResetPrompt(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + writeAISettings(w, r, logger, "reset AI system prompt")(svc.ResetPrompt(r.Context())) + } +} + +// postAIVerify checks the stored key with the provider and returns the +// settings with key_verified_at, or the failure (see writeAIFailure). +func postAIVerify(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + settings, err := svc.Verify(r.Context()) + if err != nil { + writeAIFailure(w, r, logger, "verify AI key", err) + return + } + writeJSON(w, r, logger, http.StatusOK, settings) + } +} + +// getAIModels returns the provider's live model list, or the failure (see +// writeAIFailure). +func getAIModels(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + models, err := svc.Models(r.Context()) + if err != nil { + writeAIFailure(w, r, logger, "list AI models", err) + return + } + writeJSON(w, r, logger, http.StatusOK, modelsResponse{Models: models}) + } +} + +// writeAISettings returns a func writing a Service result: the settings, +// 400 invalid_settings for a *aisettings.ValidationError, or 500. +func writeAISettings(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string) func(aisettings.Settings, error) { + return func(settings aisettings.Settings, err error) { + var invalid *aisettings.ValidationError + switch { + case errors.As(err, &invalid): + writeError(w, r, logger, http.StatusBadRequest, "invalid_settings", invalid.Error()) + case err != nil: + logger.ErrorContext(r.Context(), action, "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + default: + writeJSON(w, r, logger, http.StatusOK, settings) + } + } +} + +// writeAIFailure writes a Verify or Models error: 409 not_configured when +// the settings cannot build a provider, 409 settings_changed when they +// changed during verification, and for a failed provider call a 502 (504 +// for a timeout) whose code names the llm typed error: auth_failed, +// rate_limited, timeout, bad_output or provider_error. Rate limits and +// timeouts are retryable. Anything else is 500. +func writeAIFailure(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) { + switch { + case errors.Is(err, aisettings.ErrNotConfigured): + writeError(w, r, logger, http.StatusConflict, "not_configured", err.Error()) + case errors.Is(err, aisettings.ErrChanged): + writeError(w, r, logger, http.StatusConflict, "settings_changed", err.Error()) + case errors.Is(err, aisettings.ErrProviderFailed): + logger.WarnContext(r.Context(), action+" failed", "error", err) + status, e := http.StatusBadGateway, apiError{Code: "provider_error", Message: err.Error()} + switch { + case errors.Is(err, llm.ErrAuth): + e.Code = "auth_failed" + case errors.Is(err, llm.ErrRateLimited): + e.Code, e.Retryable = "rate_limited", true + case errors.Is(err, llm.ErrTimeout): + status, e.Code, e.Retryable = http.StatusGatewayTimeout, "timeout", true + case errors.Is(err, llm.ErrBadOutput): + e.Code = "bad_output" + } + writeAPIError(w, r, logger, status, e) + default: + logger.ErrorContext(r.Context(), action, "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + } +} diff --git a/internal/server/aisettings_test.go b/internal/server/aisettings_test.go new file mode 100644 index 0000000..8bbd281 --- /dev/null +++ b/internal/server/aisettings_test.go @@ -0,0 +1,261 @@ +package server + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "testing/fstest" + "unicode/utf8" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" +) + +const ( + aiPath = "/api/v1/settings/ai" + aiKeyPath = "/api/v1/settings/ai/api-key" + aiVerifyPath = "/api/v1/settings/ai/verify" + aiModelsPath = "/api/v1/settings/ai/models" + aiResetPromptPath = "/api/v1/settings/ai/reset-prompt" + testAIKey = "sk-test-secret-ai-key-7d21" + testWrongAIKey = "sk-test-wrong-ai-key-0b9e" +) + +// newAIRouter returns a router with a fresh, seeded AI settings service. +func newAIRouter(t *testing.T) http.Handler { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), + newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + if err != nil { + t.Fatal(err) + } + return r +} + +// newModelsServer is an OpenAI-compatible server whose GET /models accepts +// only testAIKey. While echo is set it answers 500 with a body quoting the +// Authorization header it received, so a response that passed the vendor +// body through unredacted would leak the key. Its model list holds a model +// whose ID and name quote the key. +func newModelsServer(t *testing.T, echo *atomic.Bool) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + switch { + case r.URL.Path != "/v1/models": + http.NotFound(w, r) + case echo.Load(): + w.WriteHeader(http.StatusInternalServerError) + io.WriteString(w, `{"error":{"message":"upstream failed for `+auth+`"}}`) + case auth != "Bearer "+testAIKey: + w.WriteHeader(http.StatusUnauthorized) + io.WriteString(w, `{"error":{"message":"invalid key `+auth+`"}}`) + default: + io.WriteString(w, `{"data":[{"id":"gpt-test"},{"id":"gpt-test-mini","name":"GPT Test Mini"},`+ + `{"id":"gpt-`+testAIKey+`","name":"Leaky `+testAIKey+`"}]}`) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func errorCode(body map[string]any) any { + e, _ := body["error"].(map[string]any) + return e["code"] +} + +// TestAISettingsAPI drives every /settings/ai route and scans every +// response body for the API keys. +func TestAISettingsAPI(t *testing.T) { + t.Parallel() + router := newAIRouter(t) + var echo atomic.Bool + vendor := newModelsServer(t, &echo) + var bodies []string + call := func(method, target, body string) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + var r io.Reader + if body != "" { + r = strings.NewReader(body) + } + rec, decoded := serve(t, router, signedIn(method, target, r)) + bodies = append(bodies, rec.Body.String()) + return rec, decoded + } + + // The seed: anthropic, the default persona, no key. + rec, body := call(http.MethodGet, aiPath, "") + if rec.Code != http.StatusOK || body["provider"] != "anthropic" || body["system_prompt"] != household.DefaultPersonaPrompt || + body["temperature"] != 0.7 || body["api_key_last4"] != nil || body["key_verified_at"] != nil { + t.Errorf("GET seed = %d %v", rec.Code, body) + } + for _, field := range []string{"provider", "base_url", "model", "temperature", "system_prompt", "api_key_last4", "key_verified_at"} { + if _, ok := body[field]; !ok { + t.Errorf("GET has no %s field: %v", field, body) + } + } + for _, path := range []string{aiVerifyPath, aiModelsPath} { + method := http.MethodPost + if path == aiModelsPath { + method = http.MethodGet + } + if rec, body := call(method, path, ""); rec.Code != http.StatusConflict || errorCode(body) != "not_configured" { + t.Errorf("%s %s without a key = %d %v, want 409 not_configured", method, path, rec.Code, body) + } + } + + for _, bad := range []string{ + `{}`, + `{"provider":"google"}`, + `{"provider":"openai_compatible","base_url":"not a url"}`, + `{"base_url":"https://evil.example"}`, + `{"temperature":1.1}`, + `{"temperature":-0.1}`, + `{"temperature":0.25}`, + `{"temperature":"0.5"}`, + `{"model":""}`, + `{"model":null}`, + `{"system_prompt":"` + strings.Repeat("é", 8001) + `"}`, + `{"system_prompt":"` + strings.Repeat("€", 8001) + `"}`, + `{"system_prompt":"` + strings.Repeat("a", maxAISettingsBody) + `"}`, + `{"api_key":"` + testAIKey + `"}`, + } { + if rec, body := call(http.MethodPatch, aiPath, bad); rec.Code != http.StatusBadRequest { + t.Errorf("PATCH %.80s = %d %v, want 400", bad, rec.Code, body) + } + } + + // The longest prompt fits the body limit however its 8,000 characters + // are written: three-byte, four-byte, or as \u-escaped surrogate pairs. + for _, char := range []string{"€", "🍲", `\ud83c\udf72`} { + rec, body := call(http.MethodPatch, aiPath, `{"system_prompt":"`+strings.Repeat(char, 8000)+`"}`) + if prompt, _ := body["system_prompt"].(string); rec.Code != http.StatusOK || utf8.RuneCountInString(prompt) != 8000 { + t.Errorf("PATCH 8,000 × %s = %d, %d characters", char, rec.Code, utf8.RuneCountInString(prompt)) + } + } + + rec, body = call(http.MethodPatch, aiPath, `{"provider":"openai_compatible","base_url":"`+vendor.URL+`/v1","model":"gpt-test","temperature":0.3,"system_prompt":"`+strings.Repeat("é", 8000)+`"}`) + if rec.Code != http.StatusOK || body["provider"] != "openai_compatible" || body["base_url"] != vendor.URL+"/v1" || + body["model"] != "gpt-test" || body["temperature"] != 0.3 { + t.Errorf("PATCH openai_compatible = %d %v", rec.Code, body) + } + + for _, bad := range []string{`{}`, `{"api_key":"short"}`, `{"api_key":"sk-line\nbreak"}`, `{"api_key":null}`, `{"key":"` + testAIKey + `"}`} { + if rec, body := call(http.MethodPut, aiKeyPath, bad); rec.Code != http.StatusBadRequest { + t.Errorf("PUT api-key %s = %d %v, want 400", bad, rec.Code, body) + } + } + + // A rejected key is a typed auth failure and records nothing. + rec, body = call(http.MethodPut, aiKeyPath, `{"api_key":"`+testWrongAIKey+`"}`) + if rec.Code != http.StatusOK || body["api_key_last4"] != "0b9e" || body["key_verified_at"] != nil { + t.Errorf("PUT wrong key = %d %v", rec.Code, body) + } + if rec, body := call(http.MethodPost, aiVerifyPath, ""); rec.Code != http.StatusBadGateway || errorCode(body) != "auth_failed" { + t.Errorf("POST verify with the wrong key = %d %v, want 502 auth_failed", rec.Code, body) + } + if rec, body := call(http.MethodGet, aiModelsPath, ""); rec.Code != http.StatusBadGateway || errorCode(body) != "auth_failed" { + t.Errorf("GET models with the wrong key = %d %v, want 502 auth_failed", rec.Code, body) + } + + rec, body = call(http.MethodPut, aiKeyPath, `{"api_key":"`+testAIKey+`"}`) + if rec.Code != http.StatusOK || body["api_key_last4"] != "7d21" { + t.Errorf("PUT key = %d %v", rec.Code, body) + } + if _, ok := body["api_key"]; ok { + t.Errorf("PUT response has an api_key field: %v", body) + } + + // The vendor quoting the key in an error body: the body excerpt + // reaches the response redacted. + echo.Store(true) + for _, c := range []struct{ method, path string }{{http.MethodPost, aiVerifyPath}, {http.MethodGet, aiModelsPath}} { + rec, body := call(c.method, c.path, "") + if rec.Code != http.StatusBadGateway || errorCode(body) != "provider_error" || !strings.Contains(rec.Body.String(), "upstream failed for Bearer [REDACTED]") { + t.Errorf("%s %s against an echoing vendor = %d %v, want 502 provider_error", c.method, c.path, rec.Code, body) + } + } + echo.Store(false) + + rec, body = call(http.MethodPost, aiVerifyPath, "") + if rec.Code != http.StatusOK || body["key_verified_at"] == nil || body["api_key_last4"] != "7d21" { + t.Errorf("POST verify = %d %v", rec.Code, body) + } + rec, body = call(http.MethodGet, aiPath, "") + if rec.Code != http.StatusOK || body["key_verified_at"] == nil || body["model"] != "gpt-test" || body["temperature"] != 0.3 { + t.Errorf("GET after verify = %d %v", rec.Code, body) + } + + rec, body = call(http.MethodGet, aiModelsPath, "") + models, _ := body["models"].([]any) + if rec.Code != http.StatusOK || len(models) != 3 { + t.Fatalf("GET models = %d %v", rec.Code, body) + } + if m := models[1].(map[string]any); m["id"] != "gpt-test-mini" || m["display_name"] != "GPT Test Mini" { + t.Errorf("GET models[1] = %v", m) + } + if m := models[2].(map[string]any); m["id"] != "gpt-[REDACTED]" || m["display_name"] != "Leaky [REDACTED]" { + t.Errorf("GET models[2] = %v, want the key redacted", m) + } + + rec, body = call(http.MethodPatch, aiPath, `{"system_prompt":"Be brief."}`) + if rec.Code != http.StatusOK || body["system_prompt"] != "Be brief." || body["key_verified_at"] == nil { + t.Errorf("PATCH system_prompt = %d %v", rec.Code, body) + } + rec, body = call(http.MethodPost, aiResetPromptPath, "") + if rec.Code != http.StatusOK || body["system_prompt"] != household.DefaultPersonaPrompt { + t.Errorf("POST reset-prompt = %d %v", rec.Code, body) + } + + // The stored key is never pointed at another server or provider. + for _, patch := range []string{`{"base_url":"https://attacker.example/v1"}`, `{"provider":"openai","base_url":""}`} { + if rec, body := call(http.MethodPatch, aiPath, patch); rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_settings" { + t.Errorf("PATCH %s with a key stored = %d %v, want 400 invalid_settings", patch, rec.Code, body) + } + } + if rec, body := call(http.MethodPut, aiKeyPath, `{"api_key":""}`); rec.Code != http.StatusOK || body["api_key_last4"] != nil || body["key_verified_at"] != nil { + t.Errorf("PUT empty key = %d %v", rec.Code, body) + } + rec, body = call(http.MethodPatch, aiPath, `{"base_url":"https://llm.example/v1"}`) + if rec.Code != http.StatusOK || body["base_url"] != "https://llm.example/v1" { + t.Errorf("PATCH base_url without a key = %d %v", rec.Code, body) + } + + if len(bodies) < 30 { + t.Fatalf("scanned only %d response bodies", len(bodies)) + } + for _, b := range bodies { + for _, key := range []string{testAIKey, testWrongAIKey} { + if strings.Contains(b, key) { + t.Errorf("response holds an API key: %s", b) + } + } + } +} + +func TestAISettingsWritesNeedCSRF(t *testing.T) { + t.Parallel() + router := newAIRouter(t) + for _, c := range []struct{ method, path, body string }{ + {http.MethodPatch, aiPath, `{"temperature":0.5}`}, + {http.MethodPut, aiKeyPath, `{"api_key":"` + testAIKey + `"}`}, + {http.MethodPost, aiVerifyPath, ""}, + {http.MethodPost, aiResetPromptPath, ""}, + } { + req := httptest.NewRequest(c.method, c.path, strings.NewReader(c.body)) + req.AddCookie(validSessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("%s %s without the CSRF header = %d, want 403", c.method, c.path, rec.Code) + } + } + rec, body := serve(t, router, signedIn(http.MethodGet, aiPath, nil)) + if rec.Code != http.StatusOK || body["api_key_last4"] != nil || body["temperature"] != 0.7 { + t.Errorf("GET after refused writes = %d %v, want the seed", rec.Code, body) + } +} diff --git a/internal/server/login_test.go b/internal/server/login_test.go index 29f6257..d7fa939 100644 --- a/internal/server/login_test.go +++ b/internal/server/login_test.go @@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler { t.Fatal(err) } r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t), - newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/media_test.go b/internal/server/media_test.go index fab7903..c464737 100644 --- a/internal/server/media_test.go +++ b/internal/server/media_test.go @@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestRunner(t), + r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestRunner(t), photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index 19f5022..cf52a47 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler { } logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil))) r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t), - newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/recipesearch_test.go b/internal/server/recipesearch_test.go index 384753d..09ec29e 100644 --- a/internal/server/recipesearch_test.go +++ b/internal/server/recipesearch_test.go @@ -22,7 +22,7 @@ const ( func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestRunner(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) @@ -46,7 +46,7 @@ func TestRecipeSearchSettingsAPI(t *testing.T) { } rec, body := call(http.MethodGet, recipeSearchPath, "") - if rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false { + if def, ok := body["default_base_url"]; rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false || !ok || def != nil { t.Errorf("GET unconfigured = %d %v", rec.Code, body) } rec, body = call(http.MethodPost, recipeSearchTestPath, "") diff --git a/internal/server/server.go b/internal/server/server.go index 84dc8ff..b32d9ca 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -10,6 +10,7 @@ import ( "github.com/go-chi/chi/v5" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" @@ -31,18 +32,23 @@ type RouteMounter interface { // only from a peer in trusted (see resolveOrigin). version is reported by // GET /health; auth mounts the /auth routes; sessions guards /api/v1 and // /media; settings serves /api/v1/settings/household; search serves -// /api/v1/settings/recipe-search and its /test; runner serves +// /api/v1/settings/recipe-search and its /test; ai serves +// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt; +// runner serves // POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves // GET /media/photos/{id}-{size}.jpg; assets is the web build // served for every other GET. /health, /auth and the web build are public. // Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader. -func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { +func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { if settings == nil { return nil, errors.New("server: nil household settings service") } if search == nil { return nil, errors.New("server: nil recipe search settings service") } + if ai == nil { + return nil, errors.New("server: nil AI settings service") + } if runner == nil { return nil, errors.New("server: nil job runner") } @@ -80,6 +86,12 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Get("/settings/recipe-search", getRecipeSearchSettings(logger, search)) apiV1.Patch("/settings/recipe-search", patchRecipeSearchSettings(logger, search)) apiV1.Post("/settings/recipe-search/test", postRecipeSearchTest(logger, search)) + apiV1.Get("/settings/ai", getAISettings(logger, ai)) + apiV1.Patch("/settings/ai", patchAISettings(logger, ai)) + apiV1.Put("/settings/ai/api-key", putAIKey(logger, ai)) + apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai)) + apiV1.Get("/settings/ai/models", getAIModels(logger, ai)) + apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai)) apiV1.Post("/jobs", postJob(logger, runner)) apiV1.Get("/jobs/{id}", getJob(logger, runner)) media.Get("/photos/{name}", getPhoto(logger, photos)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 4efead6..1704af7 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -16,6 +16,7 @@ import ( "github.com/go-chi/chi/v5" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" @@ -42,7 +43,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting "index.html": {Data: []byte(fixtureIndex)}, "assets/index-AbC123.js": {Data: []byte(fixtureAsset)}, } - r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), runner, newTestPhotos(t), assets) + r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), runner, newTestPhotos(t), assets) if err != nil { t.Fatal(err) } @@ -131,24 +132,54 @@ func newTestPhotos(t *testing.T) *recipes.PhotoCache { // configured, over a migrated temp database and a random secret box. func newTestSearch(t *testing.T) *recipes.SearchSettingsService { t.Helper() - key := make([]byte, secretbox.KeySize) - rand.Read(key) - box, err := secretbox.New(base64.StdEncoding.EncodeToString(key)) - if err != nil { - t.Fatal(err) - } - search, err := recipes.NewSearchSettingsService(newTestStore(t), box) + search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t), "") if err != nil { t.Fatal(err) } return search } +// newTestBox returns a secret box under a random key. +func newTestBox(t *testing.T) *secretbox.Box { + t.Helper() + key := make([]byte, secretbox.KeySize) + rand.Read(key) + box, err := secretbox.New(base64.StdEncoding.EncodeToString(key)) + if err != nil { + t.Fatal(err) + } + return box +} + +// newTestAI returns an AI settings service over a migrated temp database +// with the seed AI settings and a random secret box. +func newTestAI(t *testing.T) *aisettings.Service { + t.Helper() + st := newTestStore(t) + if err := household.SeedAISettings(context.Background(), st); err != nil { + t.Fatal(err) + } + ai, err := aisettings.New(st, newTestBox(t), "https://mealquest.example") + if err != nil { + t.Fatal(err) + } + return ai +} + +func TestNewRouterRequiresAISettings(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + t.Fatal("NewRouter accepted a nil AI settings service") + } +} + func TestNewRouterRequiresSearchSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil recipe search settings service") } } @@ -157,7 +188,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted nil photo files") } } @@ -166,7 +197,7 @@ func TestNewRouterRequiresSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil settings service") } } @@ -175,7 +206,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil job runner") } } diff --git a/internal/server/settings.go b/internal/server/settings.go index b0d3b04..489d092 100644 --- a/internal/server/settings.go +++ b/internal/server/settings.go @@ -9,14 +9,21 @@ import ( "log/slog" "net/http" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" ) // maxSettingsBody bounds a PATCH /settings/household or -// /settings/recipe-search request body. +// /settings/recipe-search, or PUT /settings/ai/api-key, request body. const maxSettingsBody = 16 << 10 +// maxAISettingsBody bounds a PATCH /settings/ai request body. It fits the +// longest valid system prompt at 12 bytes of JSON per character (a +// \u-escaped surrogate pair) on top of maxSettingsBody for the other +// fields, so only aisettings.MaxSystemPromptChars limits the prompt. +const maxAISettingsBody = maxSettingsBody + 12*aisettings.MaxSystemPromptChars + // settingsPatch is the PATCH /settings/household body. Every field is // optional; unknown fields and null values are refused. type settingsPatch struct { @@ -124,13 +131,18 @@ func writeJSON(w http.ResponseWriter, r *http.Request, logger *slog.Logger, stat // writeError responds with status and a non-retryable API error. func writeError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, code, message string) { - body, err := json.Marshal(errorBody{Error: apiError{Code: code, Message: message}}) + writeAPIError(w, r, logger, status, apiError{Code: code, Message: message}) +} + +// writeAPIError responds with status and e. +func writeAPIError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, e apiError) { + body, err := json.Marshal(errorBody{Error: e}) if err != nil { panic(err) // string and bool fields cannot fail to marshal } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if _, err := w.Write(body); err != nil { - logger.WarnContext(r.Context(), "write error response", "code", code, "error", err) + logger.WarnContext(r.Context(), "write error response", "code", e.Code, "error", err) } } diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index e3fb3fb..f0072fe 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -216,7 +216,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) { } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st), - newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 8626068..82d8105 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,11 +1,12 @@ import { act, render, screen } from '@testing-library/react' -import { MemoryRouter } from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createApiClient, createVerifiedSession } from './api/index.ts' import { createNetworkStatus } from './pwa/networkStatus.ts' import { mockViewport } from './test/matchMedia.ts' -import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts' +import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts' import App, { routes } from './App.tsx' +import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx' +import { InDataRouter } from './test/dataRouter.tsx' afterEach(() => { Reflect.deleteProperty(window, 'matchMedia') @@ -19,12 +20,15 @@ function renderAt(path: string, location = fakeLocation(path)) { function renderAppAt(path: string, location = fakeLocation(path)) { return render( - + - , + , ) } +// The routes that render a built screen rather than a placeholder page. +const SCREENS = { '/settings': true, '/settings/ai': true, '/settings/household': true } + describe('App routes', () => { it('registers every handoff route plus the extra settings pages', () => { expect(routes.map((route) => route.path)).toEqual([ @@ -46,7 +50,7 @@ describe('App routes', () => { ]) }) - it.each(routes.map(({ path, title }) => [path.replace(':id', 'abc123'), title]))( + it.each(routes.filter(({ path }) => !Object.hasOwn(SCREENS, path)).map(({ path, title }) => [path.replace(':id', 'abc123'), title]))( '%s renders its placeholder page inside the shell', async (path, title) => { mockViewport(390) @@ -57,6 +61,44 @@ describe('App routes', () => { expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument() }, ) + + it('/settings renders the settings home inside the shell', async () => { + mockViewport(390) + stubFetchByPath({ + '/api/v1/me': () => jsonResponse(200, ME), + ...SETTINGS_API, + }) + renderAt('/settings') + const heading = await screen.findByRole('heading', { level: 1, name: 'Settings' }) + expect(screen.getByRole('main')).toContainElement(heading) + expect(await screen.findByText('Anthropic · Claude Sonnet')).toBeInTheDocument() + }) + + it('/settings/ai renders the AI assistant screen inside the shell', async () => { + mockViewport(390) + stubFetchByPath({ + '/api/v1/me': () => jsonResponse(200, ME), + '/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), + '/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }), + '/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS), + }) + renderAt('/settings/ai') + const heading = await screen.findByRole('heading', { level: 1, name: 'AI assistant' }) + expect(screen.getByRole('main')).toContainElement(heading) + expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument() + }) + + it('/settings/household renders the household settings screen inside the shell', async () => { + mockViewport(390) + stubFetchByPath({ + '/api/v1/me': () => jsonResponse(200, ME), + '/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), + }) + renderAt('/settings/household') + const heading = await screen.findByRole('heading', { level: 1, name: 'Household' }) + expect(screen.getByRole('main')).toContainElement(heading) + expect(await screen.findByLabelText('Dinner time')).toHaveValue('17:30') + }) }) describe('App bootstrap', () => { @@ -177,9 +219,9 @@ describe('App offline bootstrap', () => { goOffline() const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) render( - + - , + , ) expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/) diff --git a/web/src/App.tsx b/web/src/App.tsx index 687c59f..8c8e2ef 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, type ReactNode } from 'react' import { QueryClientProvider } from '@tanstack/react-query' import { Route, Routes } from 'react-router' import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts' @@ -6,6 +6,9 @@ import { AuthBootstrap } from './AuthBootstrap.tsx' import { PlaceholderPage } from './pages/PlaceholderPage.tsx' import { OfflinePill } from './pwa/OfflinePill.tsx' import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx' +import { AISettingsPage } from './settings/AISettingsPage.tsx' +import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx' +import { SettingsPage } from './settings/SettingsPage.tsx' import { AppShell } from './shell/AppShell.tsx' // Every route the app serves, with the heading its placeholder page shows. @@ -27,13 +30,20 @@ export const routes = [ { path: '/settings/calendar', title: 'Calendar' }, ] as const +// The screens built so far; every other route shows its placeholder page. +const screens: Partial> = { + '/settings': , + '/settings/ai': , + '/settings/household': , +} + // AppRoutes is the shell and its pages, shown once /api/v1/me has loaded. export function AppRoutes() { return ( }> {routes.map(({ path, title }) => ( - } /> + } /> ))} @@ -50,8 +60,9 @@ function reloadPage() { window.location.reload() } -// App expects to be rendered inside a router (BrowserRouter in main.tsx, -// MemoryRouter in tests). +// App expects to be rendered by a data router's splat route +// (createBrowserRouter in main.tsx, createMemoryRouter in tests), so pages can +// block navigation. export default function App({ api, serviceWorker }: AppProps) { const [queryClient] = useState(createQueryClient) return ( diff --git a/web/src/main.tsx b/web/src/main.tsx index 63cd1b1..10ddcca 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,6 +1,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { BrowserRouter } from 'react-router' +import { createBrowserRouter, RouterProvider } from 'react-router' import './styles/tokens.css' import './styles/base.css' import App from './App.tsx' @@ -14,10 +14,17 @@ if (!root) { const api = createApiClient(window.location, createNetworkStatus(window), createVerifiedSession(window.localStorage)) +// A data router, so pages can block navigation (useBlocker); App's own +// match every path under its splat route. +const router = createBrowserRouter([ + { + path: '*', + element: , + }, +]) + createRoot(root).render( - - - + , ) diff --git a/web/src/settings/AISettingsPage.module.css b/web/src/settings/AISettingsPage.module.css new file mode 100644 index 0000000..e18f2f3 --- /dev/null +++ b/web/src/settings/AISettingsPage.module.css @@ -0,0 +1,313 @@ +/* Admin · AI assistant, handoff mock 1o. */ +.page { + box-sizing: border-box; + min-height: 100%; + max-width: 560px; + display: flex; + flex-direction: column; + gap: 18px; + padding-top: 6px; +} + +.header { + display: flex; + align-items: center; + gap: 16px; +} + +.kicker { + margin: 0; + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-accent-700); +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 28px; + font-weight: 400; +} + +.loading { + height: 320px; + border-radius: 26px; +} + +.columns { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 10px; +} + +.model { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.label { + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.mono { + font-family: var(--font-mono); +} + +.hint, +.note, +.verified, +.error, +.status { + margin: 0; + font-family: var(--font-body); + font-size: 13px; + line-height: 1.4; +} + +.hint, +.note { + color: var(--color-neutral-700); +} + +.verified { + font-weight: 700; + color: var(--color-accent-2-800); +} + +.error { + font-weight: 700; + color: var(--color-accent-700); +} + +.status { + color: var(--color-accent-2-800); +} + +.status:empty { + display: none; +} + +.key { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.keyRow { + display: flex; + align-items: center; + gap: 10px; + min-height: 56px; + padding: 0 6px 0 20px; + border: 1px solid var(--color-neutral-300); + border-radius: var(--radius-pill); + background: var(--color-surface); +} + +.keyValue { + flex: 1; + min-width: 0; + overflow: hidden; + font-family: var(--font-mono); + font-size: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.replace { + flex: none; + min-width: 44px; + min-height: 44px; + padding: 0 18px; + border: 0; + border-radius: var(--radius-pill); + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + cursor: pointer; +} + +.replace:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.keyStatus { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0 8px; +} + +.link { + min-width: 44px; + min-height: 44px; + padding: 0 4px; + border: 0; + background: transparent; + color: var(--color-accent-700); + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; + text-decoration: underline; + cursor: pointer; +} + +.temperature { + display: flex; + flex-direction: column; +} + +.temperatureHeader { + display: flex; + align-items: baseline; + justify-content: space-between; +} + +.value { + font-family: var(--font-body); + font-size: 15px; + font-weight: 700; +} + +.ends { + display: flex; + justify-content: space-between; + font-family: var(--font-body); + font-size: 12px; + color: var(--color-neutral-700); +} + +.prompt { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.textarea { + box-sizing: border-box; + width: 100%; + min-height: 150px; + padding: 14px 18px; + border: 1px solid var(--color-neutral-300); + border-radius: 22px; + background: var(--color-surface); + color: var(--color-text); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; + resize: vertical; +} + +.actions { + /* Mock 1o anchors Reset and Save changes at the bottom of the screen. */ + margin-top: auto; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); + gap: 10px; + padding-top: 6px; +} + +.reset, +.save { + min-height: 52px; +} + +.sheet { + display: flex; + flex-direction: column; + gap: 14px; + max-width: 560px; + margin: 0 auto; +} + +.sheetTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; +} + +.sheetText { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.45; + color: var(--color-neutral-700); +} + +.sheetActions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +/* Recipe search card, below the AI assistant's actions. */ +.card { + display: flex; + flex-direction: column; + gap: 14px; + border: 1px solid var(--color-neutral-300); +} + +.cardTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; +} + +.cardLoading { + height: 160px; + border-radius: var(--radius-lg); +} + +.cardActions { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); + gap: 10px; +} + +.cardActions > button { + min-height: 52px; +} + +.cardHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +/* AI assistant card on the desktop settings (mock 2e): the card grows to + fill its column and the system prompt grows to fill the card. */ +.fill { + flex: 1; + gap: 18px; +} + +.fill .prompt, +.fill .textarea { + flex: 1; +} + +.fill .textarea { + resize: none; +} + +/* A form locked while its save or prompt reset is pending, so no edit is + made that the response would then replace. It lays out as if it were not + there. */ +.lock { + display: contents; +} diff --git a/web/src/settings/AISettingsPage.test.tsx b/web/src/settings/AISettingsPage.test.tsx new file mode 100644 index 0000000..b0803a5 --- /dev/null +++ b/web/src/settings/AISettingsPage.test.tsx @@ -0,0 +1,782 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { Route, Routes, useLocation } from 'react-router' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { AI_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx' +import { InDataRouter } from '../test/dataRouter.tsx' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { expectMinHitArea } from '../test/hitArea.ts' +import { AISettingsPage, formatVerifiedDate } from './AISettingsPage.tsx' +import { maskedKey } from './api.ts' + +const MODELS = { + models: [ + { id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }, + { id: 'claude-haiku-4-5', display_name: 'Claude Haiku' }, + ], +} + +const DEFAULT_PROMPT = 'You are MealQuest, the default persona.' + +type Responder = (body: unknown) => Response | Promise + +// stubAPI answers "METHOD path" from responders, each called with the +// request's decoded JSON body; GET /settings/ai and /settings/ai/models +// answer AI_SETTINGS and MODELS unless overridden. Any other request fails +// the test. +function stubAPI(responders: Record = {}): Mock { + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + 'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), + 'GET /api/v1/settings/ai/models': () => jsonResponse(200, MODELS), + 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS), + ...responders, + } + const fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + const respond = Object.hasOwn(all, key) ? all[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined + return Promise.resolve(respond(body)) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// sent lists the requests other than GETs, as "METHOD path" and body. +function sent(fetchMock: Mock) { + return fetchMock.mock.calls + .filter(([, init]) => (init?.method ?? 'GET') !== 'GET') + .map(([input, init]) => ({ + request: `${init?.method} ${String(input)}`, + body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined, + })) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function CurrentPath() { + return

{useLocation().pathname}

+} + +// renderPage loads /me (for the CSRF token every write needs) and renders +// the page at /settings/ai in a data router, so its leave guard works. +async function renderPage() { + const api = createApiClient(fakeLocation('/settings/ai'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + render( + + + + + } /> + } /> + + + + , + ) + await screen.findByRole('option', { name: 'Claude Sonnet' }) +} + +const provider = () => screen.getByRole('combobox', { name: 'Provider' }) +const model = () => screen.getByRole('combobox', { name: 'Model' }) +const prompt = () => screen.getByRole('textbox', { name: 'System prompt' }) +const slider = () => screen.getByRole('slider', { name: 'Creativity (temperature)' }) +const save = () => screen.getByRole('button', { name: 'Save changes' }) + +describe('AISettingsPage', () => { + it('loads the settings as mock 1o lays them out', async () => { + stubAPI() + await renderPage() + expect(screen.getByRole('button', { name: 'Back to Settings' })).toBeInTheDocument() + expect(screen.getByText('Settings · Admin')).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: 'AI assistant' })).toBeInTheDocument() + expect(provider()).toHaveValue('anthropic') + expect(within(provider()).getByRole('option', { selected: true })).toHaveTextContent('Anthropic') + expect(model()).toHaveValue('claude-sonnet-4-5') + expect(within(model()).getAllByRole('option').map((o) => o.textContent)).toEqual([ + 'Claude Sonnet', + 'Claude Haiku', + 'Type a model id', + ]) + expect(screen.getByText('••••••••3f9a')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Replace' })).toBeInTheDocument() + expect(screen.getByText('Verified · Oct 1')).toBeInTheDocument() + expect(slider()).toHaveValue('0.7') + expect(slider()).toHaveAttribute('step', '0.1') + expect(screen.getByText('0.7')).toBeInTheDocument() + expect(screen.getByText('Familiar')).toBeInTheDocument() + expect(screen.getByText('Adventurous')).toBeInTheDocument() + expect(prompt()).toHaveValue(AI_SETTINGS.system_prompt) + expect(screen.getByRole('button', { name: 'Reset' })).toBeEnabled() + expect(save()).toBeDisabled() + }) + + it('reports a load failure with a retry', async () => { + stubAPI({ 'GET /api/v1/settings/ai': () => errorResponse(500, 'internal') }) + const api = createApiClient(fakeLocation('/settings/ai'), createNetworkStatus(window), createVerifiedSession(localStorage)) + render( + + + + + + + , + ) + expect(await screen.findByRole('alert')).toHaveTextContent('AI settings could not load: internal message') + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + }) + + it('saves only the edited fields', async () => { + const fetchMock = stubAPI({ + 'PATCH /api/v1/settings/ai': (body) => jsonResponse(200, { ...AI_SETTINGS, ...(body as object) }), + }) + await renderPage() + fireEvent.change(model(), { target: { value: 'claude-haiku-4-5' } }) + fireEvent.change(slider(), { target: { value: '0.4' } }) + expect(screen.getByText('0.4')).toBeInTheDocument() + fireEvent.change(prompt(), { target: { value: 'Keep it cozy.' } }) + expect(save()).toBeEnabled() + fireEvent.click(save()) + + expect(await screen.findByText('Changes saved.')).toBeInTheDocument() + expect(sent(fetchMock)).toEqual([ + { + request: 'PATCH /api/v1/settings/ai', + body: { model: 'claude-haiku-4-5', temperature: 0.4, system_prompt: 'Keep it cozy.' }, + }, + ]) + expect(save()).toBeDisabled() + expect(model()).toHaveValue('claude-haiku-4-5') + }) + + it('saves a typed model id', async () => { + const fetchMock = stubAPI({ + 'PATCH /api/v1/settings/ai': (body) => jsonResponse(200, { ...AI_SETTINGS, ...(body as object) }), + }) + await renderPage() + expect(screen.queryByRole('textbox', { name: 'Model id' })).not.toBeInTheDocument() + fireEvent.change(model(), { target: { value: within(model()).getByRole('option', { name: 'Type a model id' }).getAttribute('value') } }) + fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'claude-opus-5-5' } }) + fireEvent.click(save()) + await screen.findByText('Changes saved.') + expect(sent(fetchMock)).toEqual([{ request: 'PATCH /api/v1/settings/ai', body: { model: 'claude-opus-5-5' } }]) + }) + + it('explains that a stored key must be removed before switching provider', async () => { + stubAPI({ + 'PATCH /api/v1/settings/ai': () => + jsonResponse(400, { + error: { + code: 'invalid_settings', + message: 'invalid AI settings: provider: cannot change while an API key is stored: remove the key first', + retryable: false, + }, + }), + }) + await renderPage() + fireEvent.change(provider(), { target: { value: 'openai' } }) + expect(screen.getByText(/An API key is stored\. Remove it \(Replace → Remove key\)/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Replace' })).toBeEnabled() + fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'gpt-5' } }) + fireEvent.click(save()) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent('Not saved: invalid AI settings: provider: cannot change while an API key is stored') + expect(alert).toHaveTextContent('Remove it (Replace → Remove key) before changing the provider or base URL.') + }) + + it('gives the same guidance when a stored key locks the base URL', async () => { + stubAPI({ + 'GET /api/v1/settings/ai': () => + jsonResponse(200, { ...AI_SETTINGS, provider: 'openai_compatible', base_url: 'https://llm.example.com/v1' }), + 'PATCH /api/v1/settings/ai': () => + jsonResponse(400, { + error: { + code: 'invalid_settings', + message: 'invalid AI settings: base_url: cannot change while an API key is stored: remove the key first', + retryable: false, + }, + }), + }) + await renderPage() + fireEvent.change(screen.getByRole('textbox', { name: 'Base URL' }), { target: { value: 'https://other.example.com/v1' } }) + expect(screen.getByText(/An API key is stored\. Remove it/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Replace' })).toBeEnabled() + fireEvent.click(save()) + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Remove it (Replace → Remove key) before changing the provider or base URL.', + ) + }) + + it('recovers a provider switch by removing the stored key, without undoing the switch', async () => { + const fetchMock = stubAPI({ + 'PUT /api/v1/settings/ai/api-key': () => + jsonResponse(200, { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null }), + 'PATCH /api/v1/settings/ai': (body) => + jsonResponse(200, { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null, ...(body as object) }), + }) + await renderPage() + fireEvent.change(provider(), { target: { value: 'ollama' } }) + fireEvent.change(screen.getByRole('textbox', { name: 'Base URL' }), { target: { value: 'http://localhost:11434' } }) + fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'llama3.2' } }) + // The stored key stays in view, and removable, while Ollama is chosen. + expect(screen.getByText('••••••••3f9a')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Replace' })) + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove key' })) + + expect(await screen.findByText('Ollama takes no API key.')).toBeInTheDocument() + expect(screen.queryByText(/An API key is stored/)).not.toBeInTheDocument() + expect(provider()).toHaveValue('ollama') + fireEvent.click(save()) + expect(await screen.findByText('Changes saved.')).toBeInTheDocument() + expect(sent(fetchMock)).toEqual([ + { request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: '' } }, + { + request: 'PATCH /api/v1/settings/ai', + body: { provider: 'ollama', base_url: 'http://localhost:11434', model: 'llama3.2' }, + }, + ]) + }) + + it('replaces the key in a sheet, then verifies it', async () => { + const verifiedAt = '2026-10-06T14:00:00.000Z' + const fetchMock = stubAPI({ + 'PUT /api/v1/settings/ai/api-key': () => + jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: null }), + 'POST /api/v1/settings/ai/verify': () => + jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: verifiedAt }), + }) + await renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Replace' })) + const sheet = screen.getByRole('dialog', { name: 'Replace API key' }) + const input = within(sheet).getByLabelText('API key') + expect(input).toHaveAttribute('type', 'password') + fireEvent.change(input, { target: { value: 'sk-ant-secret-b7c1' } }) + fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' })) + + expect(await screen.findByText(`Verified · ${formatVerifiedDate(verifiedAt)}`)).toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.getByText('••••••••b7c1')).toBeInTheDocument() + expect(sent(fetchMock)).toEqual([ + { request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: 'sk-ant-secret-b7c1' } }, + { request: 'POST /api/v1/settings/ai/verify', body: undefined }, + ]) + expect(document.body).not.toHaveTextContent('sk-ant-secret-b7c1') + }) + + it('still verifies the key when the sheet is dismissed while it is being stored', async () => { + let answerPut: (response: Response) => void = () => { + throw new Error('PUT not sent') + } + const fetchMock = stubAPI({ + 'PUT /api/v1/settings/ai/api-key': () => new Promise((resolve) => (answerPut = resolve)), + 'POST /api/v1/settings/ai/verify': () => + jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: '2026-10-06T14:00:00.000Z' }), + }) + await renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Replace' })) + const sheet = screen.getByRole('dialog', { name: 'Replace API key' }) + fireEvent.change(within(sheet).getByLabelText('API key'), { target: { value: 'sk-ant-secret-b7c1' } }) + fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' })) + await waitFor(() => expect(within(sheet).getByRole('button', { name: 'Saving…' })).toBeDisabled()) + fireEvent.keyDown(sheet, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Saving…' })).toBeDisabled() + + act(() => answerPut(jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: null }))) + expect(await screen.findByText('Verified · Oct 6')).toBeInTheDocument() + expect(screen.getByText('••••••••b7c1')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Replace' })).toBeEnabled() + expect(sent(fetchMock)).toEqual([ + { request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: 'sk-ant-secret-b7c1' } }, + { request: 'POST /api/v1/settings/ai/verify', body: undefined }, + ]) + }) + + it('shows the verify error and verifies again on request', async () => { + let verifyCalls = 0 + stubAPI({ + 'PUT /api/v1/settings/ai/api-key': () => + jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: null }), + 'POST /api/v1/settings/ai/verify': () => { + verifyCalls++ + return verifyCalls === 1 + ? errorResponse(502, 'auth_failed') + : jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1' }) + }, + }) + await renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Replace' })) + const sheet = screen.getByRole('dialog', { name: 'Replace API key' }) + fireEvent.change(within(sheet).getByLabelText('API key'), { target: { value: 'sk-wrong-b7c1' } }) + fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' })) + + expect(await screen.findByRole('alert')).toHaveTextContent('Verification failed: the provider rejected this key.') + expect(screen.queryByText(/^Verified ·/)).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + expect(await screen.findByText('Verified · Oct 1')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('shows a refused key in the sheet', async () => { + stubAPI({ + 'PUT /api/v1/settings/ai/api-key': () => + jsonResponse(400, { + error: { code: 'invalid_settings', message: 'api_key: must be at least 8 characters', retryable: false }, + }), + }) + await renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Replace' })) + const sheet = screen.getByRole('dialog', { name: 'Replace API key' }) + fireEvent.change(within(sheet).getByLabelText('API key'), { target: { value: 'short' } }) + fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' })) + expect(await within(sheet).findByRole('alert')).toHaveTextContent( + 'The key was not stored: api_key: must be at least 8 characters', + ) + }) + + it('removes the stored key from the sheet', async () => { + const fetchMock = stubAPI({ + 'PUT /api/v1/settings/ai/api-key': () => + jsonResponse(200, { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null }), + }) + await renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Replace' })) + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove key' })) + expect(await screen.findByText('No key stored')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add key' })).toBeInTheDocument() + expect(sent(fetchMock)).toEqual([{ request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: '' } }]) + }) + + it('resets the system prompt after confirming', async () => { + const fetchMock = stubAPI({ + 'POST /api/v1/settings/ai/reset-prompt': () => jsonResponse(200, { ...AI_SETTINGS, system_prompt: DEFAULT_PROMPT }), + }) + await renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Reset' })) + const confirm = screen.getByRole('dialog', { name: 'Reset the system prompt?' }) + fireEvent.click(within(confirm).getByRole('button', { name: 'Cancel' })) + expect(sent(fetchMock)).toEqual([]) + + fireEvent.click(screen.getByRole('button', { name: 'Reset' })) + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Reset prompt' })) + await waitFor(() => expect(prompt()).toHaveValue(DEFAULT_PROMPT)) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(sent(fetchMock)).toEqual([{ request: 'POST /api/v1/settings/ai/reset-prompt', body: undefined }]) + expect(save()).toBeDisabled() + }) + + it('asks before leaving with unsaved changes', async () => { + stubAPI() + await renderPage() + fireEvent.change(prompt(), { target: { value: 'Unsaved.' } }) + fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' })) + const confirm = await screen.findByRole('dialog', { name: 'Leave without saving?' }) + fireEvent.click(within(confirm).getByRole('button', { name: 'Keep editing' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(prompt()).toHaveValue('Unsaved.') + + fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' })) + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Leave' })) + expect(await screen.findByTestId('path')).toHaveTextContent('/settings') + }) + + it('leaves without asking when nothing changed', async () => { + stubAPI() + await renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' })) + expect(await screen.findByTestId('path')).toHaveTextContent('/settings') + }) + + it('guards a page unload only while there are unsaved changes', async () => { + stubAPI() + await renderPage() + const unload = () => { + const event = new Event('beforeunload', { cancelable: true }) + act(() => void window.dispatchEvent(event)) + return event.defaultPrevented + } + expect(unload()).toBe(false) + fireEvent.change(slider(), { target: { value: '0.2' } }) + expect(unload()).toBe(true) + }) + + it('locks the form while a save is pending, and guards leaving until the save lands', async () => { + let answer: (response: Response) => void = () => {} + stubAPI({ 'PATCH /api/v1/settings/ai': () => new Promise((resolve) => (answer = resolve)) }) + await renderPage() + const unload = () => { + const event = new Event('beforeunload', { cancelable: true }) + act(() => void window.dispatchEvent(event)) + return event.defaultPrevented + } + fireEvent.change(prompt(), { target: { value: 'Keep it cozy.' } }) + fireEvent.click(save()) + + expect(await screen.findByRole('button', { name: 'Saving…' })).toBeDisabled() + for (const field of [provider(), model(), slider(), prompt()]) expect(field).toBeDisabled() + expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled() + expect(unload()).toBe(true) + + answer(jsonResponse(200, { ...AI_SETTINGS, system_prompt: 'Keep it cozy.' })) + expect(await screen.findByText('Changes saved.')).toBeInTheDocument() + expect(prompt()).toBeEnabled() + expect(prompt()).toHaveValue('Keep it cozy.') + await waitFor(() => expect(unload()).toBe(false)) + }) + + it('locks the form while a prompt reset is pending, even with the sheet closed', async () => { + let answer: (response: Response) => void = () => {} + stubAPI({ + 'POST /api/v1/settings/ai/reset-prompt': () => new Promise((resolve) => (answer = resolve)), + }) + await renderPage() + const unload = () => { + const event = new Event('beforeunload', { cancelable: true }) + act(() => void window.dispatchEvent(event)) + return event.defaultPrevented + } + fireEvent.change(slider(), { target: { value: '0.2' } }) + fireEvent.click(screen.getByRole('button', { name: 'Reset' })) + const confirm = screen.getByRole('dialog', { name: 'Reset the system prompt?' }) + fireEvent.click(within(confirm).getByRole('button', { name: 'Reset prompt' })) + expect(await within(confirm).findByRole('button', { name: 'Resetting…' })).toBeDisabled() + fireEvent.click(within(confirm).getByRole('button', { name: 'Cancel' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + + for (const field of [provider(), model(), slider(), prompt()]) expect(field).toBeDisabled() + expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled() + expect(save()).toBeDisabled() + expect(unload()).toBe(true) + + answer(jsonResponse(200, { ...AI_SETTINGS, system_prompt: DEFAULT_PROMPT })) + await waitFor(() => expect(prompt()).toBeEnabled()) + expect(prompt()).toHaveValue(DEFAULT_PROMPT) + expect(slider()).toHaveValue('0.2') + expect(unload()).toBe(true) + fireEvent.change(prompt(), { target: { value: 'After the reset.' } }) + expect(prompt()).toHaveValue('After the reset.') + }) + + it('keeps 44 px tap targets', async () => { + stubAPI() + await renderPage() + for (const control of [ + screen.getByRole('button', { name: 'Back to Settings' }), + screen.getByRole('button', { name: 'Replace' }), + provider(), + model(), + slider(), + screen.getByRole('button', { name: 'Reset' }), + save(), + ]) { + expectMinHitArea(control) + } + }) +}) + +// apiError is an API error response carrying message. +function apiError(status: number, code: string, message: string): Response { + return jsonResponse(status, { error: { code, message, retryable: false } }) +} + +const BRAVE_SETTINGS = { ...RECIPE_SEARCH_SETTINGS, provider: 'brave', api_key_set: true, api_key_last4: 'b7c2' } + +describe('Recipe search card', () => { + const card = () => screen.getByRole('region', { name: 'Recipe search' }) + const searxng = () => within(card()).getByRole('radio', { name: 'SearXNG' }) + const brave = () => within(card()).getByRole('radio', { name: 'Brave' }) + const url = () => within(card()).getByRole('textbox', { name: 'SearXNG URL' }) + const saveSearch = () => within(card()).getByRole('button', { name: 'Save recipe search' }) + const testSearch = () => within(card()).getByRole('button', { name: 'Test' }) + + async function renderCard() { + await renderPage() + await within(card()).findByRole('radiogroup', { name: 'Recipe search provider' }) + } + + // addKey enters key in the Brave key sheet. + function addKey(button: string, key: string) { + fireEvent.click(within(card()).getByRole('button', { name: button })) + const sheet = screen.getByRole('dialog') + fireEvent.change(within(sheet).getByLabelText('Brave API key'), { target: { value: key } }) + fireEvent.click(within(sheet).getByRole('button', { name: 'Use this key' })) + } + + it('shows the saved SearXNG URL', async () => { + stubAPI() + await renderCard() + expect(searxng()).toHaveAttribute('aria-checked', 'true') + expect(url()).toHaveValue('http://searxng:8080') + expect(within(card()).queryByText('Brave API key')).not.toBeInTheDocument() + expect(saveSearch()).toBeDisabled() + expect(testSearch()).toBeEnabled() + }) + + const UNCONFIGURED = { + provider: null, + base_url: null, + default_base_url: 'http://searxng:8080', + api_key_set: false, + api_key_last4: null, + updated_at: null, + } + + it('starts on SearXNG with the deployment\'s URL before recipe search is set up, and saves it', async () => { + const fetchMock = stubAPI({ + 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED), + 'PATCH /api/v1/settings/recipe-search': (body) => + jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, ...(body as object) }), + }) + await renderCard() + expect(searxng()).toHaveAttribute('aria-checked', 'true') + expect(url()).toHaveValue('http://searxng:8080') + expect(testSearch()).toBeDisabled() + expect(saveSearch()).toBeEnabled() + fireEvent.click(saveSearch()) + expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument() + expect(sent(fetchMock)).toEqual([ + { + request: 'PATCH /api/v1/settings/recipe-search', + body: { provider: 'searxng', base_url: 'http://searxng:8080' }, + }, + ]) + }) + + it('starts with an empty URL when the deployment names no SearXNG', async () => { + stubAPI({ + 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, { ...UNCONFIGURED, default_base_url: null }), + }) + await renderCard() + expect(url()).toHaveValue('') + }) + + it('shows the saved URL, not the deployment\'s', async () => { + stubAPI({ + 'GET /api/v1/settings/recipe-search': () => + jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, base_url: 'https://search.example' }), + }) + await renderCard() + expect(url()).toHaveValue('https://search.example') + expect(saveSearch()).toBeDisabled() + }) + + it('switches providers, keeping each provider\'s field', async () => { + stubAPI() + await renderCard() + fireEvent.change(url(), { target: { value: 'https://search.example' } }) + fireEvent.click(brave()) + expect(brave()).toHaveAttribute('aria-checked', 'true') + expect(within(card()).queryByRole('textbox', { name: 'SearXNG URL' })).not.toBeInTheDocument() + expect(within(card()).getByText('No key stored')).toBeInTheDocument() + expect(within(card()).getByRole('button', { name: 'Add key' })).toBeInTheDocument() + + fireEvent.click(searxng()) + expect(url()).toHaveValue('https://search.example') + }) + + it('saves a new SearXNG URL', async () => { + const fetchMock = stubAPI({ + 'PATCH /api/v1/settings/recipe-search': (body) => + jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, ...(body as object) }), + }) + await renderCard() + fireEvent.change(url(), { target: { value: 'https://search.example' } }) + expect(testSearch()).toBeDisabled() + expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument() + fireEvent.click(saveSearch()) + expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument() + expect(sent(fetchMock)).toEqual([ + { request: 'PATCH /api/v1/settings/recipe-search', body: { base_url: 'https://search.example' } }, + ]) + expect(saveSearch()).toBeDisabled() + expect(testSearch()).toBeEnabled() + }) + + it('saves a switch to Brave with its key, then shows only the key\'s last four characters', async () => { + const fetchMock = stubAPI({ 'PATCH /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) }) + await renderCard() + fireEvent.click(brave()) + addKey('Add key', 'BSA-secret-key-b7c2') + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument() + expect(within(card()).getByText('New key not saved yet')).toBeInTheDocument() + expect(document.body).not.toHaveTextContent('BSA-secret-key-b7c2') + + fireEvent.click(saveSearch()) + expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument() + expect(sent(fetchMock)).toEqual([ + { request: 'PATCH /api/v1/settings/recipe-search', body: { provider: 'brave', api_key: 'BSA-secret-key-b7c2' } }, + ]) + expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument() + expect(within(card()).queryByText('New key not saved yet')).not.toBeInTheDocument() + expect(within(card()).getByRole('button', { name: 'Replace' })).toBeInTheDocument() + expect(document.body).not.toHaveTextContent('BSA-secret-key-b7c2') + }) + + it('masks a stored Brave key and replaces it', async () => { + const fetchMock = stubAPI({ + 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS), + 'PATCH /api/v1/settings/recipe-search': () => jsonResponse(200, { ...BRAVE_SETTINGS, api_key_last4: '9d0e' }), + }) + await renderCard() + expect(brave()).toHaveAttribute('aria-checked', 'true') + expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument() + expect(saveSearch()).toBeDisabled() + + addKey('Replace', 'BSA-other-key-9d0e') + fireEvent.click(within(card()).getByRole('button', { name: 'Discard' })) + expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument() + expect(saveSearch()).toBeDisabled() + + addKey('Replace', 'BSA-other-key-9d0e') + fireEvent.click(saveSearch()) + expect(await within(card()).findByText('••••••••9d0e')).toBeInTheDocument() + expect(sent(fetchMock)).toEqual([ + { request: 'PATCH /api/v1/settings/recipe-search', body: { api_key: 'BSA-other-key-9d0e' } }, + ]) + }) + + it('reports a save the server refuses', async () => { + stubAPI({ + 'PATCH /api/v1/settings/recipe-search': () => + apiError(400, 'invalid_settings', 'invalid recipe search settings: api_key: must be at least 8 characters'), + }) + await renderCard() + fireEvent.click(brave()) + addKey('Add key', 'short') + fireEvent.click(saveSearch()) + expect(await within(card()).findByRole('alert')).toHaveTextContent( + 'Not saved: invalid recipe search settings: api_key: must be at least 8 characters.', + ) + }) + + it('shows how many recipes the test found', async () => { + const fetchMock = stubAPI({ + 'POST /api/v1/settings/recipe-search/test': () => + jsonResponse(200, { query: 'chicken tikka masala', count: 12 }), + }) + await renderCard() + fireEvent.click(testSearch()) + expect(await within(card()).findByText("Found 12 recipes for 'chicken tikka masala'")).toBeInTheDocument() + expect(sent(fetchMock)).toEqual([{ request: 'POST /api/v1/settings/recipe-search/test', body: undefined }]) + }) + + it('shows the error when the test fails', async () => { + stubAPI({ + 'POST /api/v1/settings/recipe-search/test': () => + apiError(502, 'search_failed', 'recipes: SearXNG answered 403 Forbidden'), + }) + await renderCard() + fireEvent.click(testSearch()) + expect(await within(card()).findByRole('alert')).toHaveTextContent( + 'Test failed: recipes: SearXNG answered 403 Forbidden', + ) + }) + + it('clears a test result when the settings are edited', async () => { + stubAPI({ + 'POST /api/v1/settings/recipe-search/test': () => + jsonResponse(200, { query: 'chicken tikka masala', count: 12 }), + }) + await renderCard() + fireEvent.click(testSearch()) + expect(await within(card()).findByText("Found 12 recipes for 'chicken tikka masala'")).toBeInTheDocument() + fireEvent.change(url(), { target: { value: 'https://search.example' } }) + expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument() + expect(within(card()).queryByText(/Found 12 recipes/)).not.toBeInTheDocument() + }) + + it('clears a test error when the settings are edited', async () => { + stubAPI({ + 'POST /api/v1/settings/recipe-search/test': () => + apiError(502, 'search_failed', 'recipes: SearXNG answered 403 Forbidden'), + }) + await renderCard() + fireEvent.click(testSearch()) + expect(await within(card()).findByRole('alert')).toHaveTextContent('Test failed') + fireEvent.click(brave()) + expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument() + expect(within(card()).queryByRole('alert')).not.toBeInTheDocument() + }) + + it('asks before leaving with an unsaved recipe search change', async () => { + stubAPI() + await renderCard() + fireEvent.change(url(), { target: { value: 'https://search.example' } }) + fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' })) + expect(await screen.findByRole('dialog', { name: 'Leave without saving?' })).toBeInTheDocument() + }) + + it('does not count the suggested SearXNG URL as an unsaved change until it is edited', async () => { + stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED) }) + await renderCard() + const unload = () => { + const event = new Event('beforeunload', { cancelable: true }) + act(() => void window.dispatchEvent(event)) + return event.defaultPrevented + } + expect(saveSearch()).toBeEnabled() + expect(unload()).toBe(false) + fireEvent.change(url(), { target: { value: 'https://search.example' } }) + expect(unload()).toBe(true) + fireEvent.change(url(), { target: { value: 'http://searxng:8080' } }) + expect(unload()).toBe(false) + }) + + it('leaves the untouched first-use card without asking', async () => { + stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED) }) + await renderCard() + fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' })) + expect(await screen.findByTestId('path')).toHaveTextContent('/settings') + }) + + it('locks the card while a save is pending, and guards leaving until the save lands', async () => { + let answer: (response: Response) => void = () => {} + stubAPI({ 'PATCH /api/v1/settings/recipe-search': () => new Promise((resolve) => (answer = resolve)) }) + await renderCard() + const unload = () => { + const event = new Event('beforeunload', { cancelable: true }) + act(() => void window.dispatchEvent(event)) + return event.defaultPrevented + } + fireEvent.change(url(), { target: { value: 'https://search.example' } }) + fireEvent.click(saveSearch()) + + expect(await within(card()).findByRole('button', { name: 'Saving…' })).toBeDisabled() + for (const field of [url(), searxng(), brave()]) expect(field).toBeDisabled() + expect(unload()).toBe(true) + + answer(jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, base_url: 'https://search.example' })) + expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument() + expect(url()).toBeEnabled() + expect(url()).toHaveValue('https://search.example') + await waitFor(() => expect(unload()).toBe(false)) + }) + + it('keeps 44 px tap targets', async () => { + stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) }) + await renderCard() + for (const control of [ + searxng(), + brave(), + within(card()).getByRole('button', { name: 'Replace' }), + testSearch(), + saveSearch(), + ]) { + expectMinHitArea(control) + } + }) +}) + +describe('maskedKey', () => { + it('shows only the last four characters', () => { + expect(maskedKey('3f9a')).toBe('••••••••3f9a') + }) +}) diff --git a/web/src/settings/AISettingsPage.tsx b/web/src/settings/AISettingsPage.tsx new file mode 100644 index 0000000..8fe9ec8 --- /dev/null +++ b/web/src/settings/AISettingsPage.tsx @@ -0,0 +1,546 @@ +import { ChevronLeft } from 'lucide-react' +import { useEffect, useId, useState, type ReactNode } from 'react' +import { useNavigate } from 'react-router' +import { ApiError } from '../api/index.ts' +import { Button } from '../ui/Button.tsx' +import { Card } from '../ui/Card.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { IconButton } from '../ui/IconButton.tsx' +import { Select } from '../ui/Select.tsx' +import { Sheet } from '../ui/Sheet.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { Slider } from '../ui/Slider.tsx' +import { Tag } from '../ui/Tag.tsx' +import { TextInput } from '../ui/TextInput.tsx' +import { + aiKeyPolicy, + aiProviderLabels, + maskedKey, + providersWithBaseURL, + useAIModels, + useAISettings, + useResetAIPrompt, + useSaveAISettings, + useSetAIKey, + useVerifyAIKey, + type AIProvider, + type AISettings, + type AISettingsPatch, +} from './api.ts' +import styles from './AISettingsPage.module.css' +import { Confirm, LeaveGuard } from './LeaveGuard.tsx' +import { RecipeSearchCard } from './RecipeSearchCard.tsx' + +// TYPE_MODEL is the Model dropdown's "Type a model id" option. +const TYPE_MODEL = '\u0000type' + +// Admin · AI assistant (handoff mock 1o). Provider, model, temperature and +// system prompt are edited as a draft and saved together; the API key is +// stored (and verified) on its own, straight away. The client only ever +// sees the key's last four characters. Recipe search is its own card, saved +// on its own. +export function AISettingsPage() { + const [aiDirty, setAIDirty] = useState(false) + const [recipeSearchDirty, setRecipeSearchDirty] = useState(false) + return ( +
+
+ + + +
+ ) +} + +type AISettingsProps = { + // Called with whether the form holds unsaved changes. + onDirtyChange: (dirty: boolean) => void +} + +// AIAssistantCard is the AI assistant's fields as a card, for the desktop +// settings (handoff mock 2e): the system prompt grows to fill the card. +export function AIAssistantCard({ onDirtyChange }: AISettingsProps) { + const titleId = useId() + return ( + +
+

+ AI assistant +

+ Admin +
+ +
+ ) +} + +// AIAssistantSettings is the AI assistant's form once its settings load. +function AIAssistantSettings({ onDirtyChange }: AISettingsProps) { + const settings = useAISettings() + return ( + <> + {settings.isPending && } + {settings.isError && ( + void settings.refetch()}> + AI settings could not load: {settings.error.message} + + )} + {settings.isSuccess && } + + ) +} + +function Header() { + const navigate = useNavigate() + return ( +
+ void navigate('/settings')}> + + +
+

Settings · Admin

+

AI assistant

+
+
+ ) +} + +type Draft = { + provider: AIProvider + base_url: string + model: string + temperature: number + system_prompt: string +} + +function draftOf(saved: AISettings): Draft { + return { + provider: saved.provider, + base_url: saved.base_url, + model: saved.model, + temperature: saved.temperature, + system_prompt: saved.system_prompt ?? '', + } +} + +// patchOf is the PATCH body for the fields draft changes. +function patchOf(saved: AISettings, draft: Draft): AISettingsPatch { + const before = draftOf(saved) + const patch: AISettingsPatch = {} + if (draft.provider !== before.provider) patch.provider = draft.provider + if (draft.base_url !== before.base_url) patch.base_url = draft.base_url + if (draft.model !== before.model) patch.model = draft.model + if (draft.temperature !== before.temperature) patch.temperature = draft.temperature + if (draft.system_prompt !== before.system_prompt) patch.system_prompt = draft.system_prompt + return patch +} + +type AISettingsFormProps = AISettingsProps & { + saved: AISettings +} + +function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) { + const [draft, setDraft] = useState(() => draftOf(saved)) + const [typingModel, setTypingModel] = useState(false) + const [confirmingReset, setConfirmingReset] = useState(false) + const save = useSaveAISettings() + const resetPrompt = useResetAIPrompt() + const patch = patchOf(saved, draft) + const dirty = Object.keys(patch).length > 0 + const providerChanged = draft.provider !== saved.provider + // The server keeps a stored key's provider and base URL fixed until the + // key is removed. + const keyLocked = saved.api_key_last4 !== null && (providerChanged || draft.base_url !== saved.base_url) + const temperatureId = useId() + // The model list is the saved provider's, so it is asked for only while + // that provider is chosen and has the key it needs. + const canList = + !providerChanged && (aiKeyPolicy[saved.provider] !== 'required' || saved.api_key_last4 !== null) + const models = useAIModels(canList) + const listed = canList && models.isSuccess ? models.data : [] + const typedModel = typingModel || !(draft.model === '' || listed.some((m) => m.id === draft.model)) + + useEffect(() => { + onDirtyChange(dirty) + }, [dirty, onDirtyChange]) + useEffect(() => () => onDirtyChange(false), [onDirtyChange]) + + function edit(change: Partial) { + save.reset() + setDraft((current) => ({ ...current, ...change })) + } + + function chooseProvider(provider: AIProvider) { + setTypingModel(provider !== saved.provider) + if (provider === saved.provider) { + edit({ provider, base_url: saved.base_url, model: saved.model }) + return + } + // A fixed-URL preset refuses any other base URL, and the old model is + // another provider's: the household types the new one. + edit({ provider, base_url: providersWithBaseURL.has(provider) ? draft.base_url : '', model: '' }) + } + + function onSave() { + save.mutate(patch, { + onSuccess: (settings) => { + setDraft(draftOf(settings)) + setTypingModel(false) + }, + }) + } + + function onConfirmReset() { + resetPrompt.mutate(undefined, { + onSuccess: (settings) => { + setDraft((current) => ({ ...current, system_prompt: settings.system_prompt ?? '' })) + setConfirmingReset(false) + }, + }) + } + + return ( +
+
+ +
+ + {canList && models.isPending &&

Loading models…

} + {canList && models.isError &&

Model list unavailable: {models.error.message}

} + {!canList && !providerChanged && ( +

Add the API key to choose from the provider's models.

+ )} +
+
+ {keyLocked &&

{keyLockedGuidance}

} + {typedModel && ( + edit({ model: event.target.value })} + /> + )} + {providersWithBaseURL.has(draft.provider) && ( + edit({ base_url: event.target.value })} + /> + )} + + +
+
+ + Creativity (temperature) + + + {draft.temperature.toFixed(1)} + +
+ edit({ temperature: Math.round(Number(event.target.value) * 10) / 10 })} + /> + +
+ + edit({ system_prompt })} /> + +
+ {save.isSuccess && !dirty && 'Changes saved.'} +
+ {save.isError &&

{saveErrorMessage(save.error)}

} + {resetPrompt.isError && !confirmingReset && ( +

+ The system prompt was not reset: {resetPrompt.error.message} +

+ )} + +
+ + +
+ + setConfirmingReset(false)} + onConfirm={onConfirmReset} + > + This restores MealQuest's default system prompt for everyone in the household. + +
+ ) +} + +// formatVerifiedDate turns key_verified_at into the handoff's "Oct 1". +export function formatVerifiedDate(timestamp: string): string { + const date = new Date(timestamp) + if (Number.isNaN(date.getTime())) throw new Error(`key_verified_at "${timestamp}" is not a timestamp`) + return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(date) +} + +// keyLockedGuidance is how to change the provider or base URL while a key +// is stored. +const keyLockedGuidance = + 'An API key is stored. Remove it (Replace → Remove key) before changing the provider or base URL.' + +// APIKey stays mounted whatever provider the draft names, so a key stored +// (and the verify that follows it) completes even if the household changes +// the provider meanwhile, and a stored key can always be removed. +function APIKey({ saved, draftProvider }: { saved: AISettings; draftProvider: AIProvider }) { + const [replacing, setReplacing] = useState(false) + const [key, setKey] = useState('') + const store = useSetAIKey() + const verify = useVerifyAIKey() + const labelId = useId() + const sheetTitleId = useId() + const last4 = saved.api_key_last4 + const providerChanged = draftProvider !== saved.provider + + function open() { + store.reset() + setReplacing(true) + } + + // close keeps the store mutation: a key already sent is still stored and + // verified after the sheet is dismissed. + function close() { + setReplacing(false) + setKey('') + } + + function onStore() { + verify.reset() + store.mutate(key, { + onSuccess: () => { + close() + verify.mutate(undefined) + }, + }) + } + + function onRemove() { + verify.reset() + store.mutate('', { onSuccess: close }) + } + + const storeError = store.isError && ( +

+ The key was not stored: {store.error.message} +

+ ) + + if (last4 === null && aiKeyPolicy[draftProvider] === 'none') { + return ( +
+

{aiProviderLabels[draftProvider]} takes no API key.

+ {!replacing && storeError} +
+ ) + } + + return ( +
+ + API key + +
+ {last4 === null ? 'No key stored' : maskedKey(last4)} + +
+ {last4 === null && providerChanged && ( +

Save the provider change before adding its key.

+ )} + {!replacing && storeError} + + + +
{ + event.preventDefault() + onStore() + }} + > +

+ {last4 === null ? 'Add API key' : 'Replace API key'} +

+

+ The key is stored on the server for {aiProviderLabels[saved.provider]}. This device only ever sees its + last four characters. +

+ setKey(event.target.value)} + /> + {storeError} +
+ {last4 !== null && ( + + )} + +
+ +
+
+ ) +} + +type KeyStatusProps = { + saved: AISettings + verify: ReturnType +} + +// KeyStatus is the line under the key: verifying, the verify error, when it +// was verified, or that it has not been. +function KeyStatus({ saved, verify }: KeyStatusProps) { + if (saved.api_key_last4 === null) return null + if (verify.isPending) return

Verifying…

+ if (verify.isError) { + return ( +
+

+ {verifyErrorMessage(verify.error)} +

+ Try again +
+ ) + } + if (saved.key_verified_at !== null) { + return

Verified · {formatVerifiedDate(saved.key_verified_at)}

+ } + return ( +
+

Not verified yet

+ Verify +
+ ) +} + +function VerifyButton({ verify, children }: { verify: KeyStatusProps['verify']; children: ReactNode }) { + return ( + + ) +} + +// verifyErrorMessage says why POST /settings/ai/verify failed, by the +// error code the server names (internal/server/aisettings.go). +export function verifyErrorMessage(error: Error): string { + if (!(error instanceof ApiError)) return `Verification failed: ${error.message}` + switch (error.code) { + case 'auth_failed': + return 'Verification failed: the provider rejected this key.' + case 'rate_limited': + return 'Verification failed: the provider is rate limiting requests. Try again shortly.' + case 'timeout': + return 'Verification failed: the provider did not answer in time.' + case 'settings_changed': + return 'Verification failed: the settings changed while verifying. Try again.' + default: + return `Verification failed: ${error.message}` + } +} + +// saveErrorMessage says why a save failed. The server refuses to move a +// stored key to another provider or base URL; it names how to fix that. +function saveErrorMessage(error: Error): string { + if (error instanceof ApiError && error.code === 'invalid_settings') { + const guidance = error.message.includes('while an API key is stored') ? ` ${keyLockedGuidance}` : '' + return `Not saved: ${error.message}.${guidance}` + } + return `Not saved: ${error.message}` +} + +function PromptField({ value, onChange }: { value: string; onChange: (value: string) => void }) { + const id = useId() + return ( +
+ +