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:
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user