Add AI settings API with key management

GET/PATCH /api/v1/settings/ai return and change provider, base_url,
model, temperature and system_prompt, with read-only api_key_last4 and
key_verified_at. PATCH requires a preset provider, temperature 0-1 in
0.1 steps and a system_prompt of at most 8000 characters.
PUT /settings/ai/api-key seals the key with secretbox and records its
last four characters; POST /settings/ai/verify calls Provider.Verify and
records key_verified_at or returns the provider's typed error;
GET /settings/ai/models lists the live models; POST
/settings/ai/reset-prompt restores household.DefaultPersonaPrompt.

The new internal/ai/aisettings service also builds the harness.Config
for each skill run from the saved settings. presets.ForAccount builds a
provider's ListModels and Verify without a model, so a key can be
checked and a model picked before one is chosen.

Tests scan every response body from these routes for the full key, and
a harness run with the Fake provider uses the PATCHed model and
temperature.

Maestro task: 176b2a30-6e90-4339-b430-5b8967b2fd32

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 08:50:12 -04:00
co-authored by Claude Opus 5.5
parent f25b28b39b
commit 60a92ec78f
17 changed files with 1416 additions and 31 deletions
+33
View File
@@ -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)
}
}