Files
noema/internal/ui/llm.go
T
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

425 lines
17 KiB
Go

package ui
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/llm"
"github.com/andydixon/noema/internal/llm/prompts"
"github.com/andydixon/noema/internal/llm/router"
"github.com/andydixon/noema/internal/security/secrets"
"github.com/andydixon/noema/pkg/ids"
)
func (u *UI) llmRoutes(m *http.ServeMux) {
m.Handle("GET /llm", u.require(auth.RoleObserver, u.llmPage))
m.Handle("POST /llm/providers", u.require(auth.RoleAdministrator, u.providerCreate))
m.Handle("GET /llm/providers/{id}", u.require(auth.RoleObserver, u.providerPage))
m.Handle("POST /llm/providers/{id}", u.require(auth.RoleAdministrator, u.providerSave))
m.Handle("POST /llm/providers/{id}/test", u.require(auth.RoleOperator, u.providerTest))
m.Handle("POST /llm/providers/{id}/clear-key", u.require(auth.RoleAdministrator, u.providerClearKey))
m.Handle("POST /llm/providers/{id}/delete", u.require(auth.RoleAdministrator, u.providerDelete))
m.Handle("POST /llm/models", u.require(auth.RoleAdministrator, u.modelCreate))
m.Handle("POST /llm/models/{id}/delete", u.require(auth.RoleAdministrator, u.modelDelete))
m.Handle("POST /llm/roles/{role}", u.require(auth.RoleAdministrator, u.roleSave))
m.Handle("GET /prompts", u.require(auth.RoleObserver, u.promptsPage))
m.Handle("POST /prompts", u.require(auth.RoleAdministrator, u.promptCreate))
m.Handle("GET /prompts/{name}", u.require(auth.RoleObserver, u.promptPage))
m.Handle("POST /prompts/{name}", u.require(auth.RoleAdministrator, u.promptSave))
m.Handle("POST /prompts/{name}/test", u.require(auth.RoleOperator, u.promptTest))
m.Handle("POST /prompts/{name}/clone", u.require(auth.RoleAdministrator, u.promptClone))
m.Handle("POST /prompts/{name}/versions/{n}/restore", u.require(auth.RoleAdministrator, u.promptRestore))
m.Handle("GET /prompts/{name}/diff", u.require(auth.RoleObserver, u.promptDiff))
}
type roleRow struct {
Info llm.RoleInfo
Assignment router.RoleAssignment
}
func (u *UI) llmPage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
provs, _ := u.deps.LLMStore.ListProviders(ctx)
for i := range provs {
provs[i].KeyDescribed = secrets.Describe(provs[i].APIKeyRef)
}
models, _ := u.deps.LLMStore.ListModels(ctx)
roles, _ := u.deps.LLMStore.ListRoles(ctx)
byRole := map[llm.Role]router.RoleAssignment{}
for _, a := range roles {
byRole[a.Role] = a
}
var rows []roleRow
for _, info := range llm.Roles {
rows = append(rows, roleRow{Info: info, Assignment: byRole[info.Role]})
}
usage, _ := u.deps.LLMStore.Usage(ctx, time.Now().Add(-24*time.Hour), ids.Nil)
errs, _ := u.deps.LLMStore.ListCalls(ctx, router.CallFilter{ErrorsOnly: true, Limit: 8})
used, _ := u.deps.LLMStore.TokensSince(ctx, time.Now().Truncate(24*time.Hour), ids.Nil)
u.render(w, r, "llm", "Language models", map[string]any{"Providers": provs, "Models": models, "Roles": rows, "Usage": usage, "Errors": errs,
"GlobalBudget": u.deps.Config.DailyTokenBudget, "UsedToday": used, "MaxConcurrent": u.deps.Config.MaxConcurrentLLM})
}
func (u *UI) keyRef(typed string) (string, error) {
typed = strings.TrimSpace(typed)
if typed == "" || secrets.IsReference(typed) {
return typed, nil
}
return u.deps.Secrets.Encrypt(typed)
}
func (u *UI) providerInput(r *http.Request) (router.ProviderInput, error) {
r.ParseForm() //nolint:errcheck
ref, err := u.keyRef(r.PostFormValue("api_key"))
if err != nil {
return router.ProviderInput{}, err
}
timeout, _ := strconv.Atoi(r.PostFormValue("timeout_ms"))
enabled := r.PostFormValue("enabled") != "" || r.PostFormValue("enabled_default") == "1"
return router.ProviderInput{Name: r.PostFormValue("name"), Kind: llm.ProviderKind(r.PostFormValue("kind")), Endpoint: r.PostFormValue("endpoint"), APIKeyRef: ref,
AllowPrivate: r.PostFormValue("allow_private") != "", Timeout: time.Duration(timeout) * time.Millisecond, Enabled: enabled}, nil
}
func (u *UI) providerCreate(w http.ResponseWriter, r *http.Request) {
in, err := u.providerInput(r)
if err != nil {
u.flash(w, r, "error", "Cannot store key: "+err.Error()+". Set NOEMA_MASTER_KEY or use an env: reference.", "/llm")
return
}
in.Enabled = true
p, err := u.deps.LLMStore.CreateProvider(r.Context(), in)
if err != nil {
u.flash(w, r, "error", err.Error(), "/llm")
return
}
u.deps.LLM.Invalidate()
u.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})
u.flash(w, r, "ok", "Provider added. Add a model, then test it.", "/llm")
}
func (u *UI) provider(w http.ResponseWriter, r *http.Request) (router.Provider, bool) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
u.errorPage(w, r, 404, "No such provider.")
return router.Provider{}, false
}
p, err := u.deps.LLMStore.GetProvider(r.Context(), id)
if err != nil {
u.errorPage(w, r, 404, "No such provider.")
return router.Provider{}, false
}
p.KeyDescribed = secrets.Describe(p.APIKeyRef)
return p, true
}
func (u *UI) providerPage(w http.ResponseWriter, r *http.Request) {
p, ok := u.provider(w, r)
if !ok {
return
}
all, _ := u.deps.LLMStore.ListModels(r.Context())
var mine []router.Model
for _, m := range all {
if m.ProviderID == p.ID {
mine = append(mine, m)
}
}
u.render(w, r, "provider", "Provider "+p.Name, map[string]any{"Provider": p, "Models": mine})
}
func (u *UI) providerSave(w http.ResponseWriter, r *http.Request) {
p, ok := u.provider(w, r)
if !ok {
return
}
in, err := u.providerInput(r)
if err != nil {
u.flash(w, r, "error", "Cannot store key: "+err.Error(), "/llm/providers/"+p.ID.String())
return
}
if _, err := u.deps.LLMStore.UpdateProvider(r.Context(), p.ID, in); err != nil {
u.flash(w, r, "error", err.Error(), "/llm/providers/"+p.ID.String())
return
}
u.deps.LLM.Invalidate()
u.deps.Audit.Log(r.Context(), "llm.provider.update", "llm_provider", p.ID.String(), map[string]any{"name": in.Name, "enabled": in.Enabled, "key_changed": in.APIKeyRef != ""})
u.flash(w, r, "ok", "Provider saved.", "/llm/providers/"+p.ID.String())
}
func (u *UI) providerTest(w http.ResponseWriter, r *http.Request) {
p, ok := u.provider(w, r)
if !ok {
return
}
resp, err := u.deps.LLM.TestProvider(r.Context(), p.ID, r.PostFormValue("model"))
u.deps.Audit.Log(r.Context(), "llm.provider.test", "llm_provider", p.ID.String(), map[string]any{"ok": err == nil})
if err != nil {
u.flash(w, r, "error", "Test failed: "+err.Error(), "/llm")
return
}
u.flash(w, r, "ok", "Provider ok: "+resp.Model+" answered in "+strconv.FormatInt(resp.Latency.Milliseconds(), 10)+" ms.", "/llm")
}
func (u *UI) providerClearKey(w http.ResponseWriter, r *http.Request) {
p, ok := u.provider(w, r)
if !ok {
return
}
u.deps.LLMStore.ClearProviderKey(r.Context(), p.ID) //nolint:errcheck
u.deps.LLM.Invalidate()
u.deps.Audit.Log(r.Context(), "llm.provider.clear_key", "llm_provider", p.ID.String(), nil)
u.flash(w, r, "ok", "Key reference removed.", "/llm/providers/"+p.ID.String())
}
func (u *UI) providerDelete(w http.ResponseWriter, r *http.Request) {
p, ok := u.provider(w, r)
if !ok {
return
}
if err := u.deps.LLMStore.DeleteProvider(r.Context(), p.ID); err != nil {
u.flash(w, r, "error", err.Error(), "/llm")
return
}
u.deps.LLM.Invalidate()
u.deps.Audit.Log(r.Context(), "llm.provider.delete", "llm_provider", p.ID.String(), map[string]any{"name": p.Name})
u.flash(w, r, "ok", "Provider deleted.", "/llm")
}
func (u *UI) modelCreate(w http.ResponseWriter, r *http.Request) {
r.ParseForm() //nolint:errcheck
pid, err := ids.Parse(r.PostFormValue("provider_id"))
if err != nil {
u.flash(w, r, "error", "Choose a provider.", "/llm")
return
}
cw, _ := strconv.Atoi(r.PostFormValue("context_window"))
in, _ := strconv.ParseFloat(r.PostFormValue("input_cost_per_m"), 64)
out, _ := strconv.ParseFloat(r.PostFormValue("output_cost_per_m"), 64)
m, err := u.deps.LLMStore.CreateModel(r.Context(), router.ModelInput{ProviderID: pid, Name: r.PostFormValue("name"), Kind: r.PostFormValue("kind"), ContextWindow: cw, InputCostPerM: in, OutputCostPerM: out, Enabled: true})
if err != nil {
u.flash(w, r, "error", err.Error(), "/llm")
return
}
u.deps.LLM.Invalidate()
u.deps.Audit.Log(r.Context(), "llm.model.create", "llm_model", m.ID.String(), map[string]any{"name": m.Name, "provider": m.Provider})
u.flash(w, r, "ok", "Model added.", "/llm")
}
func (u *UI) modelDelete(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil || u.deps.LLMStore.DeleteModel(r.Context(), id) != nil {
u.flash(w, r, "error", "Model not found.", "/llm")
return
}
u.deps.LLM.Invalidate()
u.deps.Audit.Log(r.Context(), "llm.model.delete", "llm_model", id.String(), nil)
u.flash(w, r, "ok", "Model removed.", "/llm")
}
func (u *UI) roleSave(w http.ResponseWriter, r *http.Request) {
r.ParseForm() //nolint:errcheck
a := router.RoleAssignment{Role: llm.Role(r.PathValue("role")), UpdatedBy: username(r), FallbackIDs: []ids.ID{}}
if v := r.PostFormValue("model_id"); v != "" {
if id, err := ids.Parse(v); err == nil {
a.ModelID = &id
}
}
for _, v := range r.PostForm["fallback_ids"] {
if id, err := ids.Parse(v); err == nil && (a.ModelID == nil || id != *a.ModelID) {
a.FallbackIDs = append(a.FallbackIDs, id)
}
}
if v := r.PostFormValue("temperature"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
a.Temperature = &f
}
}
if v := r.PostFormValue("max_tokens"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
a.MaxTokens = &n
}
}
if err := u.deps.LLMStore.SetRole(r.Context(), a); err != nil {
u.flash(w, r, "error", err.Error(), "/llm")
return
}
u.deps.LLM.Invalidate()
u.deps.Memory.SetEmbedder(u.deps.LLM.Embedder(r.Context()))
u.deps.Audit.Log(r.Context(), "llm.role.set", "llm_role", string(a.Role), map[string]any{"model_id": a.ModelID, "fallbacks": a.FallbackIDs})
u.flash(w, r, "ok", "Role "+string(a.Role)+" saved.", "/llm")
}
// ---- prompts ----
func (u *UI) promptsPage(w http.ResponseWriter, r *http.Request) {
list, _ := u.deps.Prompts.List(r.Context())
u.render(w, r, "prompts", "Prompts", map[string]any{"Prompts": list})
}
func (u *UI) promptCreate(w http.ResponseWriter, r *http.Request) {
r.ParseForm() //nolint:errcheck
p, err := u.deps.Prompts.Save(r.Context(), prompts.Input{Name: r.PostFormValue("name"), Description: r.PostFormValue("description"), Role: llm.Role(r.PostFormValue("role")),
Template: "{{data \"input\" .Text}}", Note: "created", By: username(r)})
if err != nil {
u.flash(w, r, "error", err.Error(), "/prompts")
return
}
u.deps.Audit.Log(r.Context(), "prompt.save", "prompt", p.Name, map[string]any{"version": 1})
http.Redirect(w, r, "/prompts/"+p.Name, http.StatusSeeOther)
}
var samples = map[string]string{
"perceive.message": `{"Text":"Is atlas slow again since the deploy?","Actor":"andy","Trust":"user","Channel":"web"}`,
"respond": `{"Brief":"You are Dani, an investigator.","Intent":"Explain that the deployment hypothesis is weak.","Beliefs":[{"Proposition":"the deployment caused the latency","Confidence":0.32,"Status":"hypothesis"}],"Memories":[],"Uncertainty":"Only two co-occurring observations.","Limitations":["I cannot access servers."],"Message":"Was it the deploy?"}`,
"hypothesise": `{"Observations":["deployment at 09:10","CPU spike at 09:11","latency alert at 09:12"],"Beliefs":[],"Max":3}`,
"summarise": `{"Text":"Long text to summarise…","MaxWords":40}`,
"judge.equivalence": `{"A":"atlas is slow","B":"atlas is not slow"}`,
"narrate": `{"Workspace":["latency alert on atlas"],"Recent":["hypothesis formed"],"Goals":[],"Contradictions":0}`,
}
func (u *UI) promptPage(w http.ResponseWriter, r *http.Request) {
p, err := u.deps.Prompts.Get(r.Context(), r.PathValue("name"))
if err != nil {
u.errorPage(w, r, 404, "No such prompt.")
return
}
vs, _ := u.deps.Prompts.Versions(r.Context(), p.Name)
sample := samples[p.Name]
if sample == "" {
sample = `{"Text":"sample input"}`
}
u.render(w, r, "prompt", "Prompt "+p.Name, map[string]any{"Prompt": p, "Versions": vs, "Sample": sample, "RoleAvailable": u.deps.LLM.Available(r.Context(), p.Role)})
}
func (u *UI) promptSave(w http.ResponseWriter, r *http.Request) {
r.ParseForm() //nolint:errcheck
name := r.PathValue("name")
var schema json.RawMessage
if s := strings.TrimSpace(r.PostFormValue("schema")); s != "" {
schema = json.RawMessage(s)
}
p, err := u.deps.Prompts.Save(r.Context(), prompts.Input{Name: name, Description: r.PostFormValue("description"), System: r.PostFormValue("system"), Template: r.PostFormValue("template"), Schema: schema, Note: r.PostFormValue("note"), By: username(r)})
if err != nil {
u.flash(w, r, "error", err.Error(), "/prompts/"+name)
return
}
u.deps.Audit.Log(r.Context(), "prompt.save", "prompt", p.Name, map[string]any{"version": p.CurrentVersion, "note": r.PostFormValue("note")})
u.flash(w, r, "ok", "Saved as version "+strconv.Itoa(p.CurrentVersion)+".", "/prompts/"+name)
}
func (u *UI) promptTest(w http.ResponseWriter, r *http.Request) {
r.ParseForm() //nolint:errcheck
p, err := u.deps.Prompts.Get(r.Context(), r.PathValue("name"))
if err != nil {
u.errorPage(w, r, 404, "No such prompt.")
return
}
var data map[string]any
raw := r.PostFormValue("data")
result := map[string]any{}
if err := json.Unmarshal([]byte(raw), &data); err != nil {
result["Error"] = "Sample data is not a JSON object: " + err.Error()
} else if req, err := p.Render(data); err != nil {
result["Error"] = err.Error()
} else {
result["Rendered"] = req
if r.PostFormValue("run") == "1" {
req.Purpose = "prompt test by " + username(r)
resp, err := u.deps.LLM.Complete(r.Context(), req)
if err != nil {
result["Error"] = err.Error()
} else {
result["Response"] = resp
}
u.deps.Audit.Log(r.Context(), "prompt.test", "prompt", p.Name, map[string]any{"ran": true, "ok": err == nil})
}
}
vs, _ := u.deps.Prompts.Versions(r.Context(), p.Name)
u.render(w, r, "prompt", "Prompt "+p.Name, map[string]any{"Prompt": p, "Versions": vs, "Sample": raw, "Test": result, "RoleAvailable": u.deps.LLM.Available(r.Context(), p.Role)})
}
func (u *UI) promptClone(w http.ResponseWriter, r *http.Request) {
p, err := u.deps.Prompts.Clone(r.Context(), r.PathValue("name"), r.PostFormValue("name"), username(r))
if err != nil {
u.flash(w, r, "error", err.Error(), "/prompts/"+r.PathValue("name"))
return
}
u.deps.Audit.Log(r.Context(), "prompt.clone", "prompt", p.Name, map[string]any{"from": r.PathValue("name")})
http.Redirect(w, r, "/prompts/"+p.Name, http.StatusSeeOther)
}
func (u *UI) promptRestore(w http.ResponseWriter, r *http.Request) {
n, _ := strconv.Atoi(r.PathValue("n"))
p, err := u.deps.Prompts.Restore(r.Context(), r.PathValue("name"), n, username(r))
if err != nil {
u.flash(w, r, "error", err.Error(), "/prompts/"+r.PathValue("name"))
return
}
u.deps.Audit.Log(r.Context(), "prompt.restore", "prompt", p.Name, map[string]any{"restored": n, "version": p.CurrentVersion})
u.flash(w, r, "ok", "Restored version "+strconv.Itoa(n)+" as version "+strconv.Itoa(p.CurrentVersion)+".", "/prompts/"+p.Name)
}
type diffLine struct{ Class, Text string }
func (u *UI) promptDiff(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
a, _ := strconv.Atoi(r.URL.Query().Get("a"))
b, _ := strconv.Atoi(r.URL.Query().Get("b"))
va, errA := u.deps.Prompts.GetVersion(r.Context(), name, a)
vb, errB := u.deps.Prompts.GetVersion(r.Context(), name, b)
if errA != nil || errB != nil {
u.errorPage(w, r, 404, "Version not found.")
return
}
lines := lineDiff(va.System+"\n---\n"+va.Template, vb.System+"\n---\n"+vb.Template)
u.render(w, r, "prompt_diff", "Prompt diff", map[string]any{"Name": name, "A": va, "B": vb, "Lines": lines})
}
// lineDiff is a small LCS line diff; prompts are short so O(n·m) is fine.
func lineDiff(a, b string) []diffLine {
al, bl := strings.Split(a, "\n"), strings.Split(b, "\n")
n, m := len(al), len(bl)
lcs := make([][]int, n+1)
for i := range lcs {
lcs[i] = make([]int, m+1)
}
for i := n - 1; i >= 0; i-- {
for j := m - 1; j >= 0; j-- {
if al[i] == bl[j] {
lcs[i][j] = lcs[i+1][j+1] + 1
} else if lcs[i+1][j] >= lcs[i][j+1] {
lcs[i][j] = lcs[i+1][j]
} else {
lcs[i][j] = lcs[i][j+1]
}
}
}
var out []diffLine
i, j := 0, 0
for i < n && j < m {
switch {
case al[i] == bl[j]:
out = append(out, diffLine{"ctx", " " + al[i]})
i++
j++
case lcs[i+1][j] >= lcs[i][j+1]:
out = append(out, diffLine{"del", "- " + al[i]})
i++
default:
out = append(out, diffLine{"add", "+ " + bl[j]})
j++
}
}
for ; i < n; i++ {
out = append(out, diffLine{"del", "- " + al[i]})
}
for ; j < m; j++ {
out = append(out, diffLine{"add", "+ " + bl[j]})
}
return out
}