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.
170 lines
5.0 KiB
Go
170 lines
5.0 KiB
Go
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)
|
|
}
|