From 61c767ea32d9cbdfe73fdf43388d2cd3066596df Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sun, 4 Oct 2026 08:51:19 -0400 Subject: [PATCH] Add provider presets and FromSettings factory internal/ai/llm/presets defines the anthropic, openai, openrouter, ollama and openai_compatible presets (base URL, adapter, key policy, headers, structured output modes and model quirks) and FromSettings, which turns llm.Settings plus the decrypted key into a ready llm.Provider or a typed configuration error from package llm. Temperature is omitted for model ids matching ^o1 or ^o3, listed once in TemperatureRejectingModels. Maestro task: 4d90a725-5a7c-4bee-bbe4-d036cfb69c5b Co-Authored-By: Claude Opus 5.5 --- internal/ai/llm/presets/presets.go | 257 +++++++++++++++++ internal/ai/llm/presets/presets_test.go | 357 ++++++++++++++++++++++++ internal/ai/llm/settings.go | 65 +++++ 3 files changed, 679 insertions(+) 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 diff --git a/internal/ai/llm/presets/presets.go b/internal/ai/llm/presets/presets.go new file mode 100644 index 0000000..f05b936 --- /dev/null +++ b/internal/ai/llm/presets/presets.go @@ -0,0 +1,257 @@ +// 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 contains the API +// key or quotes a setting's value. +package presets + +import ( + "context" + "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 +) + +// TemperatureRejectingModels is the one list of model-id patterns that +// reject the temperature field: OpenAI's o1 and o3 reasoning models. A +// preset whose OmitTemperature holds it never sends a temperature to a +// matching model. +var TemperatureRejectingModels = []*regexp.Regexp{ + regexp.MustCompile(`^o1`), + regexp.MustCompile(`^o3`), +} + +// 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: TemperatureRejectingModels, + }, + "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}, + }, + "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}, + OmitTemperature: TemperatureRejectingModels, + }, +} + +// FromSettings returns the Provider s describes, using key as its API key. +// It fails with a configuration error from package llm — ErrUnknownProvider, +// ErrMissingKey, ErrUnexpectedKey, 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) { + 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} + 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 + } + 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 +} + +// configured applies the household temperature and the preset's +// temperature quirk to every Generate. +type configured struct { + llm.Provider + // temperature is sent when a request sets none; nil means the model + // rejects temperature, so none is ever sent. + temperature *float64 +} + +func (c *configured) Generate(ctx context.Context, req llm.Request) (llm.Response, error) { + if c.temperature == nil { + req.Temperature = nil + } else if req.Temperature == nil { + t := *c.temperature + req.Temperature = &t + } + return c.Provider.Generate(ctx, req) +} diff --git a/internal/ai/llm/presets/presets_test.go b/internal/ai/llm/presets/presets_test.go new file mode 100644 index 0000000..bda8346 --- /dev/null +++ b/internal/ai/llm/presets/presets_test.go @@ -0,0 +1,357 @@ +package presets + +import ( + "context" + "encoding/json" + "errors" + "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) { + for _, provider := range []string{"openai", "openai_compatible"} { + for _, model := range []string{"o1", "o1-mini", "o3-mini"} { + s := llm.Settings{Provider: provider, BaseURL: "http://llm.lan/v1", Model: model, Temperature: 0.8} + if provider == "openai" { + s.BaseURL = "" + } + 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) { + for _, model := range []string{"gpt-4o", "gpt-4o-mini", "chatgpt-o1-like"} { + c := generate(t, llm.Settings{Provider: "openai", Model: model, Temperature: 0.8}, testKey, plainRequest()) + if got, ok := temperature(t, c); !ok || got != 0.8 { + t.Errorf("%s: temperature = %v, %v; want 0.8", 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) + } +} + +func TestTemperatureQuirkPresets(t *testing.T) { + want := map[string]bool{"openai": 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.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}, + {"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) + } + } +} diff --git a/internal/ai/llm/settings.go b/internal/ai/llm/settings.go new file mode 100644 index 0000000..a9333a7 --- /dev/null +++ b/internal/ai/llm/settings.go @@ -0,0 +1,65 @@ +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") + // 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") +)