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.
This commit is contained in:
Andy Dixon
2026-06-23 23:43:33 +01:00
parent 0b790f34c7
commit d9c6ffa30f
43 changed files with 4138 additions and 20 deletions
+29 -2
View File
@@ -22,10 +22,13 @@ import (
"github.com/andydixon/noema/internal/cognition"
"github.com/andydixon/noema/internal/config"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/llm/prompts"
"github.com/andydixon/noema/internal/llm/router"
"github.com/andydixon/noema/internal/memory"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/observability"
"github.com/andydixon/noema/internal/scheduler"
"github.com/andydixon/noema/internal/security/secrets"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/internal/ui"
"github.com/andydixon/noema/internal/version"
@@ -88,12 +91,36 @@ func run() error {
}
group := scheduler.NewGroup(ctx, log)
deps.Memory = memory.NewStore(ctx, db, nil)
sec, err := secrets.New(cfg.MasterKey)
if err != nil {
return err
}
deps.Secrets = sec
if !sec.CanEncrypt() {
log.Warn("NOEMA_MASTER_KEY not set: API keys must be given as env: or file: references")
}
deps.LLMStore = router.NewStore(db)
deps.LLM = router.NewRouter(deps.LLMStore, sec, log, metrics, cfg.MaxConcurrentLLM)
deps.LLM.DailyTokenBudget = cfg.DailyTokenBudget
deps.LLM.MindBudget = func(id ids.ID) int64 {
m, err := deps.Minds.Get(ctx, id)
if err != nil {
return 0
}
return m.Config.DailyTokenBudget
}
deps.Prompts = prompts.NewStore(db)
if n, err := deps.Prompts.SeedDefaults(ctx); err != nil {
return fmt.Errorf("seed prompts: %w", err)
} else if n > 0 {
log.Info("installed default prompts", "count", n)
}
deps.Memory = memory.NewStore(ctx, db, deps.LLM.Embedder(ctx))
deps.Beliefs = beliefs.NewStore(db)
registry := brain.NewRegistry().AddProcess(cognition.Attend{}).AddProcess(cognition.Retrieve{}).AddProcess(cognition.Associate{})
cognition.Handlers{Memory: deps.Memory, Beliefs: deps.Beliefs}.Register(registry)
deps.Brain = brain.NewManager(brain.Config{DB: db, Events: deps.Events, Minds: deps.Minds, Registry: registry, Pool: scheduler.NewPool(cfg.MaxWorkers),
Log: log, Metrics: metrics, Services: brain.Services{Events: deps.Events, Memory: deps.Memory, Beliefs: deps.Beliefs}}, group)
Log: log, Metrics: metrics, Services: brain.Services{Events: deps.Events, Memory: deps.Memory, Beliefs: deps.Beliefs, LLM: deps.LLM, Prompts: deps.Prompts}}, group)
group.Every("memory.decay", 6*time.Hour, func(ctx context.Context) error { return decayMemories(ctx, deps) })
group.Every("memory.embed", time.Minute, func(ctx context.Context) error { _, err := deps.Memory.EmbedMissing(ctx, 50); return err })
if err := deps.Brain.Start(ctx); err != nil {