From 9ace3aba9f2d2bc866f23c40a90455ddd002e262 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sun, 4 Oct 2026 09:18:34 -0400 Subject: [PATCH] Provider presets and factory (#44) Co-authored-by: dev_agent Co-committed-by: dev_agent --- internal/ai/llm/openai/openai.go | 4 + internal/ai/llm/openai/openai_test.go | 14 + internal/ai/llm/presets/presets.go | 319 ++++++++++++++++ internal/ai/llm/presets/presets_test.go | 472 ++++++++++++++++++++++++ internal/ai/llm/settings.go | 68 ++++ internal/store/migrate_test.go | 95 ++++- internal/store/schema_test.go | 21 +- migrations/0004_ai_provider_presets.sql | 68 ++++ 8 files changed, 1055 insertions(+), 6 deletions(-) create mode 100644 internal/ai/llm/presets/presets.go create mode 100644 internal/ai/llm/presets/presets_test.go create mode 100644 internal/ai/llm/settings.go create mode 100644 migrations/0004_ai_provider_presets.sql diff --git a/internal/ai/llm/openai/openai.go b/internal/ai/llm/openai/openai.go index 4f21be7..70c94e9 100644 --- a/internal/ai/llm/openai/openai.go +++ b/internal/ai/llm/openai/openai.go @@ -53,6 +53,7 @@ type Config struct { BaseURL string // APIKey is sent as "Authorization: Bearer ". Empty sends no // Authorization header, for endpoints such as Ollama that need none. + // It must not have a control character other than tab. APIKey string // Model is the model Generate requests. ListModels and Verify do not // need it. @@ -102,6 +103,9 @@ func newFromConfig(cfg Config) (*Provider, error) { if u.User != nil || strings.ContainsAny(cfg.BaseURL, "?#") { return nil, errors.New("openai: base URL must not carry credentials, a query or a fragment") } + if !validHeaderValue(cfg.APIKey) { + return nil, errors.New("openai: API key has a control character") + } // No error from New quotes caller-supplied text or a name derived from // it: any such text may equal or contain the API key, and redaction // cannot catch a fragment or a case-changed copy of it. diff --git a/internal/ai/llm/openai/openai_test.go b/internal/ai/llm/openai/openai_test.go index c953fe1..12f2c09 100644 --- a/internal/ai/llm/openai/openai_test.go +++ b/internal/ai/llm/openai/openai_test.go @@ -628,6 +628,20 @@ func TestNewRejectsBadHeaderWithoutEchoingValue(t *testing.T) { assertNoSecret(t, err) } +func TestNewRejectsKeyWithControlCharacter(t *testing.T) { + for _, key := range []string{testKey + "\n", testKey + "\r\nX-Injected: 1", "\x00" + testKey, testKey + "\x7f"} { + _, err := New(Config{BaseURL: "https://example.com/v1", APIKey: key, StructuredOutput: JSONSchema}) + if err == nil { + t.Errorf("New(%q) = nil error", key) + continue + } + assertNoSecret(t, err) + if strings.Contains(err.Error()+fmt.Sprintf("%#v", err), key) { + t.Errorf("error leaks the key: %v", err) + } + } +} + // TestNewConfigErrorsNeverLeakKey covers every New error that names // caller-supplied text, each with the API key embedded in that text. func TestNewConfigErrorsNeverLeakKey(t *testing.T) { diff --git a/internal/ai/llm/presets/presets.go b/internal/ai/llm/presets/presets.go new file mode 100644 index 0000000..d33d613 --- /dev/null +++ b/internal/ai/llm/presets/presets.go @@ -0,0 +1,319 @@ +// Package presets holds what MealQuest knows about each language-model +// vendor — base URL, adapter, credentials, headers, structured output mode +// and model quirks — and FromSettings, which turns the household's +// llm.Settings into a ready llm.Provider. +// +// FromSettings lives here rather than in package llm because it builds the +// adapters, and every adapter imports llm. +// +// Nothing in this package logs, and no error it returns or unwraps to +// contains the API key, even where the key equals a setting's or request's +// value or text the error spells anyway. +package presets + +import ( + "context" + "errors" + "fmt" + "math" + "regexp" + "slices" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/anthropic" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/ollama" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/openai" +) + +// Adapter names the llm adapter package a preset builds. +type Adapter string + +// The adapters. +const ( + AdapterAnthropic Adapter = "anthropic" + AdapterOpenAI Adapter = "openai" + AdapterOllama Adapter = "ollama" +) + +// KeyPolicy is whether a preset sends an API key. +type KeyPolicy int + +// The key policies. +const ( + // KeyRequired presets fail without a key. + KeyRequired KeyPolicy = iota + // KeyOptional presets send a key when one is given. + KeyOptional + // KeyNone presets send no key and refuse one. + KeyNone +) + +// The reasoning models that reject the temperature field are OpenAI's o1 +// and o3 families: the bare family name or the name followed by a "-" +// variant (o1, o1-mini, o3-2025-04-16), never a longer word such as o123. +// OpenRouter names them with an "openai/" prefix and may add a ":" variant +// (openai/o1:online). A preset whose OmitTemperature holds a pattern never +// sends a temperature to a matching model. +var ( + openAIReasoningModels = regexp.MustCompile(`^o[13](-.*)?$`) + openRouterReasoningModels = regexp.MustCompile(`^openai/o[13]([-:].*)?$`) +) + +// Preset is one vendor's fixed configuration. +type Preset struct { + // Name is the Settings.Provider value that selects the preset. + Name string + // Adapter is the adapter the preset builds. + Adapter Adapter + // BaseURL is the API root the adapter appends its endpoint paths to. + // Empty means the household must supply it in Settings.BaseURL. + BaseURL string + // Key is whether the preset sends an API key. + Key KeyPolicy + // RequiresPublicURL means the preset needs Settings.PublicURL for its + // headers. + RequiresPublicURL bool + // Headers returns the extra request headers an openai-adapter preset + // sends; nil means none. + Headers func(llm.Settings) map[string]string + // StructuredOutput is the default mode of an openai-adapter preset. + StructuredOutput openai.StructuredOutput + // FallbackStructuredOutput is the mode the household can select when + // the default fails for its model; empty means none. FromSettings + // builds one mode and never retries a request in another. + FallbackStructuredOutput openai.StructuredOutput + // StructuredOutputs are the modes Settings.StructuredOutput may select. + StructuredOutputs []openai.StructuredOutput + // MaxTokens is the max_tokens the anthropic adapter sends when a + // Request sets none. + MaxTokens int + // OmitTemperature are model-id patterns the preset never sends a + // temperature to. + OmitTemperature []*regexp.Regexp +} + +// openRouterHeaders identify MealQuest to OpenRouter: HTTP-Referer is +// MEALQUEST_PUBLIC_URL and X-Title is the app name. +func openRouterHeaders(s llm.Settings) map[string]string { + return map[string]string{ + "HTTP-Referer": s.PublicURL, + "X-Title": "MealQuest", + } +} + +// omitsTemperature reports whether model matches one of the preset's +// OmitTemperature patterns. +func (p Preset) omitsTemperature(model string) bool { + return slices.ContainsFunc(p.OmitTemperature, func(re *regexp.Regexp) bool { + return re.MatchString(model) + }) +} + +// Lookup returns the preset Settings.Provider name selects. +func Lookup(name string) (Preset, bool) { + p, ok := all[name] + return p, ok +} + +// all is every preset, by Settings.Provider name. +var all = map[string]Preset{ + "anthropic": { + Name: "anthropic", + Adapter: AdapterAnthropic, + BaseURL: "https://api.anthropic.com", + Key: KeyRequired, + MaxTokens: 4096, + }, + "openai": { + Name: "openai", + Adapter: AdapterOpenAI, + BaseURL: "https://api.openai.com/v1", + Key: KeyRequired, + StructuredOutput: openai.JSONSchema, + StructuredOutputs: []openai.StructuredOutput{openai.JSONSchema}, + OmitTemperature: []*regexp.Regexp{openAIReasoningModels}, + }, + "openrouter": { + Name: "openrouter", + Adapter: AdapterOpenAI, + BaseURL: "https://openrouter.ai/api/v1", + Key: KeyRequired, + RequiresPublicURL: true, + Headers: openRouterHeaders, + StructuredOutput: openai.JSONSchema, + FallbackStructuredOutput: openai.JSONObject, + StructuredOutputs: []openai.StructuredOutput{openai.JSONSchema, openai.JSONObject}, + OmitTemperature: []*regexp.Regexp{openRouterReasoningModels}, + }, + "ollama": { + Name: "ollama", + Adapter: AdapterOllama, + Key: KeyNone, + }, + "openai_compatible": { + Name: "openai_compatible", + Adapter: AdapterOpenAI, + Key: KeyOptional, + StructuredOutput: openai.JSONSchema, + StructuredOutputs: []openai.StructuredOutput{openai.JSONSchema, openai.JSONObject, openai.PromptOnly}, + // The server may be OpenAI itself or a gateway that names models + // the OpenRouter way. + OmitTemperature: []*regexp.Regexp{openAIReasoningModels, openRouterReasoningModels}, + }, +} + +// FromSettings returns the Provider s describes, using key as its API key. +// It fails with a configuration error from package llm — ErrUnknownProvider, +// ErrMissingKey, ErrUnexpectedKey, ErrInvalidKey, ErrMissingBaseURL, ErrMissingModel, +// ErrMissingPublicURL or ErrInvalidSetting — before contacting any vendor. +// +// The Provider sends s.Temperature with every request whose Temperature is +// nil; a request that sets one keeps it. Neither is sent to a model the +// preset's OmitTemperature matches. +func FromSettings(s llm.Settings, key string) (llm.Provider, error) { + cp, err := fromSettings(s, key) + if err != nil { + return nil, redact(err, key) + } + return cp, nil +} + +func fromSettings(s llm.Settings, key string) (*configured, error) { + p, ok := Lookup(s.Provider) + if !ok { + return nil, llm.ErrUnknownProvider + } + if err := check(p, s, key); err != nil { + return nil, fmt.Errorf("%s: %w", p.Name, err) + } + base := p.BaseURL + if base == "" { + base = s.BaseURL + } + + var inner llm.Provider + var err error + switch p.Adapter { + case AdapterAnthropic: + inner, err = anthropic.New(anthropic.Config{BaseURL: base, APIKey: key, Model: s.Model, MaxTokens: p.MaxTokens}) + case AdapterOllama: + inner, err = ollama.New(ollama.Config{BaseURL: base, Model: s.Model}) + case AdapterOpenAI: + mode := p.StructuredOutput + if s.StructuredOutput != "" { + mode = openai.StructuredOutput(s.StructuredOutput) + } + cfg := openai.Config{BaseURL: base, APIKey: key, Model: s.Model, StructuredOutput: mode} + if p.Headers != nil { + cfg.Headers = p.Headers(s) + } + inner, err = openai.New(cfg) + default: + panic("presets: preset " + p.Name + " has unknown adapter " + string(p.Adapter)) + } + if err != nil { + return nil, fmt.Errorf("%s: %w: %w", p.Name, llm.ErrInvalidSetting, err) + } + + cp := &configured{Provider: inner, key: key} + if !p.omitsTemperature(s.Model) { + t := s.Temperature + cp.temperature = &t + } + return cp, nil +} + +// check validates s and key against p. Its errors never quote a value. +func check(p Preset, s llm.Settings, key string) error { + switch { + case p.Key == KeyRequired && key == "": + return llm.ErrMissingKey + case p.Key == KeyNone && key != "": + return llm.ErrUnexpectedKey + case !validHeaderValue(key): + return llm.ErrInvalidKey + } + switch { + case p.BaseURL == "" && s.BaseURL == "": + return llm.ErrMissingBaseURL + case p.BaseURL != "" && s.BaseURL != "" && s.BaseURL != p.BaseURL: + return fmt.Errorf("%w: base URL is fixed by the preset", llm.ErrInvalidSetting) + } + if s.Model == "" { + return llm.ErrMissingModel + } + if p.RequiresPublicURL && s.PublicURL == "" { + return llm.ErrMissingPublicURL + } + if s.StructuredOutput != "" && !slices.Contains(p.StructuredOutputs, openai.StructuredOutput(s.StructuredOutput)) { + return fmt.Errorf("%w: structured output mode is not supported by the preset", llm.ErrInvalidSetting) + } + if math.IsNaN(s.Temperature) || math.IsInf(s.Temperature, 0) || s.Temperature < 0 { + return fmt.Errorf("%w: temperature must be a non-negative number", llm.ErrInvalidSetting) + } + return nil +} + +// validHeaderValue reports whether value has no control character other +// than tab, so it can be sent as an HTTP header value. +func validHeaderValue(value string) bool { + for i := 0; i < len(value); i++ { + if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f { + return false + } + } + return true +} + +// configured applies the household temperature and the preset's +// temperature quirk to every Generate. +type configured struct { + llm.Provider + // key is the API key, redacted from validation errors; the adapter + // redacts its own. + key string + // temperature is sent when a request sets none; nil means the model + // rejects temperature, so none is ever sent. + temperature *float64 +} + +// Generate validates req as sent by the caller before changing its +// temperature, so clearing it for a model that rejects temperature never +// hides an invalid one. +func (c *configured) Generate(ctx context.Context, req llm.Request) (llm.Response, error) { + if err := req.Validate(); err != nil { + return llm.Response{}, redact(fmt.Errorf("presets: generate: %w", err), c.key) + } + if c.temperature == nil { + req.Temperature = nil + } else if req.Temperature == nil { + t := *c.temperature + req.Temperature = &t + } + return c.Provider.Generate(ctx, req) +} + +// redactedError is an error with the API key removed from its text. It +// does not unwrap, because every layer below may hold the key; Is still +// matches the sentinels the original error wraps. +type redactedError struct { + text string + err error +} + +func (e *redactedError) Error() string { return e.text } + +// GoString keeps %#v to the redacted Error text instead of the fields. +func (e *redactedError) GoString() string { return e.text } + +// Is reports whether the original error matches target. +func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) } + +// redact returns err with key removed from its text; a nil err or an empty +// key returns err unchanged. +func redact(err error, key string) error { + if err == nil || key == "" { + return err + } + return &redactedError{text: llm.Redact(err.Error(), key), err: err} +} diff --git a/internal/ai/llm/presets/presets_test.go b/internal/ai/llm/presets/presets_test.go new file mode 100644 index 0000000..47f98c5 --- /dev/null +++ b/internal/ai/llm/presets/presets_test.go @@ -0,0 +1,472 @@ +package presets + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" +) + +const testKey = "sk-test-0123456789" + +// captured is the HTTP request an adapter sent. +type captured struct { + url string + header http.Header + body map[string]json.RawMessage +} + +var errStop = errors.New("recorded") + +// recordingTransport records each request and fails it, so no request +// leaves the process. +type recordingTransport struct{ reqs []captured } + +func (rt *recordingTransport) RoundTrip(r *http.Request) (*http.Response, error) { + c := captured{url: r.URL.String(), header: r.Header.Clone()} + if r.Body != nil { + raw, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + if err := json.Unmarshal(raw, &c.body); err != nil { + return nil, err + } + } + rt.reqs = append(rt.reqs, c) + return nil, errStop +} + +// generate builds the provider s describes and returns the request its +// Generate sends for req. The adapters use http.DefaultTransport, which the +// test replaces, so the real preset base URLs are never contacted. +func generate(t *testing.T, s llm.Settings, key string, req llm.Request) captured { + t.Helper() + rt := &recordingTransport{} + orig := http.DefaultTransport + http.DefaultTransport = rt + t.Cleanup(func() { http.DefaultTransport = orig }) + + p, err := FromSettings(s, key) + if err != nil { + t.Fatalf("FromSettings: %v", err) + } + if _, err := p.Generate(context.Background(), req); err == nil { + t.Fatal("Generate succeeded through the recording transport") + } + if len(rt.reqs) != 1 { + t.Fatalf("sent %d requests, want 1", len(rt.reqs)) + } + return rt.reqs[0] +} + +func plainRequest() llm.Request { + return llm.Request{Messages: []llm.Message{{Role: llm.RoleUser, Content: "dinner?"}}} +} + +func schemaRequest() llm.Request { + r := plainRequest() + r.Schema = &llm.JSONSchema{Name: "side_quests", Schema: json.RawMessage(`{"type":"object"}`)} + return r +} + +func ptr(f float64) *float64 { return &f } + +func responseFormatType(t *testing.T, c captured) string { + t.Helper() + raw, ok := c.body["response_format"] + if !ok { + return "" + } + var rf struct{ Type string } + if err := json.Unmarshal(raw, &rf); err != nil { + t.Fatalf("response_format: %v", err) + } + return rf.Type +} + +func temperature(t *testing.T, c captured) (float64, bool) { + t.Helper() + raw, ok := c.body["temperature"] + if !ok { + return 0, false + } + var f float64 + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatalf("temperature: %v", err) + } + return f, true +} + +func TestAnthropicPreset(t *testing.T) { + c := generate(t, llm.Settings{Provider: "anthropic", Model: "claude-sonnet-5", Temperature: 0.7}, testKey, plainRequest()) + if c.url != "https://api.anthropic.com/v1/messages" { + t.Fatalf("URL = %q", c.url) + } + if got := c.header.Get("X-Api-Key"); got != testKey { + t.Fatalf("x-api-key = %q", got) + } + if got := c.header.Get("Authorization"); got != "" { + t.Fatalf("Authorization = %q, want none", got) + } + if got, ok := temperature(t, c); !ok || got != 0.7 { + t.Fatalf("temperature = %v, %v; want 0.7", got, ok) + } + if string(c.body["max_tokens"]) != "4096" { + t.Fatalf("max_tokens = %s", c.body["max_tokens"]) + } +} + +func TestAnthropicPresetAcceptsItsOwnBaseURL(t *testing.T) { + c := generate(t, llm.Settings{Provider: "anthropic", BaseURL: "https://api.anthropic.com", Model: "claude-sonnet-5"}, testKey, plainRequest()) + if c.url != "https://api.anthropic.com/v1/messages" { + t.Fatalf("URL = %q", c.url) + } +} + +func TestOpenAIPreset(t *testing.T) { + c := generate(t, llm.Settings{Provider: "openai", Model: "gpt-4o", Temperature: 0.4}, testKey, schemaRequest()) + if c.url != "https://api.openai.com/v1/chat/completions" { + t.Fatalf("URL = %q", c.url) + } + if got := c.header.Get("Authorization"); got != "Bearer "+testKey { + t.Fatalf("Authorization = %q", got) + } + if got := responseFormatType(t, c); got != "json_schema" { + t.Fatalf("response_format type = %q, want json_schema", got) + } + if c.header.Get("HTTP-Referer") != "" || c.header.Get("X-Title") != "" { + t.Fatal("openai sends OpenRouter headers") + } +} + +func TestOpenRouterPreset(t *testing.T) { + s := llm.Settings{Provider: "openrouter", Model: "openai/gpt-4o", PublicURL: "https://meals.example.com/"} + c := generate(t, s, testKey, schemaRequest()) + if c.url != "https://openrouter.ai/api/v1/chat/completions" { + t.Fatalf("URL = %q", c.url) + } + if got := c.header.Get("Authorization"); got != "Bearer "+testKey { + t.Fatalf("Authorization = %q", got) + } + if got := c.header.Get("HTTP-Referer"); got != "https://meals.example.com/" { + t.Fatalf("HTTP-Referer = %q", got) + } + if got := c.header.Get("X-Title"); got != "MealQuest" { + t.Fatalf("X-Title = %q", got) + } + if got := responseFormatType(t, c); got != "json_schema" { + t.Fatalf("default response_format type = %q, want json_schema", got) + } +} + +func TestOpenRouterFallbackModeIsJSONObject(t *testing.T) { + p, _ := Lookup("openrouter") + s := llm.Settings{ + Provider: "openrouter", + Model: "meta-llama/llama-3.3-70b-instruct", + PublicURL: "https://meals.example.com", + StructuredOutput: string(p.FallbackStructuredOutput), + } + c := generate(t, s, testKey, schemaRequest()) + if got := responseFormatType(t, c); got != "json_object" { + t.Fatalf("response_format type = %q, want json_object", got) + } +} + +func TestOllamaPreset(t *testing.T) { + s := llm.Settings{Provider: "ollama", BaseURL: "http://ollama.lan:11434", Model: "llama3.2", Temperature: 0.5} + c := generate(t, s, "", plainRequest()) + if c.url != "http://ollama.lan:11434/api/chat" { + t.Fatalf("URL = %q", c.url) + } + if got := c.header.Get("Authorization"); got != "" { + t.Fatalf("Authorization = %q, want none", got) + } + var opts struct{ Temperature *float64 } + if err := json.Unmarshal(c.body["options"], &opts); err != nil || opts.Temperature == nil || *opts.Temperature != 0.5 { + t.Fatalf("options = %s", c.body["options"]) + } +} + +func TestOpenAICompatiblePreset(t *testing.T) { + base := llm.Settings{Provider: "openai_compatible", BaseURL: "http://llm.lan:8000/v1", Model: "qwen2.5"} + + c := generate(t, base, "", schemaRequest()) + if c.url != "http://llm.lan:8000/v1/chat/completions" { + t.Fatalf("URL = %q", c.url) + } + if got := c.header.Get("Authorization"); got != "" { + t.Fatalf("Authorization without key = %q, want none", got) + } + if got := responseFormatType(t, c); got != "json_schema" { + t.Fatalf("default response_format type = %q, want json_schema", got) + } + + c = generate(t, base, testKey, plainRequest()) + if got := c.header.Get("Authorization"); got != "Bearer "+testKey { + t.Fatalf("Authorization with key = %q", got) + } + + for mode, want := range map[string]string{"json_schema": "json_schema", "json_object": "json_object", "prompt_only": ""} { + s := base + s.StructuredOutput = mode + if got := responseFormatType(t, generate(t, s, "", schemaRequest())); got != want { + t.Errorf("mode %s: response_format type = %q, want %q", mode, got, want) + } + } +} + +func TestTemperatureOmittedForRejectingModels(t *testing.T) { + cases := map[string][]string{ + "openai": {"o1", "o1-mini", "o1-2024-12-17", "o3", "o3-mini"}, + "openai_compatible": {"o1", "o1-mini", "o3-mini", "openai/o1"}, + "openrouter": {"openai/o1", "openai/o1-mini", "openai/o3", "openai/o3-mini", "openai/o1:online"}, + } + for provider, models := range cases { + for _, model := range models { + s := llm.Settings{Provider: provider, Model: model, Temperature: 0.8} + switch provider { + case "openai_compatible": + s.BaseURL = "http://llm.lan/v1" + case "openrouter": + s.PublicURL = "https://mealquest.example" + } + for _, req := range []llm.Request{plainRequest(), func() llm.Request { r := plainRequest(); r.Temperature = ptr(0.2); return r }()} { + if got, ok := temperature(t, generate(t, s, testKey, req)); ok { + t.Errorf("%s %s: temperature %v sent, want omitted", provider, model, got) + } + } + } + } +} + +func TestTemperatureSentForOtherModels(t *testing.T) { + cases := map[string][]string{ + "openai": {"gpt-4o", "gpt-4o-mini", "chatgpt-o1-like", "o123", "o3custom", "o1mini", "openai/o1"}, + "openrouter": {"openai/gpt-4o", "openai/o123", "openai/o3custom", "acme/o1", "o1-mini"}, + } + for provider, models := range cases { + for _, model := range models { + s := llm.Settings{Provider: provider, Model: model, Temperature: 0.8} + if provider == "openrouter" { + s.PublicURL = "https://mealquest.example" + } + if got, ok := temperature(t, generate(t, s, testKey, plainRequest())); !ok || got != 0.8 { + t.Errorf("%s %s: temperature = %v, %v; want 0.8", provider, model, got, ok) + } + } + } + // A request's own temperature wins over the household's. + req := plainRequest() + req.Temperature = ptr(0.1) + c := generate(t, llm.Settings{Provider: "openai", Model: "gpt-4o", Temperature: 0.8}, testKey, req) + if got, ok := temperature(t, c); !ok || got != 0.1 { + t.Fatalf("temperature = %v, %v; want 0.1", got, ok) + } +} + +// A request is validated before the temperature quirk clears its +// temperature, so a negative one is an error and nothing is sent. +func TestNegativeTemperatureRejectedForRejectingModels(t *testing.T) { + rt := &recordingTransport{} + orig := http.DefaultTransport + http.DefaultTransport = rt + t.Cleanup(func() { http.DefaultTransport = orig }) + + for _, s := range []llm.Settings{ + {Provider: "openai", Model: "o1"}, + {Provider: "openrouter", Model: "openai/o1", PublicURL: "https://mealquest.example"}, + } { + p, err := FromSettings(s, testKey) + if err != nil { + t.Fatalf("%s: FromSettings: %v", s.Model, err) + } + req := plainRequest() + req.Temperature = ptr(-0.5) + if _, err := p.Generate(context.Background(), req); err == nil || errors.Is(err, errStop) || !strings.Contains(err.Error(), "temperature") { + t.Errorf("%s: Generate = %v, want a temperature validation error", s.Model, err) + } + } + if len(rt.reqs) != 0 { + t.Fatalf("sent %d requests, want 0", len(rt.reqs)) + } +} + +func TestTemperatureQuirkPresets(t *testing.T) { + want := map[string]bool{"openai": true, "openrouter": true, "openai_compatible": true} + for name, p := range all { + if got := len(p.OmitTemperature) > 0; got != want[name] { + t.Errorf("%s: lists temperature quirk = %v, want %v", name, got, want[name]) + } + } +} + +func TestPresetTable(t *testing.T) { + cases := []struct { + name string + adapter Adapter + baseURL string + key KeyPolicy + }{ + {"anthropic", AdapterAnthropic, "https://api.anthropic.com", KeyRequired}, + {"openai", AdapterOpenAI, "https://api.openai.com/v1", KeyRequired}, + {"openrouter", AdapterOpenAI, "https://openrouter.ai/api/v1", KeyRequired}, + {"ollama", AdapterOllama, "", KeyNone}, + {"openai_compatible", AdapterOpenAI, "", KeyOptional}, + } + if len(all) != len(cases) { + t.Fatalf("%d presets, want %d", len(all), len(cases)) + } + for _, c := range cases { + p, ok := Lookup(c.name) + if !ok { + t.Fatalf("no preset %q", c.name) + } + if p.Name != c.name || p.Adapter != c.adapter || p.BaseURL != c.baseURL || p.Key != c.key { + t.Errorf("%s = {%s %s %q %d}", c.name, p.Name, p.Adapter, p.BaseURL, p.Key) + } + } +} + +func TestConfigErrors(t *testing.T) { + sentinels := []error{ + llm.ErrUnknownProvider, llm.ErrMissingKey, llm.ErrUnexpectedKey, llm.ErrInvalidKey, llm.ErrMissingBaseURL, + llm.ErrMissingModel, llm.ErrMissingPublicURL, llm.ErrInvalidSetting, + } + cases := []struct { + name string + s llm.Settings + key string + want error + }{ + {"unknown provider", llm.Settings{Provider: "gemini", Model: "m"}, testKey, llm.ErrUnknownProvider}, + {"anthropic no key", llm.Settings{Provider: "anthropic", Model: "m"}, "", llm.ErrMissingKey}, + {"openai no key", llm.Settings{Provider: "openai", Model: "m"}, "", llm.ErrMissingKey}, + {"openrouter no key", llm.Settings{Provider: "openrouter", Model: "m", PublicURL: "https://x.example"}, "", llm.ErrMissingKey}, + {"ollama with key", llm.Settings{Provider: "ollama", BaseURL: "http://o:11434", Model: "m"}, testKey, llm.ErrUnexpectedKey}, + {"anthropic key with newline", llm.Settings{Provider: "anthropic", Model: "m"}, testKey + "\n", llm.ErrInvalidKey}, + {"openai key with newline", llm.Settings{Provider: "openai", Model: "m"}, testKey + "\n", llm.ErrInvalidKey}, + {"openrouter key with CR LF", llm.Settings{Provider: "openrouter", Model: "m", PublicURL: "https://x.example"}, testKey + "\r\nX-Injected: 1", llm.ErrInvalidKey}, + {"openai_compatible key with NUL", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m"}, "\x00" + testKey, llm.ErrInvalidKey}, + {"ollama no base URL", llm.Settings{Provider: "ollama", Model: "m"}, "", llm.ErrMissingBaseURL}, + {"openai_compatible no base URL", llm.Settings{Provider: "openai_compatible", Model: "m"}, "", llm.ErrMissingBaseURL}, + {"anthropic no model", llm.Settings{Provider: "anthropic"}, testKey, llm.ErrMissingModel}, + {"openai no model", llm.Settings{Provider: "openai"}, testKey, llm.ErrMissingModel}, + {"openrouter no model", llm.Settings{Provider: "openrouter", PublicURL: "https://x.example"}, testKey, llm.ErrMissingModel}, + {"ollama no model", llm.Settings{Provider: "ollama", BaseURL: "http://o:11434"}, "", llm.ErrMissingModel}, + {"openai_compatible no model", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1"}, "", llm.ErrMissingModel}, + {"openrouter no public URL", llm.Settings{Provider: "openrouter", Model: "m"}, testKey, llm.ErrMissingPublicURL}, + {"fixed base URL overridden", llm.Settings{Provider: "openai", BaseURL: "https://evil.example/v1", Model: "m"}, testKey, llm.ErrInvalidSetting}, + {"openai json_object", llm.Settings{Provider: "openai", Model: "m", StructuredOutput: "json_object"}, testKey, llm.ErrInvalidSetting}, + {"anthropic mode", llm.Settings{Provider: "anthropic", Model: "m", StructuredOutput: "json_schema"}, testKey, llm.ErrInvalidSetting}, + {"unknown mode", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m", StructuredOutput: "xml"}, "", llm.ErrInvalidSetting}, + {"negative temperature", llm.Settings{Provider: "openai", Model: "m", Temperature: -0.1}, testKey, llm.ErrInvalidSetting}, + {"bad base URL", llm.Settings{Provider: "ollama", BaseURL: "ftp://o", Model: "m"}, "", llm.ErrInvalidSetting}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + p, err := FromSettings(c.s, c.key) + if p != nil || !errors.Is(err, c.want) { + t.Fatalf("FromSettings = %v, %v; want %v", p, err, c.want) + } + for _, other := range sentinels { + if other != c.want && errors.Is(err, other) { + t.Fatalf("error %v also matches %v", err, other) + } + } + if c.key != "" && strings.Contains(err.Error(), c.key) { + t.Fatalf("error leaks the key: %v", err) + } + }) + } +} + +func TestReadyProviderForEveryPreset(t *testing.T) { + for _, s := range []struct { + s llm.Settings + key string + }{ + {llm.Settings{Provider: "anthropic", Model: "m"}, testKey}, + {llm.Settings{Provider: "openai", Model: "m"}, testKey}, + {llm.Settings{Provider: "openrouter", Model: "m", PublicURL: "https://x.example"}, testKey}, + {llm.Settings{Provider: "ollama", BaseURL: "http://o:11434", Model: "m"}, ""}, + {llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m"}, ""}, + } { + if p, err := FromSettings(s.s, s.key); err != nil || p == nil { + t.Errorf("%s: FromSettings = %v, %v", s.s.Provider, p, err) + } + } +} + +// A key that matches a value the request carries, or text the error spells +// anyway, never appears in a validation error, and nothing is sent. +func TestGenerateValidationErrorsNeverLeakKey(t *testing.T) { + rt := &recordingTransport{} + orig := http.DefaultTransport + http.DefaultTransport = rt + t.Cleanup(func() { http.DefaultTransport = orig }) + + for _, key := range []string{"-0.5", "temperature", "llm", "negative"} { + p, err := FromSettings(llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m"}, key) + if err != nil { + t.Fatalf("%s: FromSettings: %v", key, err) + } + req := plainRequest() + req.Temperature = ptr(-0.5) + _, err = p.Generate(context.Background(), req) + if err == nil || errors.Is(err, errStop) { + t.Fatalf("%s: Generate = %v, want a validation error", key, err) + } + for e := error(err); e != nil; e = errors.Unwrap(e) { + if s := e.Error() + fmt.Sprintf("%#v", e); strings.Contains(s, key) { + t.Fatalf("%s: error leaks the key: %s", key, s) + } + } + } + if len(rt.reqs) != 0 { + t.Fatalf("sent %d requests, want 0", len(rt.reqs)) + } +} + +// A configuration error keeps its sentinel but never holds the key, even +// when the key is also a setting's value or text the error spells anyway. +func TestConfigErrorsNeverLeakKey(t *testing.T) { + cases := []struct { + name string + s llm.Settings + key string + want error + }{ + {"unknown provider named by key", llm.Settings{Provider: "llm", Model: "m"}, "llm", llm.ErrUnknownProvider}, + {"no base URL", llm.Settings{Provider: "openai_compatible", Model: "m"}, "base URL", llm.ErrMissingBaseURL}, + {"no model", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1"}, "model", llm.ErrMissingModel}, + {"key as model, no public URL", llm.Settings{Provider: "openrouter", Model: "missing"}, "missing", llm.ErrMissingPublicURL}, + {"key as base URL", llm.Settings{Provider: "openai", BaseURL: "llm", Model: "m"}, "llm", llm.ErrInvalidSetting}, + {"key as mode", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m", StructuredOutput: "xml"}, "xml", llm.ErrInvalidSetting}, + {"key as temperature", llm.Settings{Provider: "openai", Model: "m", Temperature: -0.1}, "-0.1", llm.ErrInvalidSetting}, + {"key in bad base URL", llm.Settings{Provider: "openai_compatible", BaseURL: "ftp://k3y", Model: "m"}, "k3y", llm.ErrInvalidSetting}, + {"ollama with key", llm.Settings{Provider: "ollama", BaseURL: "http://o:11434", Model: "m"}, "key", llm.ErrUnexpectedKey}, + {"key with newline", llm.Settings{Provider: "openai", Model: "m"}, "sk-test\n", llm.ErrInvalidKey}, + {"key with newline as text", llm.Settings{Provider: "openai", Model: "m"}, "header\n", llm.ErrInvalidKey}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + p, err := FromSettings(c.s, c.key) + if p != nil || !errors.Is(err, c.want) { + t.Fatalf("FromSettings = %v, %v; want %v", p, err, c.want) + } + for e := error(err); e != nil; e = errors.Unwrap(e) { + if s := e.Error() + fmt.Sprintf("%#v", e); strings.Contains(s, c.key) { + t.Fatalf("error leaks the key: %s", s) + } + } + }) + } +} diff --git a/internal/ai/llm/settings.go b/internal/ai/llm/settings.go new file mode 100644 index 0000000..adde36c --- /dev/null +++ b/internal/ai/llm/settings.go @@ -0,0 +1,68 @@ +package llm + +import "errors" + +// Settings is the household's AI configuration, the input to +// presets.FromSettings. The caller fills it from the db.AiSetting row and +// the service config; this package does not import internal/db. +// +// llm.Settings{ +// Provider: row.Provider, +// BaseURL: row.BaseUrl, +// Model: row.Model, +// Temperature: row.Temperature, +// PublicURL: cfg.PublicURL.String(), // MEALQUEST_PUBLIC_URL +// } +// +// Settings never holds the API key: the caller decrypts it and passes it +// to presets.FromSettings separately. +type Settings struct { + // Provider names the preset: "anthropic", "openai", "openrouter", + // "ollama" or "openai_compatible". + Provider string + // BaseURL is the API root. It is required for ollama and + // openai_compatible; the other presets fix their own, and a non-empty + // value must equal it. + BaseURL string + // Model is the model id Generate requests. It is required. + Model string + // Temperature is the household's sampling temperature. It is sent with + // every request that sets none, except to models that reject it. + Temperature float64 + // PublicURL is MEALQUEST_PUBLIC_URL. OpenRouter requires it and + // receives it as the HTTP-Referer header; other presets ignore it. + PublicURL string + // StructuredOutput selects how an OpenAI-compatible preset asks for + // JSON: "json_schema", "json_object" or "prompt_only". Empty selects + // the preset's default, json_schema. Each preset allows only the modes + // it lists; anthropic and ollama allow none, so it must be empty. + StructuredOutput string +} + +// Configuration errors from presets.FromSettings. Each is distinct and +// matches with errors.Is. +var ( + // ErrUnknownProvider means Settings.Provider names no preset. + ErrUnknownProvider = errors.New("llm: unknown provider") + // ErrMissingKey means the preset requires an API key and none was + // given. + ErrMissingKey = errors.New("llm: API key is required") + // ErrUnexpectedKey means an API key was given to a preset that sends + // none. + ErrUnexpectedKey = errors.New("llm: provider takes no API key") + // ErrInvalidKey means the API key is not a valid HTTP header value: + // it has a control character such as CR or LF. + ErrInvalidKey = errors.New("llm: API key is not a valid header value") + // ErrMissingBaseURL means the preset requires a base URL and none was + // given. + ErrMissingBaseURL = errors.New("llm: base URL is required") + // ErrMissingModel means Settings.Model is empty. + ErrMissingModel = errors.New("llm: model is required") + // ErrMissingPublicURL means the preset requires Settings.PublicURL and + // it is empty. + ErrMissingPublicURL = errors.New("llm: public URL is required") + // ErrInvalidSetting means a setting has a value the preset does not + // accept, such as a base URL other than the preset's, an unsupported + // structured output mode or a negative temperature. + ErrInvalidSetting = errors.New("llm: invalid setting") +) diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index e104996..444399a 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -175,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) { if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil { t.Fatal(err) } - if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status" { - t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got) + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets", got) } } @@ -196,8 +196,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) { if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { t.Fatal(err) } - if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status" { - t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got) + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets", got) } if !tableExists(t, s, "dinners") { t.Error("table from 0001_init was not created") @@ -309,3 +309,90 @@ func TestMigrateJobsStatusMapsLegacyRows(t *testing.T) { t.Error("jobs_new left behind") } } + +// TestMigrateAIProviderPresetsKeepsRowsAndReferences upgrades a database +// holding ai_settings and ai_runs rows, with suggestion_batches and +// week_plans referencing the run, and checks 0004 carries every row over, +// keeps the references enforced and accepts the new providers. +func TestMigrateAIProviderPresetsKeepsRowsAndReferences(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + before := fstest.MapFS{} + for _, name := range []string{"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql", "0003_jobs_status.sql"} { + body, err := fs.ReadFile(migrations.FS, name) + if err != nil { + t.Fatal(err) + } + before[name] = &fstest.MapFile{Data: body} + } + if err := Migrate(ctx, s.Writer, before); err != nil { + t.Fatal(err) + } + for _, stmt := range []string{ + `INSERT INTO ai_settings (id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at) + VALUES (1, 'anthropic', 'https://api.anthropic.com', 'claude', 0.7, 'sp', x'0102', 'abcd', 'v', 'c', 'u')`, + `INSERT INTO ai_runs (id, skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at) + VALUES ('run-1', 'suggest', '1', 'ollama', 'llama', 0.5, 'ok', 10, 20, 30, 'e', 'req', 'resp', 'c', 'u')`, + `INSERT INTO suggestion_batches (id, trigger, status, ai_run_id, created_at, updated_at) + VALUES ('batch-1', 'manual', 'done', 'run-1', 'c', 'u')`, + `INSERT INTO week_plans (id, week_start, status, ai_run_id, created_at, updated_at) + VALUES ('plan-1', '2026-10-04', 'draft', 'run-1', 'c', 'u')`, + } { + if _, err := s.Writer.Exec(stmt); err != nil { + t.Fatalf("seed: %v", err) + } + } + + if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + + var provider, baseURL, model, systemPrompt, last4, verifiedAt, createdAt, updatedAt string + var temperature float64 + var keyCT []byte + if err := s.Reader.QueryRow(`SELECT provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at FROM ai_settings WHERE id = 1`). + Scan(&provider, &baseURL, &model, &temperature, &systemPrompt, &keyCT, &last4, &verifiedAt, &createdAt, &updatedAt); err != nil { + t.Fatalf("read ai_settings: %v", err) + } + settings := []any{provider, baseURL, model, temperature, systemPrompt, string(keyCT), last4, verifiedAt, createdAt, updatedAt} + if want := []any{"anthropic", "https://api.anthropic.com", "claude", 0.7, "sp", "\x01\x02", "abcd", "v", "c", "u"}; !slices.Equal(settings, want) { + t.Errorf("ai_settings = %v, want %v", settings, want) + } + + var skill, skillVersion, status, errText, request, response string + var latency, tokensIn, tokensOut int + if err := s.Reader.QueryRow(`SELECT skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at FROM ai_runs WHERE id = 'run-1'`). + Scan(&skill, &skillVersion, &provider, &model, &temperature, &status, &latency, &tokensIn, &tokensOut, &errText, &request, &response, &createdAt, &updatedAt); err != nil { + t.Fatalf("read ai_runs: %v", err) + } + run := []any{skill, skillVersion, provider, model, temperature, status, latency, tokensIn, tokensOut, errText, request, response, createdAt, updatedAt} + if want := []any{"suggest", "1", "ollama", "llama", 0.5, "ok", 10, 20, 30, "e", "req", "resp", "c", "u"}; !slices.Equal(run, want) { + t.Errorf("ai_runs = %v, want %v", run, want) + } + + rows, err := s.Reader.Query("PRAGMA foreign_key_check") + if err != nil { + t.Fatal(err) + } + if rows.Next() { + t.Error("foreign_key_check reported violations after 0004") + } + if err := rows.Close(); err != nil { + t.Fatal(err) + } + if _, err := s.Writer.Exec(`DELETE FROM ai_runs WHERE id = 'run-1'`); err == nil || !strings.Contains(err.Error(), "FOREIGN KEY constraint failed") { + t.Errorf("deleting a referenced ai_run: error %v, want FOREIGN KEY constraint failure", err) + } + + if _, err := s.Writer.Exec(`UPDATE ai_settings SET provider = 'openrouter' WHERE id = 1`); err != nil { + t.Errorf("ai_settings.provider openrouter rejected: %v", err) + } + if _, err := s.Writer.Exec(`UPDATE ai_runs SET provider = 'openai' WHERE id = 'run-1'`); err != nil { + t.Errorf("ai_runs.provider openai rejected: %v", err) + } + for _, table := range []string{"ai_settings_copy", "ai_runs_copy"} { + if tableExists(t, s, table) { + t.Errorf("%s left behind", table) + } + } +} diff --git a/internal/store/schema_test.go b/internal/store/schema_test.go index 2b36ad1..1ce39a4 100644 --- a/internal/store/schema_test.go +++ b/internal/store/schema_test.go @@ -305,7 +305,7 @@ func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) { {"household_settings", "plan_draft_dow", 5.5}, {"household_settings", "suggestions_dow", 0.5}, {"household_settings", "chat_autosend_dow", 3.5}, - {"ai_runs", "provider", "openai"}, + {"ai_runs", "provider", "gemini"}, {"ai_runs", "status", "done"}, {"dinners", "status", "deck"}, {"dinners", "origin", "ai"}, @@ -322,7 +322,7 @@ func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) { {"rotation_reviews", "decision", "promote"}, {"shopping_lists", "status", "frozen"}, {"shopping_items", "needed", 2}, - {"ai_settings", "provider", "openai"}, + {"ai_settings", "provider", "gemini"}, {"recipe_search_settings", "provider", "google"}, {"chat_settings", "last_test_status", "pending"}, {"notification_prefs", "event", "weekly_digest"}, @@ -360,6 +360,23 @@ func TestSchemaRotationReviewEventAllowed(t *testing.T) { } } +// TestSchemaAIProvidersAllowed checks every provider preset can be stored in +// both ai_settings and ai_runs. +func TestSchemaAIProvidersAllowed(t *testing.T) { + s := seededStore(t) + for _, provider := range []string{"openai_compatible", "anthropic", "ollama", "openai", "openrouter"} { + for _, table := range []string{"ai_settings", "ai_runs"} { + inRolledBackTx(t, s, func(tx *sql.Tx) { + row := testRow(table, 1) + row["provider"] = provider + if err := insertSchemaRow(tx, table, row); err != nil { + t.Errorf("%s.provider %s rejected: %v", table, provider, err) + } + }) + } + } +} + func TestSchemaDinnerStatuses(t *testing.T) { s := seededStore(t) for _, status := range []string{"suggested", "quest_log", "tried", "regular", "archived"} { diff --git a/migrations/0004_ai_provider_presets.sql b/migrations/0004_ai_provider_presets.sql new file mode 100644 index 0000000..965ca79 --- /dev/null +++ b/migrations/0004_ai_provider_presets.sql @@ -0,0 +1,68 @@ +-- 0004_ai_provider_presets: ai_settings.provider and ai_runs.provider become +-- openai_compatible, anthropic, ollama, openai, openrouter, the presets in +-- internal/ai/llm/presets. Every 0001 value is kept, so existing rows copy +-- unchanged. SQLite cannot change a CHECK in place, so both tables are +-- rebuilt: copy the rows aside, drop the table, create it again with the +-- wider CHECK, copy the rows back and drop the copy. Neither table has +-- indexes or triggers. +-- +-- suggestion_batches.ai_run_id and week_plans.ai_run_id reference ai_runs, +-- so the rebuild cannot use 0003's create-new-then-rename: dropping ai_runs +-- counts every referencing row as a foreign key violation, and only inserting +-- the matching ai_runs rows under the name ai_runs clears them. Deferring +-- foreign keys holds the check until commit, after the rows are back. The +-- pragma lasts only for this migration's transaction. + +PRAGMA defer_foreign_keys = ON; + +CREATE TABLE ai_settings_copy AS SELECT * FROM ai_settings; + +DROP TABLE ai_settings; + +CREATE TABLE ai_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama', 'openai', 'openrouter')), + base_url TEXT NOT NULL, + model TEXT NOT NULL, + temperature REAL NOT NULL, + system_prompt TEXT, + api_key_ct BLOB, + api_key_last4 TEXT, + key_verified_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +INSERT INTO ai_settings (id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at) +SELECT id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at +FROM ai_settings_copy; + +DROP TABLE ai_settings_copy; + +CREATE TABLE ai_runs_copy AS SELECT * FROM ai_runs; + +DROP TABLE ai_runs; + +CREATE TABLE ai_runs ( + id TEXT NOT NULL PRIMARY KEY, + skill TEXT NOT NULL, + skill_version TEXT NOT NULL, + provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama', 'openai', 'openrouter')), + model TEXT NOT NULL, + temperature REAL NOT NULL, + status TEXT NOT NULL CHECK (status IN ('running', 'ok', 'error')), + latency_ms INTEGER, + tokens_in INTEGER, + tokens_out INTEGER, + error TEXT, + request_redacted TEXT NOT NULL, + response_redacted TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +INSERT INTO ai_runs (id, skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at) +SELECT id, skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at +FROM ai_runs_copy; + +DROP TABLE ai_runs_copy;