Everything in server.yaml that is not one machine's own is now a versioned document in the store (cluster_settings), the same on every control plane. The first start against a store imports the file's cluster keys, with the baseline guard policy when there is none; after that those keys in a file are ignored, with a warning. Each control plane polls the document's version and its own server.yaml's hash. A change is validated and applied: guard live (Gate.Replace), anything else by a graceful restart that systemd brings back, standbys first and the leader 30s later. A version that does not load on a machine is not applied; at start the newest one that loads is used, so a bad edit cannot keep every control plane down. llmgw ctl config show/get/set/unset/edit/history/rollback, the admin API under /api/v1/settings, and the console's Settings screen, which layers the store's document over the machine's file, write it. Commands beside the control plane that read cluster keys (init, node add, server add, backup, upgrade) read the layered configuration; init --bundle writes offline to the store once it holds the settings. A standby that becomes the leader re-reads models, engine choices and facility holds from the store. Retires appendGuardIfMissing: the import does it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
766 lines
27 KiB
Go
766 lines
27 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// The people side of the console: accounts, keys and the organizations they
|
|
// belong to. These live in the store rather than behind the admin API — the
|
|
// API mints no credentials — so the console reads the store as `llmgw users`
|
|
// does, and changes it by running those same commands, their checks and
|
|
// messages included.
|
|
|
|
// storeHolder is the store, opened on first use and shared by every copy of
|
|
// the console's model.
|
|
type storeHolder struct {
|
|
mu sync.Mutex
|
|
file string
|
|
db *store.DB
|
|
}
|
|
|
|
func (h *storeHolder) get(ctx context.Context) (*store.DB, error) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if h.db == nil {
|
|
sf := storeFlags{config: h.file}
|
|
db, err := sf.openCtx(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
h.db = db
|
|
}
|
|
return h.db, nil
|
|
}
|
|
|
|
func (h *storeHolder) close() {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if h.db != nil {
|
|
_ = h.db.Close()
|
|
h.db = nil
|
|
}
|
|
}
|
|
|
|
// orgRow is one line of the flattened tenancy tree.
|
|
type orgRow struct {
|
|
level string // org, team, project
|
|
id, name, path string
|
|
members, keys int
|
|
}
|
|
|
|
type peopleData struct {
|
|
users []store.User
|
|
where map[string][]string // user id → paths it is a member of
|
|
keys []store.KeyRow
|
|
orgs []orgRow
|
|
limits map[string]store.Limits
|
|
threads map[string]int // project id → hours kept, conversations
|
|
notes map[string]int // project id → hours kept, agent notes
|
|
// requests is how each project keeps its requests and answers.
|
|
requests map[string]store.RequestLog
|
|
// cache is how each project reuses answers.
|
|
cache map[string]store.AnswerCache
|
|
// guardPolicies are server.yaml's guardrail policies, guardDefault those
|
|
// applied everywhere, and guardAt which are attached where.
|
|
guardPolicies []string
|
|
guardDefault []string
|
|
guardAt []guard.Attachment
|
|
err error
|
|
}
|
|
|
|
type consolePeopleMsg peopleData
|
|
|
|
func (m consoleModel) fetchPeople() tea.Cmd {
|
|
h := m.st
|
|
file := m.file
|
|
return func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
var d peopleData
|
|
s, err := h.get(ctx)
|
|
if err != nil {
|
|
d.err = err
|
|
return consolePeopleMsg(d)
|
|
}
|
|
if d.users, d.err = s.Users(ctx, ""); d.err != nil {
|
|
return consolePeopleMsg(d)
|
|
}
|
|
all, _ := s.AllMemberships(ctx)
|
|
d.where = map[string][]string{}
|
|
for _, ms := range all {
|
|
d.where[ms.UserID] = append(d.where[ms.UserID], ms.Path)
|
|
}
|
|
d.keys, _ = s.AllKeys(ctx)
|
|
d.limits, _ = s.AllLimits(ctx)
|
|
tree, _ := s.Tree(ctx)
|
|
for _, o := range tree {
|
|
d.orgs = append(d.orgs, orgRow{level: "org", id: o.ID, name: o.Name, path: o.Name, members: o.Members})
|
|
for _, t := range o.Teams {
|
|
tp := o.Name + "/" + t.Name
|
|
d.orgs = append(d.orgs, orgRow{level: "team", id: t.ID, name: t.Name, path: tp, members: t.Members})
|
|
for _, p := range t.Projects {
|
|
d.orgs = append(d.orgs, orgRow{level: "project", id: p.ID, name: p.Name, path: tp + "/" + p.Name,
|
|
members: p.Users, keys: p.ActiveKeys})
|
|
}
|
|
}
|
|
}
|
|
if cfg, err := loadEffectiveConfig(file); err == nil {
|
|
for name := range cfg.Guard.Policies {
|
|
d.guardPolicies = append(d.guardPolicies, name)
|
|
}
|
|
slices.Sort(d.guardPolicies)
|
|
d.guardDefault = cfg.Guard.Default
|
|
d.guardAt, _ = s.GuardAttachments(ctx)
|
|
}
|
|
d.threads, d.notes, d.requests = map[string]int{}, map[string]int{}, map[string]store.RequestLog{}
|
|
d.cache = map[string]store.AnswerCache{}
|
|
for _, r := range d.orgs {
|
|
if r.level != "project" {
|
|
continue
|
|
}
|
|
if t, err := s.ThreadRetention(ctx, r.id); err == nil {
|
|
d.threads[r.id] = int(t.Hours())
|
|
}
|
|
if t, err := s.MemoryRetention(ctx, r.id); err == nil {
|
|
d.notes[r.id] = int(t.Hours())
|
|
}
|
|
if l, err := s.RequestLogOf(ctx, r.id); err == nil {
|
|
d.requests[r.id] = l
|
|
}
|
|
if c, err := s.AnswerCacheOf(ctx, r.id); err == nil {
|
|
d.cache[r.id] = c
|
|
}
|
|
}
|
|
return consolePeopleMsg(d)
|
|
}
|
|
}
|
|
|
|
// runCLI runs llmgw with these arguments in the background, against this
|
|
// console's store, and shows what it printed.
|
|
func (m *consoleModel) runCLI(title string, args ...string) tea.Cmd {
|
|
m.busy = title
|
|
file := m.file
|
|
return func() tea.Msg {
|
|
var out bytes.Buffer
|
|
c := Root()
|
|
c.SetArgs(append(append([]string{"--log-level=error"}, args...), "--config="+file))
|
|
c.SetOut(&out)
|
|
c.SetErr(&out)
|
|
c.SetIn(strings.NewReader(""))
|
|
err := c.ExecuteContext(context.Background())
|
|
text := strings.TrimSpace(out.String())
|
|
if text == "" {
|
|
text = "Done."
|
|
}
|
|
if err != nil {
|
|
if text != "Done." {
|
|
err = fmt.Errorf("%v\n%s", err, text)
|
|
}
|
|
return actionDoneMsg{title: title, err: err}
|
|
}
|
|
return actionDoneMsg{title: title, text: text}
|
|
}
|
|
}
|
|
|
|
func pwF(label, hint string) formField {
|
|
f := textF(label, "", hint)
|
|
f.text.Mask = true
|
|
return f
|
|
}
|
|
|
|
// --- accounts ---
|
|
|
|
func (m consoleModel) userMenu(u store.User) *menuDialog {
|
|
who := u.Email
|
|
cli := func(title string, args ...string) menuItem {
|
|
return menuItem{title, func(m *consoleModel) tea.Cmd { return m.runCLI(title+" "+who, args...) }}
|
|
}
|
|
var items []menuItem
|
|
switch u.State {
|
|
case store.UserPending:
|
|
items = append(items, menuItem{"Approve…", func(m *consoleModel) tea.Cmd {
|
|
m.openForm(&formDialog{title: "Approve " + who,
|
|
note: "Approved, they sign in and issue their own keys.",
|
|
fields: []formField{textF("project", "", "optional: a project to put them in, like Acme/ml/training")},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
args := []string{"users", "approve", u.ID, "--by=console"}
|
|
if v[0] != "" {
|
|
args = append(args, "--project="+v[0])
|
|
}
|
|
return m.runCLI("Approve "+who, args...), nil
|
|
}})
|
|
return nil
|
|
}})
|
|
case store.UserSuspended:
|
|
items = append(items, cli("Reinstate", "users", "reinstate", u.ID))
|
|
default:
|
|
items = append(items, menuItem{"Suspend…", func(m *consoleModel) tea.Cmd {
|
|
m.confirmD = &confirmDialog{title: "Suspend " + who,
|
|
text: "The account stops working and its sessions close; it and its history are kept.",
|
|
yes: func(m *consoleModel) tea.Cmd { return m.runCLI("Suspend "+who, "users", "suspend", u.ID) }}
|
|
return nil
|
|
}})
|
|
}
|
|
items = append(items, menuItem{"Staff role (" + or(u.StaffRole(), "none") + ")…", func(m *consoleModel) tea.Cmd {
|
|
m.menuD = staffRoleMenu(u)
|
|
return nil
|
|
}})
|
|
items = append(items,
|
|
menuItem{"Add to an organization, team or project…", func(m *consoleModel) tea.Cmd {
|
|
m.openForm(&formDialog{title: "Membership for " + who,
|
|
note: "A member of a team or organization may use every project in it.",
|
|
fields: []formField{
|
|
choiceF("level", []string{"org", "team", "project"}, "team"),
|
|
textF("which", "", "Acme, Acme/ml or Acme/ml/training"),
|
|
choiceF("role", []string{store.RoleMember, "manager"}, store.RoleMember),
|
|
},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if v[1] == "" {
|
|
return nil, fmt.Errorf("which %s?", v[0])
|
|
}
|
|
return m.runCLI("Membership for "+who, "users", "join", u.ID, "--"+v[0]+"="+v[1], "--role="+v[2], "--by=console"), nil
|
|
}})
|
|
return nil
|
|
}},
|
|
menuItem{"Take out of one…", func(m *consoleModel) tea.Cmd {
|
|
m.openForm(&formDialog{title: "Take " + who + " out",
|
|
note: "Keys they made in a project they can no longer reach are revoked.",
|
|
fields: []formField{
|
|
choiceF("level", []string{"org", "team", "project"}, "team"),
|
|
textF("which", "", strings.Join(m.people.where[u.ID], "; ")),
|
|
},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if v[1] == "" {
|
|
return nil, fmt.Errorf("which %s?", v[0])
|
|
}
|
|
return m.runCLI("Take "+who+" out", "users", "leave", u.ID, "--"+v[0]+"="+v[1]), nil
|
|
}})
|
|
return nil
|
|
}},
|
|
menuItem{"Their limits…", func(m *consoleModel) tea.Cmd { m.limitsForm(store.ScopeUser, u.ID, who); return nil }},
|
|
menuItem{"Set a new password…", func(m *consoleModel) tea.Cmd {
|
|
m.openForm(&formDialog{title: "New password for " + who,
|
|
note: "Their sessions close; tell them out of band and have them change it.",
|
|
fields: []formField{pwF("password", "at least 12 characters"), pwF("again", "")},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if v[0] == "" || v[0] != v[1] {
|
|
return nil, fmt.Errorf("the two are not the same")
|
|
}
|
|
return m.runCLI("Password for "+who, "users", "reset-password", u.ID, "--password="+v[0]), nil
|
|
}})
|
|
return nil
|
|
}},
|
|
menuItem{"Delete the account…", func(m *consoleModel) tea.Cmd {
|
|
m.confirmD = &confirmDialog{title: "Delete " + who,
|
|
text: "They are signed out, every key they made is revoked, and the account is removed.\nTo keep it, suspend it instead.",
|
|
yes: func(m *consoleModel) tea.Cmd { return m.runCLI("Delete "+who, "users", "delete", u.ID, "--yes") }}
|
|
return nil
|
|
}},
|
|
menuItem{"Forget them…", func(m *consoleModel) tea.Cmd {
|
|
m.confirmD = &confirmDialog{title: "Forget " + who,
|
|
text: "For a request to be forgotten. Their usage stays, under a random name; their kept\n" +
|
|
"requests, personal files and batches go; the audit log stops naming them; then\n" +
|
|
"the account is removed. It cannot be undone.",
|
|
yes: func(m *consoleModel) tea.Cmd { return m.runCLI("Forget "+who, "users", "forget", u.ID, "--yes") }}
|
|
return nil
|
|
}},
|
|
)
|
|
return &menuDialog{title: who, items: items}
|
|
}
|
|
|
|
// staffRoleMenu sets what a person may do on the pages under /admin.
|
|
func staffRoleMenu(u store.User) *menuDialog {
|
|
about := map[string]string{
|
|
store.RoleViewer: "looks at every administration page, changes nothing",
|
|
store.RoleOperator: "also runs the cluster: models, the mirror, nodes",
|
|
store.RoleBilling: "sees usage and quotas",
|
|
store.RoleAdmin: "may do anything",
|
|
"none": "no part in running the gateway",
|
|
}
|
|
var items []menuItem
|
|
for _, role := range append(slices.Clone(store.StaffRoles), "none") {
|
|
label := role + " — " + about[role]
|
|
if role == or(u.StaffRole(), "none") {
|
|
label = "✓ " + label
|
|
}
|
|
items = append(items, menuItem{label, func(m *consoleModel) tea.Cmd {
|
|
return m.runCLI("Staff role for "+u.Email, "users", "role", u.ID, role)
|
|
}})
|
|
}
|
|
return &menuDialog{title: "Staff role · " + u.Email, items: items}
|
|
}
|
|
|
|
func (m *consoleModel) createUserForm() {
|
|
m.openForm(&formDialog{title: "New account",
|
|
note: "Created already approved. The password should be changed by its owner.",
|
|
fields: []formField{
|
|
textF("email", "", ""),
|
|
pwF("password", "at least 12 characters"),
|
|
textF("project", "", "optional: a project to put them in"),
|
|
},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if !strings.Contains(v[0], "@") || v[1] == "" {
|
|
return nil, fmt.Errorf("an email and a password")
|
|
}
|
|
args := []string{"users", "create", v[0], "--password=" + v[1]}
|
|
if v[2] != "" {
|
|
args = append(args, "--project="+v[2])
|
|
}
|
|
return m.runCLI("New account", args...), nil
|
|
}})
|
|
}
|
|
|
|
// --- keys ---
|
|
|
|
func (m consoleModel) keyMenu(k store.KeyRow) *menuDialog {
|
|
name := k.Name + " (" + k.Prefix + ")"
|
|
return &menuDialog{title: name, items: []menuItem{
|
|
{"Its limits…", func(m *consoleModel) tea.Cmd { m.limitsForm(store.ScopeKey, k.ID, name); return nil }},
|
|
{"Rotate…", func(m *consoleModel) tea.Cmd {
|
|
m.openForm(&formDialog{title: "Rotate " + name,
|
|
note: "A new key with the same project, owner and limits is shown once; this one keeps working for the overlap, then stops.",
|
|
fields: []formField{textF("overlap", "24h", "how long both work, like 48h; 0 stops this one now")},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if _, err := time.ParseDuration(v[0]); err != nil {
|
|
return nil, fmt.Errorf("overlap is a duration, like 24h")
|
|
}
|
|
return m.runCLI("Rotate "+name, "keys", "rotate", k.ID, "--overlap="+v[0]), nil
|
|
}})
|
|
return nil
|
|
}},
|
|
{"Revoke…", func(m *consoleModel) tea.Cmd {
|
|
m.confirmD = &confirmDialog{title: "Revoke " + name,
|
|
text: "It stops working at once. It is kept, revoked, for the usage that refers to it.",
|
|
yes: func(m *consoleModel) tea.Cmd { return m.runCLI("Revoke "+name, "keys", "revoke", k.ID) }}
|
|
return nil
|
|
}},
|
|
}}
|
|
}
|
|
|
|
func (m *consoleModel) createKeyForm() {
|
|
m.openForm(&formDialog{title: "New API key",
|
|
note: "Shown once, here, and never again: only its hash is kept.",
|
|
fields: []formField{
|
|
textF("name", "", "what it is for"),
|
|
textF("project", "", "Acme/ml/training; empty with a person below is their personal key"),
|
|
textF("for", "", "optional: the person it belongs to, by email"),
|
|
textF("expires in", "", "optional: like 720h; empty never expires"),
|
|
},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if v[0] == "" {
|
|
return nil, fmt.Errorf("a name")
|
|
}
|
|
args := []string{"keys", "create", v[0]}
|
|
if v[1] != "" {
|
|
args = append(args, "--project="+v[1])
|
|
}
|
|
if v[2] != "" {
|
|
args = append(args, "--for="+v[2])
|
|
}
|
|
if v[3] != "" {
|
|
if _, err := time.ParseDuration(v[3]); err != nil {
|
|
return nil, fmt.Errorf("expires in is a duration, like 720h")
|
|
}
|
|
args = append(args, "--expires-in="+v[3])
|
|
}
|
|
return m.runCLI("New key "+v[0], args...), nil
|
|
}})
|
|
}
|
|
|
|
// --- limits, at any level ---
|
|
|
|
func (m *consoleModel) limitsForm(scope store.Scope, id, who string) {
|
|
l := m.people.limits[store.LimitOwner{Scope: scope, ID: id}.CounterKey()]
|
|
budget, period := "", "month"
|
|
if l.Budget != nil {
|
|
budget = strconv.FormatFloat(float64(*l.Budget)/1e6, 'f', -1, 64)
|
|
}
|
|
if l.BudgetPeriod != "" {
|
|
period = string(l.BudgetPeriod)
|
|
}
|
|
models := "every model"
|
|
if l.AllowedModels != nil {
|
|
models = strings.Join(l.AllowedModels, ",")
|
|
}
|
|
m.openForm(&formDialog{title: "Limits on " + who,
|
|
note: "Each level only narrows what is above it. Empty is no limit here.",
|
|
fields: []formField{
|
|
textF("requests/min", ptrText(l.RPM), ""),
|
|
textF("tokens/min", ptrText(l.TPM), ""),
|
|
textF("budget", budget, "spend per period, in whole units"),
|
|
choiceF("per", []string{"day", "month"}, period),
|
|
textF("models", models, "allowed, separated by commas; \"every model\" for no list"),
|
|
},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
args := []string{"keys", "limits", "--scope=" + string(scope), "--id=" + id}
|
|
for i, flag := range []string{"rpm", "tpm"} {
|
|
if v[i] != "" {
|
|
if _, err := strconv.Atoi(v[i]); err != nil {
|
|
return nil, fmt.Errorf("%s is a whole number", flag)
|
|
}
|
|
args = append(args, "--"+flag+"="+v[i])
|
|
}
|
|
}
|
|
if v[2] != "" {
|
|
if _, err := strconv.ParseFloat(v[2], 64); err != nil {
|
|
return nil, fmt.Errorf("the budget is a number")
|
|
}
|
|
args = append(args, "--budget="+v[2], "--period="+v[3])
|
|
}
|
|
if v[4] != "every model" {
|
|
args = append(args, "--models="+strings.ReplaceAll(v[4], " ", ""))
|
|
}
|
|
return m.runCLI("Limits on "+who, args...), nil
|
|
}})
|
|
}
|
|
|
|
// --- organizations ---
|
|
|
|
func (m consoleModel) orgMenu(r orgRow) *menuDialog {
|
|
child := map[string]string{"org": "team", "team": "project"}[r.level]
|
|
parent := map[string]string{"team": "organization", "project": "team"}[r.level]
|
|
var items []menuItem
|
|
if child != "" {
|
|
items = append(items, menuItem{"Add a " + child + " in it…", func(m *consoleModel) tea.Cmd {
|
|
m.openForm(&formDialog{title: "New " + child + " in " + r.path,
|
|
fields: []formField{textF("name", "", "")},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if v[0] == "" {
|
|
return nil, fmt.Errorf("a name")
|
|
}
|
|
return m.runCLI("New "+child, "org", "add", child, v[0], "--in="+r.id), nil
|
|
}})
|
|
return nil
|
|
}})
|
|
}
|
|
items = append(items, menuItem{"Rename…", func(m *consoleModel) tea.Cmd {
|
|
m.openForm(&formDialog{title: "Rename " + r.path,
|
|
fields: []formField{textF("name", r.name, "")},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
return m.runCLI("Rename "+r.path, "org", "rename", r.level, r.id, v[0]), nil
|
|
}})
|
|
return nil
|
|
}})
|
|
if parent != "" {
|
|
items = append(items, menuItem{"Move to another " + parent + "…", func(m *consoleModel) tea.Cmd {
|
|
m.openForm(&formDialog{title: "Move " + r.path,
|
|
fields: []formField{textF(parent, "", "")},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
return m.runCLI("Move "+r.path, "org", "move", r.level, r.id, "--to="+v[0]), nil
|
|
}})
|
|
return nil
|
|
}})
|
|
}
|
|
items = append(items,
|
|
menuItem{"Limits…", func(m *consoleModel) tea.Cmd { m.limitsForm(store.Scope(r.level), r.id, r.path); return nil }},
|
|
menuItem{"Limits for each member…", func(m *consoleModel) tea.Cmd {
|
|
m.limitsForm(store.MemberScope(store.Scope(r.level)), r.id, "each member of "+r.path)
|
|
return nil
|
|
}},
|
|
menuItem{"Guardrails…", func(m *consoleModel) tea.Cmd { m.menuD = m.guardMenu(r); return nil }},
|
|
menuItem{"Longest key lifetime…", func(m *consoleModel) tea.Cmd {
|
|
m.openForm(&formDialog{title: "Longest key lifetime · " + r.path,
|
|
note: "Keys made here get at most this, and this when none is asked for. Existing keys are not changed.",
|
|
fields: []formField{textF("longest", "", "like 720h; off removes the limit here")},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if v[0] != "off" {
|
|
if d, err := time.ParseDuration(v[0]); err != nil || d <= 0 {
|
|
return nil, fmt.Errorf("a duration like 720h, or off")
|
|
}
|
|
}
|
|
return m.runCLI("Key lifetime "+r.path, "keys", "ttl", r.level+":"+r.id, v[0]), nil
|
|
}})
|
|
return nil
|
|
}},
|
|
menuItem{"Capacity quota…", func(m *consoleModel) tea.Cmd {
|
|
q := adminapi.QuotaView{Scope: r.level + ":" + r.id}
|
|
for _, x := range m.quotas {
|
|
if x.Scope == q.Scope {
|
|
q = x
|
|
}
|
|
}
|
|
m.quotaForm(q)
|
|
return nil
|
|
}},
|
|
)
|
|
if r.level == "project" {
|
|
retention := func(label, sub, what string, now int) menuItem {
|
|
return menuItem{label + "…", func(m *consoleModel) tea.Cmd {
|
|
m.openForm(&formDialog{title: label + " · " + r.path,
|
|
note: "Off by default: llmgw keeps no " + what + " unless a project asks for it.",
|
|
fields: []formField{textF("hours", strconv.Itoa(now), "how long they are kept; 0 keeps none")},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
h, err := strconv.Atoi(v[0])
|
|
if err != nil || h < 0 {
|
|
return nil, fmt.Errorf("hours is a whole number")
|
|
}
|
|
args := []string{"keys", sub, "--project=" + r.id}
|
|
if h == 0 {
|
|
args = append(args, "--off")
|
|
} else {
|
|
args = append(args, "--retain-hours="+v[0])
|
|
}
|
|
return m.runCLI(label, args...), nil
|
|
}})
|
|
return nil
|
|
}}
|
|
}
|
|
items = append(items,
|
|
retention("Keep conversations", "threads", "conversations", m.people.threads[r.id]),
|
|
retention("Keep agent notes", "memory", "notes", m.people.notes[r.id]),
|
|
menuItem{"Requests stay in the cluster…", func(m *consoleModel) tea.Cmd {
|
|
m.menuD = &menuDialog{title: "Residency · " + r.path, items: []menuItem{
|
|
{"In the cluster: never an outside provider", func(m *consoleModel) tea.Cmd {
|
|
return m.runCLI("Residency", "keys", "residency", "--project="+r.id, "--in-cluster")
|
|
}},
|
|
{"Anywhere its keys allow", func(m *consoleModel) tea.Cmd {
|
|
return m.runCLI("Residency", "keys", "residency", "--project="+r.id, "--anywhere")
|
|
}},
|
|
}}
|
|
return nil
|
|
}},
|
|
menuItem{"Keep requests…", func(m *consoleModel) tea.Cmd {
|
|
l := m.people.requests[r.id]
|
|
sample := "1"
|
|
if l.Sample > 0 {
|
|
sample = strconv.FormatFloat(l.Sample, 'f', -1, 64)
|
|
}
|
|
m.openForm(&formDialog{title: "Keep requests · " + r.path,
|
|
note: "Off by default: llmgw keeps no requests unless a project asks for it.\n" +
|
|
"Kept, `llmgw ctl request ID --body` shows one and --replay sends it to another model.",
|
|
fields: []formField{
|
|
textF("hours", strconv.Itoa(int(l.Retain.Hours())), "how long they are kept; 0 keeps none"),
|
|
textF("share", sample, "of requests kept, above 0 and at most 1"),
|
|
},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
h, err := strconv.Atoi(v[0])
|
|
if err != nil || h < 0 {
|
|
return nil, fmt.Errorf("hours is a whole number")
|
|
}
|
|
args := []string{"keys", "requests", "--project=" + r.id}
|
|
if h == 0 {
|
|
return m.runCLI("Keep requests", append(args, "--off")...), nil
|
|
}
|
|
return m.runCLI("Keep requests", append(args, "--retain-hours="+v[0], "--sample="+v[1])...), nil
|
|
}})
|
|
return nil
|
|
}},
|
|
menuItem{"Cache answers…", func(m *consoleModel) tea.Cmd {
|
|
c := m.people.cache[r.id]
|
|
ttl, which := "off", "reproducible"
|
|
if c.TTL > 0 {
|
|
ttl = c.TTL.String()
|
|
}
|
|
if c.Any {
|
|
which = "any"
|
|
}
|
|
m.openForm(&formDialog{title: "Cache answers · " + r.path,
|
|
note: "Off by default. On, the same request to the same model within the time below is\n" +
|
|
"answered again from the gateway's memory, without the model, and costs nothing.\n" +
|
|
"Reproducible: only temperature 0 or a seed, and embeddings. Any: sampled answers too.",
|
|
fields: []formField{
|
|
textF("reuse for", ttl, "like 1h; off reuses none"),
|
|
choiceF("answers", []string{"reproducible", "any"}, which),
|
|
},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
args := []string{"keys", "cache", "--project=" + r.id}
|
|
if v[0] == "off" || v[0] == "0" {
|
|
return m.runCLI("Cache answers", append(args, "--off")...), nil
|
|
}
|
|
if d, err := time.ParseDuration(v[0]); err != nil || d < time.Second {
|
|
return nil, fmt.Errorf("a duration like 1h, or off")
|
|
}
|
|
args = append(args, "--ttl="+v[0])
|
|
if v[1] == "any" {
|
|
args = append(args, "--any")
|
|
}
|
|
return m.runCLI("Cache answers", args...), nil
|
|
}})
|
|
return nil
|
|
}})
|
|
}
|
|
items = append(items, menuItem{"Remove…", func(m *consoleModel) tea.Cmd {
|
|
m.confirmD = &confirmDialog{title: "Remove " + r.path,
|
|
text: "Only something empty is removed: an organization with no teams, a team with\nno projects, a project with no accounts and no keys.",
|
|
yes: func(m *consoleModel) tea.Cmd { return m.runCLI("Remove "+r.path, "org", "remove", r.level, r.id) }}
|
|
return nil
|
|
}})
|
|
return &menuDialog{title: r.level + " " + r.path, items: items}
|
|
}
|
|
|
|
// guardMenu attaches and detaches guardrail policies on a branch of the
|
|
// tree, saying which apply to it already from above.
|
|
func (m consoleModel) guardMenu(r orgRow) *menuDialog {
|
|
if len(m.people.guardPolicies) == 0 {
|
|
return &menuDialog{title: "Guardrails · " + r.path, items: []menuItem{{
|
|
"No policies: add a guard: section to server.yaml (docs/guardrails.md)", func(*consoleModel) tea.Cmd { return nil }}}}
|
|
}
|
|
var items []menuItem
|
|
for _, pol := range m.people.guardPolicies {
|
|
label, attached := " "+pol, false
|
|
switch from := m.guardFrom(r, pol); {
|
|
case from == r.path:
|
|
label, attached = "✓ "+pol+" — attached here, Enter detaches", true
|
|
case from != "":
|
|
label = "· " + pol + " — applies from " + from
|
|
default:
|
|
label += " — Enter attaches"
|
|
}
|
|
target := r.level + ":" + r.id
|
|
items = append(items, menuItem{label, func(m *consoleModel) tea.Cmd {
|
|
if attached {
|
|
return m.runCLI("Detach "+pol, "ctl", "guard", "detach", target, pol)
|
|
}
|
|
return m.runCLI("Attach "+pol, "ctl", "guard", "attach", target, pol)
|
|
}})
|
|
}
|
|
return &menuDialog{title: "Guardrails · " + r.path, items: items}
|
|
}
|
|
|
|
// guardFrom is where a policy applies to a branch from: "everywhere", the
|
|
// branch's own path, an ancestor's, or "" when it does not.
|
|
func (m consoleModel) guardFrom(r orgRow, policy string) string {
|
|
if slices.Contains(m.people.guardDefault, policy) {
|
|
return "everywhere"
|
|
}
|
|
paths := map[string]string{}
|
|
for _, o := range m.people.orgs {
|
|
paths[o.level+":"+o.id] = o.path
|
|
}
|
|
from := ""
|
|
for _, a := range m.people.guardAt {
|
|
if a.Policy != policy {
|
|
continue
|
|
}
|
|
p, ok := paths[a.Scope+":"+a.ScopeID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if p == r.path {
|
|
return p
|
|
}
|
|
if strings.HasPrefix(r.path, p+"/") {
|
|
from = p
|
|
}
|
|
}
|
|
return from
|
|
}
|
|
|
|
// guardCell names the policies attached to a branch itself.
|
|
func (m consoleModel) guardCell(r orgRow) string {
|
|
var names []string
|
|
for _, a := range m.people.guardAt {
|
|
if a.Scope == r.level && a.ScopeID == r.id {
|
|
names = append(names, a.Policy)
|
|
}
|
|
}
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
func (m *consoleModel) createOrgForm() {
|
|
m.openForm(&formDialog{title: "New organization",
|
|
fields: []formField{textF("name", "", "")},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if v[0] == "" {
|
|
return nil, fmt.Errorf("a name")
|
|
}
|
|
return m.runCLI("New organization", "org", "add", "org", v[0]), nil
|
|
}})
|
|
}
|
|
|
|
// --- the views ---
|
|
|
|
func (m consoleModel) peoplePanel(pw int) (string, string, string) {
|
|
title := consoleLabels[m.menu]
|
|
keys := "→ or Enter open · ↑↓ choose · Q leave"
|
|
if m.inPanel {
|
|
keys = "↑↓ row · Enter actions · A add · ← back · Q leave"
|
|
}
|
|
if m.people.err != nil {
|
|
return title, "\n " + sBad.Render("Cannot read the store: ") + m.people.err.Error(), keys
|
|
}
|
|
if m.people.users == nil && m.people.orgs == nil {
|
|
return title, "\n " + m.spin.View() + " reading the store…", keys
|
|
}
|
|
now := time.Now()
|
|
var head []string
|
|
var rows [][]string
|
|
switch m.menu {
|
|
case cAccounts:
|
|
head = []string{"EMAIL", "STATE", "STAFF", "MEMBER OF", "SIGNED UP", "LAST LOGIN"}
|
|
for _, u := range m.people.users {
|
|
state := string(u.State)
|
|
if u.State == store.UserPending {
|
|
state = sWarn.Render(state)
|
|
}
|
|
admin := u.StaffRole()
|
|
where := strings.Join(m.people.where[u.ID], "; ")
|
|
if where == "" {
|
|
where = sDim.Render("on their own")
|
|
}
|
|
rows = append(rows, []string{u.Email, state, admin, where, ago(u.CreatedAt, now), ago(u.LastLoginAt, now)})
|
|
}
|
|
case cKeys:
|
|
head = []string{"NAME", "KEY", "PROJECT", "OWNER", "STATE", "LAST USED"}
|
|
for _, k := range m.people.keys {
|
|
where := k.ProjectPath
|
|
if where == "" {
|
|
where = "personal"
|
|
}
|
|
rows = append(rows, []string{k.Name, k.Prefix, where, dash(k.OwnerEmail), keyState(k.Key, now), ago(k.LastUsedAt, now)})
|
|
}
|
|
case cOrgs:
|
|
head = []string{"NAME", "LEVEL", "MEMBERS", "KEYS", "LIMITS", "GUARDRAILS"}
|
|
for _, r := range m.people.orgs {
|
|
name := r.name
|
|
switch r.level {
|
|
case "team":
|
|
name = " " + name
|
|
case "project":
|
|
name = " " + name
|
|
}
|
|
keysCell := ""
|
|
if r.level == "project" {
|
|
keysCell = strconv.Itoa(r.keys)
|
|
}
|
|
rows = append(rows, []string{name, r.level, strconv.Itoa(r.members), keysCell,
|
|
limitsCell(m.people.limits[store.LimitOwner{Scope: store.Scope(r.level), ID: r.id}.CounterKey()]),
|
|
m.guardCell(r)})
|
|
}
|
|
}
|
|
if len(rows) == 0 {
|
|
return title, "\n " + sDim.Render("None yet. A adds one."), keys
|
|
}
|
|
return title, "\n" + table(head, rows, m.row, pw, m.inPanel), keys
|
|
}
|
|
|
|
func limitsCell(l store.Limits) string {
|
|
var p []string
|
|
if l.RPM != nil {
|
|
p = append(p, fmt.Sprintf("%d rpm", *l.RPM))
|
|
}
|
|
if l.TPM != nil {
|
|
p = append(p, fmt.Sprintf("%s tpm", compactNum(int64(*l.TPM))))
|
|
}
|
|
if l.Budget != nil {
|
|
p = append(p, fmt.Sprintf("%.2f/%s", float64(*l.Budget)/1e6, l.BudgetPeriod))
|
|
}
|
|
if l.AllowedModels != nil {
|
|
p = append(p, fmt.Sprintf("%d models", len(l.AllowedModels)))
|
|
}
|
|
return strings.Join(p, ", ")
|
|
}
|