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

377 lines
12 KiB
Go

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})
}