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
+7 -1
View File
@@ -17,10 +17,13 @@ import (
"github.com/andydixon/noema/internal/brain"
"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/migrations"
)
@@ -31,7 +34,10 @@ func testServer(t *testing.T) (*httptest.Server, string) {
cfg := config.Defaults()
cfg.DatabaseURL = "postgres://test"
log := slog.New(slog.DiscardHandler)
deps := app.Deps{Config: cfg, Log: log, DB: pool, Auth: auth.NewService(pool, time.Hour, 2*time.Hour),
sec, _ := secrets.New("")
llmStore := router.NewStore(pool)
deps := app.Deps{Config: cfg, Log: log, DB: pool, Auth: auth.NewService(pool, time.Hour, 2*time.Hour), Secrets: sec, LLMStore: llmStore,
LLM: router.NewRouter(llmStore, sec, log, nil, 2), Prompts: prompts.NewStore(pool),
Audit: audit.New(pool, log), Metrics: observability.NewMetrics(), Minds: minds.NewStore(pool), Beliefs: beliefs.NewStore(pool), Memory: memory.NewStore(context.Background(), pool, nil), Events: evstore.NewStore(pool, evstore.NewBus(50), nil), Migrations: migrations.FS, Started: time.Now()}
ctx := context.Background()
group := scheduler.NewGroup(ctx, log)
+376
View File
@@ -0,0 +1,376 @@
package api
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/llm"
"github.com/andydixon/noema/internal/llm/router"
"github.com/andydixon/noema/internal/security/secrets"
"github.com/andydixon/noema/pkg/ids"
)
func (s *Server) llmRoutes() {
m := s.mux
m.Handle("GET /api/v1/llm/providers", require(auth.RoleObserver, s.providersList))
m.Handle("POST /api/v1/llm/providers", require(auth.RoleAdministrator, s.providersCreate))
m.Handle("GET /api/v1/llm/providers/{id}", require(auth.RoleObserver, s.providerGet))
m.Handle("PUT /api/v1/llm/providers/{id}", require(auth.RoleAdministrator, s.providerPut))
m.Handle("DELETE /api/v1/llm/providers/{id}", require(auth.RoleAdministrator, s.providerDelete))
m.Handle("POST /api/v1/llm/providers/{id}/test", require(auth.RoleOperator, s.providerTest))
m.Handle("GET /api/v1/llm/models", require(auth.RoleObserver, s.modelsList))
m.Handle("POST /api/v1/llm/models", require(auth.RoleAdministrator, s.modelsCreate))
m.Handle("PUT /api/v1/llm/models/{id}", require(auth.RoleAdministrator, s.modelPut))
m.Handle("DELETE /api/v1/llm/models/{id}", require(auth.RoleAdministrator, s.modelDelete))
m.Handle("GET /api/v1/llm/roles", require(auth.RoleObserver, s.rolesList))
m.Handle("PUT /api/v1/llm/roles/{role}", require(auth.RoleAdministrator, s.rolePut))
m.Handle("GET /api/v1/llm/calls", require(auth.RoleObserver, s.callsList))
m.Handle("GET /api/v1/llm/calls/{id}", require(auth.RoleObserver, s.callGet))
m.Handle("GET /api/v1/llm/usage", require(auth.RoleObserver, s.usage))
}
func llmErr(err error) error {
if errors.Is(err, router.ErrInvalid) || errors.Is(err, secrets.ErrNoMasterKey) {
return ValidationError(err.Error())
}
return err
}
// keyRef turns a typed secret into a reference: references pass through,
// anything else is encrypted under the master key (or refused without one).
func (s *Server) keyRef(typed string) (string, error) {
typed = strings.TrimSpace(typed)
if typed == "" || secrets.IsReference(typed) {
return typed, nil
}
ref, err := s.deps.Secrets.Encrypt(typed)
if err != nil {
return "", errors.New("no NOEMA_MASTER_KEY configured: set one, or enter a reference like env:OPENAI_API_KEY")
}
return ref, nil
}
type providerIn struct {
Name string `json:"name"`
Kind llm.ProviderKind `json:"kind"`
Endpoint string `json:"endpoint"`
APIKey string `json:"api_key"` // plaintext (encrypted) or env:/file:/enc: reference; empty keeps existing
AllowPrivate bool `json:"allow_private"`
Headers map[string]string `json:"headers"`
TimeoutMS int `json:"timeout_ms"`
Enabled *bool `json:"enabled"`
}
func (in providerIn) toInput(s *Server) (router.ProviderInput, error) {
ref, err := s.keyRef(in.APIKey)
if err != nil {
return router.ProviderInput{}, ValidationError(err.Error())
}
enabled := true
if in.Enabled != nil {
enabled = *in.Enabled
}
return router.ProviderInput{Name: in.Name, Kind: in.Kind, Endpoint: in.Endpoint, APIKeyRef: ref, AllowPrivate: in.AllowPrivate, Headers: in.Headers, Timeout: time.Duration(in.TimeoutMS) * time.Millisecond, Enabled: enabled}, nil
}
func describe(p router.Provider) router.Provider {
p.KeyDescribed = secrets.Describe(p.APIKeyRef)
return p
}
func (s *Server) providersList(w http.ResponseWriter, r *http.Request) {
list, err := s.deps.LLMStore.ListProviders(r.Context())
if err != nil {
fail(w, r, err)
return
}
out := make([]router.Provider, 0, len(list))
for _, p := range list {
out = append(out, describe(p))
}
writeJSON(w, http.StatusOK, page[router.Provider]{Items: out})
}
func (s *Server) providersCreate(w http.ResponseWriter, r *http.Request) {
var in providerIn
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
pin, err := in.toInput(s)
if err != nil {
fail(w, r, err)
return
}
p, err := s.deps.LLMStore.CreateProvider(r.Context(), pin)
if err != nil {
fail(w, r, llmErr(err))
return
}
s.deps.LLM.Invalidate()
s.deps.Audit.Log(r.Context(), "llm.provider.create", "llm_provider", p.ID.String(), map[string]any{"name": p.Name, "kind": p.Kind, "endpoint": p.Endpoint})
writeJSON(w, http.StatusCreated, describe(p))
}
func (s *Server) providerGet(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
p, err := s.deps.LLMStore.GetProvider(r.Context(), id)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, describe(p))
}
func (s *Server) providerPut(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
var in providerIn
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
pin, err := in.toInput(s)
if err != nil {
fail(w, r, err)
return
}
p, err := s.deps.LLMStore.UpdateProvider(r.Context(), id, pin)
if err != nil {
fail(w, r, llmErr(err))
return
}
s.deps.LLM.Invalidate()
s.deps.Audit.Log(r.Context(), "llm.provider.update", "llm_provider", p.ID.String(), map[string]any{"name": p.Name, "enabled": p.Enabled, "key_changed": in.APIKey != ""})
writeJSON(w, http.StatusOK, describe(p))
}
func (s *Server) providerDelete(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
if err := s.deps.LLMStore.DeleteProvider(r.Context(), id); err != nil {
fail(w, r, err)
return
}
s.deps.LLM.Invalidate()
s.deps.Audit.Log(r.Context(), "llm.provider.delete", "llm_provider", id.String(), nil)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) providerTest(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
var in struct {
Model string `json:"model"`
}
if r.ContentLength != 0 {
decode(r, &in) //nolint:errcheck
}
resp, err := s.deps.LLM.TestProvider(r.Context(), id, in.Model)
s.deps.Audit.Log(r.Context(), "llm.provider.test", "llm_provider", id.String(), map[string]any{"ok": err == nil})
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": resp.Model, "latency_ms": resp.Latency.Milliseconds(), "text": resp.Text, "usage": resp.Usage})
}
type modelIn struct {
ProviderID ids.ID `json:"provider_id"`
Name string `json:"name"`
Kind string `json:"kind"`
ContextWindow int `json:"context_window"`
Dims int `json:"dims"`
InputCostPerM float64 `json:"input_cost_per_m"`
OutputCostPerM float64 `json:"output_cost_per_m"`
Params map[string]any `json:"params"`
Enabled *bool `json:"enabled"`
}
func (in modelIn) toInput() router.ModelInput {
enabled := true
if in.Enabled != nil {
enabled = *in.Enabled
}
return router.ModelInput{ProviderID: in.ProviderID, Name: in.Name, Kind: in.Kind, ContextWindow: in.ContextWindow, Dims: in.Dims, InputCostPerM: in.InputCostPerM, OutputCostPerM: in.OutputCostPerM, Params: in.Params, Enabled: enabled}
}
func (s *Server) modelsList(w http.ResponseWriter, r *http.Request) {
list, err := s.deps.LLMStore.ListModels(r.Context())
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []router.Model{}
}
writeJSON(w, http.StatusOK, page[router.Model]{Items: list})
}
func (s *Server) modelsCreate(w http.ResponseWriter, r *http.Request) {
var in modelIn
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
m, err := s.deps.LLMStore.CreateModel(r.Context(), in.toInput())
if err != nil {
fail(w, r, llmErr(err))
return
}
s.deps.LLM.Invalidate()
s.deps.Audit.Log(r.Context(), "llm.model.create", "llm_model", m.ID.String(), map[string]any{"name": m.Name, "provider": m.Provider})
writeJSON(w, http.StatusCreated, m)
}
func (s *Server) modelPut(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
var in modelIn
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
m, err := s.deps.LLMStore.UpdateModel(r.Context(), id, in.toInput())
if err != nil {
fail(w, r, llmErr(err))
return
}
s.deps.LLM.Invalidate()
s.deps.Audit.Log(r.Context(), "llm.model.update", "llm_model", m.ID.String(), map[string]any{"name": m.Name, "enabled": m.Enabled})
writeJSON(w, http.StatusOK, m)
}
func (s *Server) modelDelete(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
if err := s.deps.LLMStore.DeleteModel(r.Context(), id); err != nil {
fail(w, r, err)
return
}
s.deps.LLM.Invalidate()
s.deps.Audit.Log(r.Context(), "llm.model.delete", "llm_model", id.String(), nil)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) rolesList(w http.ResponseWriter, r *http.Request) {
list, err := s.deps.LLMStore.ListRoles(r.Context())
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": list, "roles": llm.Roles})
}
func (s *Server) rolePut(w http.ResponseWriter, r *http.Request) {
var in struct {
ModelID *ids.ID `json:"model_id"`
FallbackIDs []ids.ID `json:"fallback_ids"`
Temperature *float64 `json:"temperature"`
MaxTokens *int `json:"max_tokens"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
a := router.RoleAssignment{Role: llm.Role(r.PathValue("role")), ModelID: in.ModelID, FallbackIDs: in.FallbackIDs, Temperature: in.Temperature, MaxTokens: in.MaxTokens, UpdatedBy: actor(r)}
if err := s.deps.LLMStore.SetRole(r.Context(), a); err != nil {
fail(w, r, llmErr(err))
return
}
s.deps.LLM.Invalidate()
s.onLLMConfigChanged(r)
s.deps.Audit.Log(r.Context(), "llm.role.set", "llm_role", string(a.Role), map[string]any{"model_id": in.ModelID, "fallbacks": in.FallbackIDs})
writeJSON(w, http.StatusOK, a)
}
// onLLMConfigChanged refreshes dependents such as the memory embedder.
func (s *Server) onLLMConfigChanged(r *http.Request) {
if s.deps.Memory != nil {
s.deps.Memory.SetEmbedder(s.deps.LLM.Embedder(r.Context()))
}
}
func (s *Server) callsList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
f := router.CallFilter{Role: llm.Role(q.Get("role")), ErrorsOnly: q.Get("errors") == "1"}
if v := q.Get("mind"); v != "" {
if m, err := s.deps.Minds.Resolve(r.Context(), v); err == nil {
f.MindID = m.ID
}
}
if v := q.Get("correlation"); v != "" {
if id, err := ids.Parse(v); err == nil {
f.CorrelationID = id
}
}
f.Limit, _ = strconv.Atoi(q.Get("limit"))
list, err := s.deps.LLMStore.ListCalls(r.Context(), f)
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []router.Call{}
}
writeJSON(w, http.StatusOK, page[router.Call]{Items: list})
}
func (s *Server) callGet(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
c, err := s.deps.LLMStore.GetCall(r.Context(), id)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, c)
}
func (s *Server) usage(w http.ResponseWriter, r *http.Request) {
window, err := time.ParseDuration(r.URL.Query().Get("window"))
if err != nil {
window = 24 * time.Hour
}
var mind ids.ID
if v := r.URL.Query().Get("mind"); v != "" {
if m, err := s.deps.Minds.Resolve(r.Context(), v); err == nil {
mind = m.ID
}
}
rows, err := s.deps.LLMStore.Usage(r.Context(), time.Now().Add(-window), mind)
if err != nil {
fail(w, r, err)
return
}
if rows == nil {
rows = []router.UsageRow{}
}
writeJSON(w, http.StatusOK, map[string]any{"window": window.String(), "items": rows})
}
+169
View File
@@ -0,0 +1,169 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/llm"
"github.com/andydixon/noema/internal/llm/prompts"
)
func (s *Server) promptRoutes() {
m := s.mux
m.Handle("GET /api/v1/prompts", require(auth.RoleObserver, s.promptsList))
m.Handle("POST /api/v1/prompts", require(auth.RoleAdministrator, s.promptsSave))
m.Handle("GET /api/v1/prompts/{name}", require(auth.RoleObserver, s.promptGet))
m.Handle("PUT /api/v1/prompts/{name}", require(auth.RoleAdministrator, s.promptsSave))
m.Handle("GET /api/v1/prompts/{name}/versions", require(auth.RoleObserver, s.promptVersions))
m.Handle("POST /api/v1/prompts/{name}/versions/{n}/restore", require(auth.RoleAdministrator, s.promptRestore))
m.Handle("POST /api/v1/prompts/{name}/clone", require(auth.RoleAdministrator, s.promptClone))
m.Handle("POST /api/v1/prompts/{name}/test", require(auth.RoleOperator, s.promptTest))
}
func promptErr(err error) error {
if errors.Is(err, prompts.ErrInvalid) {
return ValidationError(err.Error())
}
return err
}
func (s *Server) promptsList(w http.ResponseWriter, r *http.Request) {
list, err := s.deps.Prompts.List(r.Context())
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []prompts.Prompt{}
}
writeJSON(w, http.StatusOK, page[prompts.Prompt]{Items: list})
}
func (s *Server) promptGet(w http.ResponseWriter, r *http.Request) {
p, err := s.deps.Prompts.Get(r.Context(), r.PathValue("name"))
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, p)
}
func (s *Server) promptsSave(w http.ResponseWriter, r *http.Request) {
var in struct {
Name string `json:"name"`
Description string `json:"description"`
Role llm.Role `json:"role"`
System string `json:"system"`
Template string `json:"template"`
Schema json.RawMessage `json:"schema"`
Note string `json:"note"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
if n := r.PathValue("name"); n != "" {
in.Name = n
}
p, err := s.deps.Prompts.Save(r.Context(), prompts.Input{Name: in.Name, Description: in.Description, Role: in.Role, System: in.System, Template: in.Template, Schema: in.Schema, Note: in.Note, By: actor(r)})
if err != nil {
fail(w, r, promptErr(err))
return
}
s.deps.Audit.Log(r.Context(), "prompt.save", "prompt", p.Name, map[string]any{"version": p.CurrentVersion, "note": in.Note})
writeJSON(w, http.StatusOK, p)
}
func (s *Server) promptVersions(w http.ResponseWriter, r *http.Request) {
vs, err := s.deps.Prompts.Versions(r.Context(), r.PathValue("name"))
if err != nil {
fail(w, r, err)
return
}
if vs == nil {
vs = []prompts.Version{}
}
writeJSON(w, http.StatusOK, page[prompts.Version]{Items: vs})
}
func (s *Server) promptRestore(w http.ResponseWriter, r *http.Request) {
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil {
fail(w, r, ValidationError("bad version"))
return
}
p, err := s.deps.Prompts.Restore(r.Context(), r.PathValue("name"), n, actor(r))
if err != nil {
fail(w, r, promptErr(err))
return
}
s.deps.Audit.Log(r.Context(), "prompt.restore", "prompt", p.Name, map[string]any{"restored": n, "version": p.CurrentVersion})
writeJSON(w, http.StatusOK, p)
}
func (s *Server) promptClone(w http.ResponseWriter, r *http.Request) {
var in struct {
Name string `json:"name"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
p, err := s.deps.Prompts.Clone(r.Context(), r.PathValue("name"), in.Name, actor(r))
if err != nil {
fail(w, r, promptErr(err))
return
}
s.deps.Audit.Log(r.Context(), "prompt.clone", "prompt", p.Name, map[string]any{"from": r.PathValue("name")})
writeJSON(w, http.StatusCreated, p)
}
// promptTest renders a prompt (optionally an unsaved draft) with sample data
// and, if requested, runs it through the role's model.
func (s *Server) promptTest(w http.ResponseWriter, r *http.Request) {
var in struct {
Data map[string]any `json:"data"`
System *string `json:"system"`
Template *string `json:"template"`
Schema json.RawMessage `json:"schema"`
Run bool `json:"run"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
p, err := s.deps.Prompts.Get(r.Context(), r.PathValue("name"))
if err != nil {
fail(w, r, err)
return
}
if in.System != nil {
p.System = *in.System
}
if in.Template != nil {
p.Template = *in.Template
}
if len(in.Schema) > 0 {
p.Schema = in.Schema
}
req, err := p.Render(in.Data)
if err != nil {
fail(w, r, promptErr(err))
return
}
out := map[string]any{"rendered": req}
if in.Run {
req.Purpose = "prompt test by " + actor(r)
resp, err := s.deps.LLM.Complete(r.Context(), req)
if err != nil {
out["error"] = err.Error()
} else {
out["response"] = resp
}
s.deps.Audit.Log(r.Context(), "prompt.test", "prompt", p.Name, map[string]any{"ran": true, "ok": err == nil})
}
writeJSON(w, http.StatusOK, out)
}
+2
View File
@@ -56,6 +56,8 @@ func (s *Server) routes() {
s.workspaceRoutes()
s.memoryRoutes()
s.beliefRoutes()
s.llmRoutes()
s.promptRoutes()
m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint")