Files
Andy Dixon d9c6ffa30f feat: add provider-neutral LLM layer with secrets, budgets, recording and prompts
Phase 7 (ADR-0004). Cognitive code asks the router for a role; the
router maps roles to configured models with ordered fallbacks, applies
global and per-mind daily token budgets and a concurrency limit, records
every call (request, response, usage, latency, cost, seed, request hash)
and can serve recordings back for exact replay. Providers are raw HTTP
clients for OpenAI-compatible, Anthropic and Ollama endpoints, with
embedding support for the embed role wired into memory retrieval.

Secrets are references only: env:, file:, or enc: values encrypted with
AES-256-GCM under NOEMA_MASTER_KEY; keys are resolved at call time and
never rendered, logged or exported. Outbound HTTP refuses private and
loopback addresses at dial time unless a provider explicitly allows it,
bounds redirects and caps bodies. Provider errors are scrubbed.

Prompts are named, versioned rows with system and user templates and an
optional JSON schema; untrusted text enters only through delimited data
blocks. Six defaults are seeded and never overwritten. The UI adds the
LLM page (providers, models, role matrix, usage, errors, budgets), a
provider page with connectivity test, and a prompt manager with editor,
test runner, history, restore, clone and diff. CLI: provider, prompt.
2026-06-23 23:43:33 +01:00

85 lines
2.7 KiB
Go

package providers
import (
"context"
"time"
"github.com/andydixon/noema/internal/llm"
)
// Ollama speaks the native Ollama API, which exposes seeds and JSON mode
// directly; llama.cpp servers should use the OpenAI kind instead.
type Ollama struct{ cfg Config }
// NewOllama creates a client.
func NewOllama(cfg Config) *Ollama { return &Ollama{cfg: cfg} }
// Complete implements llm.LanguageModel.
func (p *Ollama) Complete(ctx context.Context, req llm.Request) (llm.Response, error) {
opts := map[string]any{}
if req.Temperature != nil {
opts["temperature"] = *req.Temperature
}
if req.TopP != nil {
opts["top_p"] = *req.TopP
}
if req.MaxTokens > 0 {
opts["num_predict"] = req.MaxTokens
}
if req.Seed != nil {
opts["seed"] = *req.Seed
}
if len(req.Stop) > 0 {
opts["stop"] = req.Stop
}
body := map[string]any{"model": p.cfg.Model, "messages": messages(req), "stream": false, "options": opts}
if len(req.JSONSchema) > 0 {
body["format"] = "json"
}
var out struct {
Model string `json:"model"`
Message struct{ Content string } `json:"message"`
DoneReason string `json:"done_reason"`
PromptEvalCount int `json:"prompt_eval_count"`
EvalCount int `json:"eval_count"`
}
start := time.Now()
if err := post(ctx, p.cfg.client(), p.cfg.url("/api/chat"), p.cfg.Headers, body, &out); err != nil {
return llm.Response{}, err
}
resp := llm.Response{Text: out.Message.Content, Provider: p.cfg.Name, Model: firstNonEmpty(out.Model, p.cfg.Model), Latency: time.Since(start), FinishReason: out.DoneReason,
Usage: llm.Usage{PromptTokens: out.PromptEvalCount, CompletionTokens: out.EvalCount, TotalTokens: out.PromptEvalCount + out.EvalCount}}
return resp, finish(req, &resp)
}
// OllamaEmbedder calls /api/embed.
type OllamaEmbedder struct {
cfg Config
dims int
}
// NewOllamaEmbedder creates an embedder.
func NewOllamaEmbedder(cfg Config, dims int) *OllamaEmbedder {
return &OllamaEmbedder{cfg: cfg, dims: dims}
}
// Model implements embeddings.Embedder.
func (e *OllamaEmbedder) Model() string { return e.cfg.Name + "/" + e.cfg.Model }
// Dims implements embeddings.Embedder.
func (e *OllamaEmbedder) Dims() int { return e.dims }
// Embed implements embeddings.Embedder.
func (e *OllamaEmbedder) Embed(ctx context.Context, texts []string) ([][]float32, error) {
var out struct {
Embeddings [][]float32 `json:"embeddings"`
}
if err := post(ctx, e.cfg.client(), e.cfg.url("/api/embed"), e.cfg.Headers, map[string]any{"model": e.cfg.Model, "input": texts}, &out); err != nil {
return nil, err
}
if len(out.Embeddings) > 0 {
e.dims = len(out.Embeddings[0])
}
return out.Embeddings, nil
}