From 8855091b30880b863bdfd91678de96e12fc490d5 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 09:16:09 -0400 Subject: [PATCH] AI settings API with key management (#80) Co-authored-by: dev_agent --- cmd/server/main.go | 8 +- docs/ARCHITECTURE.md | 23 ++ 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/household/defaults.go | 3 +- 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 | 2 +- internal/server/server.go | 16 +- internal/server/server_test.go | 55 ++- internal/server/settings.go | 18 +- internal/server/settings_test.go | 2 +- 17 files changed, 1523 insertions(+), 30 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 diff --git a/cmd/server/main.go b/cmd/server/main.go index 01c4ee5..c23b5db 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" @@ -83,6 +84,11 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } + ai, err := aisettings.New(db, box, cfg.PublicURL.String()) + if err != nil { + return err + } + sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax) 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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fbbfdd5..c6e0e82 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -152,6 +152,29 @@ automatic** (D-021). `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/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/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..4b60f41 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) 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..8e16ff6 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) }