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
This commit is contained in:
32
README.md
32
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.
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
@@ -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:
|
||||
|
||||
33
internal/ai/factory_test.go
Normal file
33
internal/ai/factory_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
24
internal/ai/ollama/client.go
Normal file
24
internal/ai/ollama/client.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user