From c8ca272b0328fc346bcf02e6bf776db884fc97d3 Mon Sep 17 00:00:00 2001 From: Hein Date: Wed, 25 Mar 2026 12:26:31 +0200 Subject: [PATCH] feat(ai): add support for Ollama AI provider configuration * Update README with Ollama integration details * Add Ollama configuration to example YAML files * Implement Ollama provider in AI factory * Add tests for Ollama provider functionality * Enhance config validation for Ollama settings --- README.md | 32 ++++++++++++++++++++- configs/config.example.yaml | 4 +++ configs/dev.yaml | 4 +++ configs/docker.yaml | 4 +++ internal/ai/factory.go | 3 ++ internal/ai/factory_test.go | 33 ++++++++++++++++++++++ internal/ai/ollama/client.go | 24 ++++++++++++++++ internal/config/config.go | 7 +++++ internal/config/loader.go | 6 ++++ internal/config/loader_test.go | 48 ++++++++++++++++++++++++++++++++ internal/config/validate.go | 9 +++++- internal/config/validate_test.go | 11 +++++++- 12 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 internal/ai/factory_test.go create mode 100644 internal/ai/ollama/client.go diff --git a/README.md b/README.md index 703ea57..aa274fd 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ A Go MCP server for capturing and retrieving thoughts, memory, and project conte - Go — MCP server over Streamable HTTP - Postgres + pgvector — storage and vector search -- LiteLLM — primary AI provider (embeddings + metadata extraction) +- LiteLLM — primary hosted AI provider (embeddings + metadata extraction) - OpenRouter — default upstream behind LiteLLM +- Ollama — supported local or self-hosted OpenAI-compatible provider ## Tools @@ -48,6 +49,7 @@ Config is YAML-driven. Copy `configs/config.example.yaml` and set: - `database.url` — Postgres connection string - `auth.keys` — API keys for MCP endpoint access - `ai.litellm.base_url` and `ai.litellm.api_key` — LiteLLM proxy +- `ai.ollama.base_url` and `ai.ollama.api_key` — Ollama local or remote server See `llm/plan.md` for full architecture and implementation plan. @@ -72,7 +74,35 @@ Notes: - The app uses `configs/docker.yaml` inside the container. - `OB1_LITELLM_BASE_URL` overrides the LiteLLM endpoint, so you can retarget it without editing YAML. +- `OB1_OLLAMA_BASE_URL` overrides the Ollama endpoint for local or remote servers. - The base Compose file uses `host.containers.internal`, which is Podman-friendly. - The Docker override file adds `host-gateway` aliases so Docker can resolve the same host endpoint. - Database migrations `001` through `005` run automatically when the Postgres volume is created for the first time. - `migrations/006_rls_and_grants.sql` is intentionally skipped during container bootstrap because it contains deployment-specific grants for a role named `amcs_user`. + +## Ollama + +Set `ai.provider: "ollama"` to use a local or self-hosted Ollama server through its OpenAI-compatible API. + +Example: + +```yaml +ai: + provider: "ollama" + embeddings: + model: "nomic-embed-text" + dimensions: 768 + metadata: + model: "llama3.2" + temperature: 0.1 + ollama: + base_url: "http://localhost:11434/v1" + api_key: "ollama" + request_headers: {} +``` + +Notes: + +- For remote Ollama servers, point `ai.ollama.base_url` at the remote `/v1` endpoint. +- The client always sends Bearer auth; Ollama ignores it locally, so `api_key: "ollama"` is a safe default. +- `ai.embeddings.dimensions` must match the embedding model you actually use, or startup will fail the database vector-dimension check. diff --git a/configs/config.example.yaml b/configs/config.example.yaml index 5c09c67..2a763d9 100644 --- a/configs/config.example.yaml +++ b/configs/config.example.yaml @@ -45,6 +45,10 @@ ai: request_headers: {} embedding_model: "openrouter/openai/text-embedding-3-small" metadata_model: "gpt-4o-mini" + ollama: + base_url: "http://localhost:11434/v1" + api_key: "ollama" + request_headers: {} openrouter: base_url: "https://openrouter.ai/api/v1" api_key: "" diff --git a/configs/dev.yaml b/configs/dev.yaml index 5c09c67..2a763d9 100644 --- a/configs/dev.yaml +++ b/configs/dev.yaml @@ -45,6 +45,10 @@ ai: request_headers: {} embedding_model: "openrouter/openai/text-embedding-3-small" metadata_model: "gpt-4o-mini" + ollama: + base_url: "http://localhost:11434/v1" + api_key: "ollama" + request_headers: {} openrouter: base_url: "https://openrouter.ai/api/v1" api_key: "" diff --git a/configs/docker.yaml b/configs/docker.yaml index 2193d0e..f7a734f 100644 --- a/configs/docker.yaml +++ b/configs/docker.yaml @@ -45,6 +45,10 @@ ai: request_headers: {} embedding_model: "openrouter/openai/text-embedding-3-small" metadata_model: "gpt-4o-mini" + ollama: + base_url: "http://host.containers.internal:11434/v1" + api_key: "ollama" + request_headers: {} openrouter: base_url: "https://openrouter.ai/api/v1" api_key: "" diff --git a/internal/ai/factory.go b/internal/ai/factory.go index 994ba75..b6ee360 100644 --- a/internal/ai/factory.go +++ b/internal/ai/factory.go @@ -6,6 +6,7 @@ import ( "net/http" "git.warky.dev/wdevs/amcs/internal/ai/litellm" + "git.warky.dev/wdevs/amcs/internal/ai/ollama" "git.warky.dev/wdevs/amcs/internal/ai/openrouter" "git.warky.dev/wdevs/amcs/internal/config" ) @@ -14,6 +15,8 @@ func NewProvider(cfg config.AIConfig, httpClient *http.Client, log *slog.Logger) switch cfg.Provider { case "litellm": return litellm.New(cfg, httpClient, log) + case "ollama": + return ollama.New(cfg, httpClient, log) case "openrouter": return openrouter.New(cfg, httpClient, log) default: diff --git a/internal/ai/factory_test.go b/internal/ai/factory_test.go new file mode 100644 index 0000000..02d2837 --- /dev/null +++ b/internal/ai/factory_test.go @@ -0,0 +1,33 @@ +package ai + +import ( + "io" + "log/slog" + "net/http" + "testing" + + "git.warky.dev/wdevs/amcs/internal/config" +) + +func TestNewProviderSupportsOllama(t *testing.T) { + provider, err := NewProvider(config.AIConfig{ + Provider: "ollama", + Embeddings: config.AIEmbeddingConfig{ + Model: "nomic-embed-text", + Dimensions: 768, + }, + Metadata: config.AIMetadataConfig{ + Model: "llama3.2", + }, + Ollama: config.OllamaConfig{ + BaseURL: "http://localhost:11434/v1", + APIKey: "ollama", + }, + }, &http.Client{}, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("NewProvider() error = %v", err) + } + if provider.Name() != "ollama" { + t.Fatalf("provider name = %q, want ollama", provider.Name()) + } +} diff --git a/internal/ai/ollama/client.go b/internal/ai/ollama/client.go new file mode 100644 index 0000000..ca83a34 --- /dev/null +++ b/internal/ai/ollama/client.go @@ -0,0 +1,24 @@ +package ollama + +import ( + "log/slog" + "net/http" + + "git.warky.dev/wdevs/amcs/internal/ai/compat" + "git.warky.dev/wdevs/amcs/internal/config" +) + +func New(cfg config.AIConfig, httpClient *http.Client, log *slog.Logger) (*compat.Client, error) { + return compat.New(compat.Config{ + Name: "ollama", + BaseURL: cfg.Ollama.BaseURL, + APIKey: cfg.Ollama.APIKey, + EmbeddingModel: cfg.Embeddings.Model, + MetadataModel: cfg.Metadata.Model, + Temperature: cfg.Metadata.Temperature, + Headers: cfg.Ollama.RequestHeaders, + HTTPClient: httpClient, + Log: log, + Dimensions: cfg.Embeddings.Dimensions, + }), nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 3bb07f8..3d8bd7a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -62,6 +62,7 @@ type AIConfig struct { Embeddings AIEmbeddingConfig `yaml:"embeddings"` Metadata AIMetadataConfig `yaml:"metadata"` LiteLLM LiteLLMConfig `yaml:"litellm"` + Ollama OllamaConfig `yaml:"ollama"` OpenRouter OpenRouterAIConfig `yaml:"openrouter"` } @@ -84,6 +85,12 @@ type LiteLLMConfig struct { MetadataModel string `yaml:"metadata_model"` } +type OllamaConfig struct { + BaseURL string `yaml:"base_url"` + APIKey string `yaml:"api_key"` + RequestHeaders map[string]string `yaml:"request_headers"` +} + type OpenRouterAIConfig struct { BaseURL string `yaml:"base_url"` APIKey string `yaml:"api_key"` diff --git a/internal/config/loader.go b/internal/config/loader.go index 0013eaa..1b86d7d 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -73,6 +73,10 @@ func defaultConfig() Config { Model: "gpt-4o-mini", Temperature: 0.1, }, + Ollama: OllamaConfig{ + BaseURL: "http://localhost:11434/v1", + APIKey: "ollama", + }, }, Capture: CaptureConfig{ Source: DefaultSource, @@ -97,6 +101,8 @@ func applyEnvOverrides(cfg *Config) { overrideString(&cfg.Database.URL, "OB1_DATABASE_URL") overrideString(&cfg.AI.LiteLLM.BaseURL, "OB1_LITELLM_BASE_URL") overrideString(&cfg.AI.LiteLLM.APIKey, "OB1_LITELLM_API_KEY") + overrideString(&cfg.AI.Ollama.BaseURL, "OB1_OLLAMA_BASE_URL") + overrideString(&cfg.AI.Ollama.APIKey, "OB1_OLLAMA_API_KEY") overrideString(&cfg.AI.OpenRouter.APIKey, "OB1_OPENROUTER_API_KEY") if value, ok := os.LookupEnv("OB1_SERVER_PORT"); ok { diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 87b5933..5863dcd 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -69,3 +69,51 @@ logging: t.Fatalf("server port = %d, want 9090", cfg.Server.Port) } } + +func TestLoadAppliesOllamaEnvOverrides(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "test.yaml") + if err := os.WriteFile(configPath, []byte(` +server: + port: 8080 +mcp: + path: "/mcp" +auth: + keys: + - id: "test" + value: "secret" +database: + url: "postgres://from-file" +ai: + provider: "ollama" + embeddings: + model: "nomic-embed-text" + dimensions: 768 + metadata: + model: "llama3.2" + ollama: + base_url: "http://localhost:11434/v1" + api_key: "ollama" +search: + default_limit: 10 + max_limit: 50 +logging: + level: "info" +`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + t.Setenv("OB1_OLLAMA_BASE_URL", "https://ollama.example.com/v1") + t.Setenv("OB1_OLLAMA_API_KEY", "remote-key") + + cfg, _, err := Load(configPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.AI.Ollama.BaseURL != "https://ollama.example.com/v1" { + t.Fatalf("ollama base url = %q, want env override", cfg.AI.Ollama.BaseURL) + } + if cfg.AI.Ollama.APIKey != "remote-key" { + t.Fatalf("ollama api key = %q, want env override", cfg.AI.Ollama.APIKey) + } +} diff --git a/internal/config/validate.go b/internal/config/validate.go index ef09be5..a9559bf 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -28,7 +28,7 @@ func (c Config) Validate() error { } switch c.AI.Provider { - case "litellm", "openrouter": + case "litellm", "ollama", "openrouter": default: return fmt.Errorf("invalid config: unsupported ai.provider %q", c.AI.Provider) } @@ -45,6 +45,13 @@ func (c Config) Validate() error { if strings.TrimSpace(c.AI.LiteLLM.APIKey) == "" { return fmt.Errorf("invalid config: ai.litellm.api_key is required when ai.provider=litellm") } + case "ollama": + if strings.TrimSpace(c.AI.Ollama.BaseURL) == "" { + return fmt.Errorf("invalid config: ai.ollama.base_url is required when ai.provider=ollama") + } + if strings.TrimSpace(c.AI.Ollama.APIKey) == "" { + return fmt.Errorf("invalid config: ai.ollama.api_key is required when ai.provider=ollama") + } case "openrouter": if strings.TrimSpace(c.AI.OpenRouter.BaseURL) == "" { return fmt.Errorf("invalid config: ai.openrouter.base_url is required when ai.provider=openrouter") diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index 8d8192e..735bf86 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -19,6 +19,10 @@ func validConfig() Config { BaseURL: "http://localhost:4000/v1", APIKey: "key", }, + Ollama: OllamaConfig{ + BaseURL: "http://localhost:11434/v1", + APIKey: "ollama", + }, OpenRouter: OpenRouterAIConfig{ BaseURL: "https://openrouter.ai/api/v1", APIKey: "key", @@ -29,12 +33,17 @@ func validConfig() Config { } } -func TestValidateAcceptsLiteLLMAndOpenRouter(t *testing.T) { +func TestValidateAcceptsSupportedProviders(t *testing.T) { cfg := validConfig() if err := cfg.Validate(); err != nil { t.Fatalf("Validate litellm error = %v", err) } + cfg.AI.Provider = "ollama" + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate ollama error = %v", err) + } + cfg.AI.Provider = "openrouter" if err := cfg.Validate(); err != nil { t.Fatalf("Validate openrouter error = %v", err)