Files
llmgw/internal/web/account.go
T

724 lines
24 KiB
Go

package web
import (
"context"
"crypto/rand"
"encoding/base32"
"encoding/hex"
"errors"
"log/slog"
"net/http"
"strconv"
"strings"
"sync"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/account"
"gitea.mynodes.xyz/ilker/llmgw/internal/audit"
"gitea.mynodes.xyz/ilker/llmgw/internal/clientip"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// SessionCookie is the name of the cookie a signed-in browser carries.
const SessionCookie = "llmgw_session"
// Accounts is what the pages need from the account service.
type Accounts interface {
SignUp(ctx context.Context, email, name, password string) error
SignIn(ctx context.Context, email, password string) (token, csrf string, err error)
Resolve(ctx context.Context, token string) (store.Session, store.User, error)
SignOut(ctx context.Context, token string) error
CheckCSRF(sess store.Session, submitted string) bool
SignupOpen() bool
ChangePassword(ctx context.Context, pw account.PasswordChanger,
u store.User, current, next string) error
OpenSession(ctx context.Context, userID string) (token, csrf string, err error)
}
// KeyOwner is the store half of the profile page.
type KeyOwner interface {
KeysForUser(ctx context.Context, userID string) ([]store.Key, error)
CreateUserKey(ctx context.Context, userID string, k store.Key) error
RevokeUserKey(ctx context.Context, userID, keyID string, at time.Time) error
Resolve(ctx context.Context, keyID string) (store.Effective, error)
LimitsFor(ctx context.Context, scope store.Scope, id string) (store.Limits, error)
// Where a person may make keys, and what applies there.
ProjectsFor(ctx context.Context, userID string) ([]store.ProjectPath, error)
MembershipsOf(ctx context.Context, userID string) ([]store.Membership, error)
ResolveFor(ctx context.Context, callerID, userID, projectID string) (store.Effective, error)
}
// AccountPages serves signup, sign-in and the profile.
type AccountPages struct {
accounts Accounts
keys KeyOwner
passwords account.PasswordChanger
version string
log *slog.Logger
// served, when set, names the models an endpoint is currently serving. The
// curl example on this page fills its model field from it: every other
// field in that snippet is real, so the one placeholder left in it was
// copied verbatim and sent as the literal string MODEL.
served func() []string
// hasMirror is set when /admin/mirror is registered, so the other admin
// pages can link to it and only then.
hasMirror bool
// hasQuotas is set when /admin/quotas is registered.
hasQuotas bool
// oidc is sign-in through an identity provider; nil when there is none.
oidc *oidcLogin
// audit records every change made through these pages; nil records none.
audit audit.Sink
// hasGuard is set when /admin/guard is registered.
hasGuard bool
// guardFor names the guardrail policies in force for someone, and
// whether they keep nothing; nil when there are none.
guardFor func(guard.Subject) (string, bool)
// capacity sets capacity quotas, from the quotas page; nil leaves them off.
// history is the metrics store, for what only it knows: the GPU time
// each owner held. Nil without one.
history History
capacity CapacityAdmin
// personalOff refuses personal keys; see SetPersonalOff.
personalOff bool
// personalDefaults is what personal use falls under by default.
personalDefaults *store.Limits
// orgs is set when /org is mounted, so the menu can offer it to managers.
orgs OrgViewer
// llamaVersion is the llama.cpp build nodes install, for judging whether a
// GGUF can be served before it is fetched or catalogued.
llamaVersion string
// priced says whether a budget can ever be spent against.
priced Priced
// registry browses a model registry, or is nil where there is no way out.
registry Registry
// kinds remembers what each held GGUF file is, by its address: a held
// version never changes, so it is read once.
kinds sync.Map
// catalog puts a mirrored model into service; fleet says whether anything
// could run it. Both nil where the mirror page is read-only.
catalog CatalogAdmin
fleet Source
// The stores behind the people page's three tabs — the tree and its
// people, the limits, the usage — each set when its part is mounted, so
// the page draws the parts that exist.
peopleStore AccountAdmin
quotaStore QuotaAdmin
usageStore UsageAdmin
usagePriced Priced
}
// ServedModels supplies the names the profile page's example should use.
//
// A setter rather than a constructor argument because only the router knows
// what is being served, and the page is useful without it — with no source the
// example falls back to a placeholder, which is what it always was.
func (p *AccountPages) ServedModels(f func() []string) { p.served = f }
// exampleModel picks the model the copy-pasteable example should name.
//
// A real model over a simulated one is not expressible here — this package does
// not know which is which — so it takes the first served name and relies on the
// router listing real models first.
func (p *AccountPages) exampleModel() string {
if p.served == nil {
return "<model-name>"
}
if names := p.served(); len(names) > 0 {
return names[0]
}
return "<model-name>"
}
// NewAccountPages builds the handlers.
func NewAccountPages(a Accounts, k KeyOwner, pw account.PasswordChanger,
version string, log *slog.Logger,
) *AccountPages {
if log == nil {
log = slog.Default()
}
return &AccountPages{accounts: a, keys: k, passwords: pw, version: version, log: log}
}
// Register mounts the pages. They are all browser surfaces and none of them
// takes an API key, so the caller must exempt these paths from key
// authentication.
func (p *AccountPages) Register(mux *http.ServeMux) {
if p.accounts.SignupOpen() {
mux.HandleFunc("GET /signup", p.signupForm)
// Each signup costs a password hash and a row: a few a minute from
// one address is a person, more is a script.
mux.HandleFunc("POST /signup", rationed(newRation(5, 10), p.signup))
}
mux.HandleFunc("GET /login", p.loginForm)
mux.HandleFunc("POST /login", rationed(newRation(30, 30), p.login))
mux.HandleFunc("POST /logout", p.logout)
mux.HandleFunc("GET /profile", p.profile)
mux.HandleFunc("POST /profile/keys", p.selfAudited(p.createKey))
mux.HandleFunc("POST /profile/keys/{id}/revoke", p.selfAudited(p.revokeKey))
mux.HandleFunc("POST /profile/password", p.selfAudited(p.changePassword))
mux.HandleFunc("POST /profile/connect", p.selfAudited(p.connect))
mux.HandleFunc("GET /setup/{tool}", p.setupScript)
}
// --- signup ---
type signupData struct {
Email, Name, Error string
Done bool
MinPassword int
Version string
}
func (p *AccountPages) signupForm(w http.ResponseWriter, _ *http.Request) {
noStore(w)
render(w, "signup", signupData{MinPassword: account.MinPasswordLen, Version: p.version})
}
func (p *AccountPages) signup(w http.ResponseWriter, r *http.Request) {
noStore(w)
d := signupData{
Email: strings.TrimSpace(r.FormValue("email")),
Name: strings.TrimSpace(r.FormValue("name")),
MinPassword: account.MinPasswordLen,
Version: p.version,
}
err := p.accounts.SignUp(r.Context(), d.Email, d.Name, r.FormValue("password"))
switch {
case err == nil:
// The same page whether or not the address was already taken. Anything
// else is an account enumerator that anyone can run.
d.Done = true
case errors.Is(err, account.ErrWeakPassword), errors.Is(err, account.ErrInvalidEmail):
d.Error = err.Error()
default:
// Nothing of the database's words on the page; a reference that finds
// them in the log for whoever runs the gateway.
ref := errorRef()
p.log.Error("signup failed", "ref", ref, "err", err)
d.Error = "something went wrong on our side, and your account was not created. " +
"Try again later; if it keeps happening, give an operator this reference: " + ref
}
render(w, "signup", d)
}
// --- sign in and out ---
// ssoLabel is what the sign-in button names, or "" without one.
func (p *AccountPages) ssoLabel() string {
if p.oidc == nil {
return ""
}
return or(p.oidc.cfg.Label, "single sign-on")
}
type loginData struct {
Email, Error string
SignupOpen bool
Version string
// SSO is the identity provider's label, when sign-in through one is on.
SSO string
// CSRF is the form's token, matched by a cookie.
CSRF string
}
func (p *AccountPages) loginForm(w http.ResponseWriter, r *http.Request) {
noStore(w)
render(w, "login", loginData{SSO: p.ssoLabel(), SignupOpen: p.accounts.SignupOpen(), Version: p.version, CSRF: setLoginToken(w, r)})
}
func (p *AccountPages) login(w http.ResponseWriter, r *http.Request) {
noStore(w)
email := strings.TrimSpace(r.FormValue("email"))
if !loginTokenOK(r) {
// Not this site's form: another one posting a visitor into an
// account of its choosing, or a form left open past its hour.
renderStatus(w, http.StatusBadRequest, "login", loginData{SSO: p.ssoLabel(), Email: email, SignupOpen: p.accounts.SignupOpen(), Version: p.version,
Error: "the form had expired; sign in again", CSRF: setLoginToken(w, r)})
return
}
ctx := account.WithClient(r.Context(), clientAddr(r))
token, csrf, err := p.accounts.SignIn(ctx, email, r.FormValue("password"))
if err != nil {
d := loginData{SSO: p.ssoLabel(), Email: email, SignupOpen: p.accounts.SignupOpen(), Version: p.version, CSRF: setLoginToken(w, r)}
switch {
case errors.Is(err, account.ErrBadCredentials),
errors.Is(err, account.ErrNotApproved),
errors.Is(err, account.ErrSuspended),
errors.Is(err, account.ErrTooManyTries),
errors.Is(err, account.ErrBusy):
d.Error = err.Error()
default:
p.log.Error("sign-in failed", "err", err)
d.Error = "something went wrong; try again"
}
renderStatus(w, http.StatusUnauthorized, "login", d)
return
}
// Through the provider only, except an administrator's way in for when
// the provider itself is broken.
if p.oidc != nil && p.oidc.cfg.NoPasswords {
if _, u, err := p.accounts.Resolve(ctx, token); err != nil || !u.IsAdmin {
_ = p.accounts.SignOut(ctx, token)
renderStatus(w, http.StatusForbidden, "login", loginData{SSO: p.ssoLabel(), Email: email,
SignupOpen: p.accounts.SignupOpen(), Version: p.version, CSRF: setLoginToken(w, r),
Error: "this gateway signs people in with " + p.ssoLabel() + ", not passwords"})
return
}
}
setSessionCookie(w, r, token)
setCSRFCookie(w, r, csrf)
http.Redirect(w, r, "/profile", http.StatusSeeOther)
}
func (p *AccountPages) logout(w http.ResponseWriter, r *http.Request) {
noStore(w)
// Even signing out is a state change, so it needs its token: otherwise any
// page can sign a visitor out of this one.
sess, _, ok := p.session(r)
if ok && !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if c, err := r.Cookie(SessionCookie); err == nil {
if err := p.accounts.SignOut(r.Context(), c.Value); err != nil {
p.log.Warn("could not close a session", "err", err)
}
}
clearSessionCookie(w, r)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// --- profile ---
type limitRow struct{ Name, Value string }
// contextLimits is what applies to a person in one place they can spend.
type contextLimits struct {
Label string
Rows []limitRow
// Use is what the person spent here over the window, and Budgets how
// much is left of each budget that applies; both nil without usage.
Use *placeUse
Budgets []budgetBar
project string
chain []store.Limits
}
type profileData struct {
Nav Nav
// Contexts are where a key can be made: the person's projects, and
// themselves when personal use is on.
Contexts []ChatContext
// Where names each key's project by path; a personal key has none.
Where map[string]string
Memberships []store.Membership
LimitsBy []contextLimits
// Own is the person's use over the last thirty days; nil when it cannot
// be read.
Own *ownUse
User store.User
Keys []store.Key
Limits []limitRow
NewKey string
// Connect is an editor just connected: its key and configuration, shown
// once, like any new key.
Connect *connectView
// Tools and Models are what the connect form offers.
Tools []struct{ Key, Name string }
Models []string
Error string
PasswordChanged bool
PasswordError string
MinPassword int
CSRF string
BaseURL string
// ExampleModel is a model that is actually being served, so the example on
// the page can be copied and run as it stands.
ExampleModel string
Version string
Now string
}
func (p *AccountPages) profile(w http.ResponseWriter, r *http.Request) {
noStore(w)
sess, user, ok := p.session(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
p.renderProfile(w, r, sess, user, "", "")
}
func (p *AccountPages) createKey(w http.ResponseWriter, r *http.Request) {
noStore(w)
sess, user, ok := p.session(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
name = "untitled"
}
project := r.FormValue("context")
if project == personalValue {
if p.personalOff {
p.renderProfile(w, r, sess, user, "", "personal keys are off on this gateway; choose a project")
return
}
project = ""
}
secret, k, err := store.NewKey(randomKeyID(), name, project)
if err != nil {
p.log.Error("could not mint a key", "err", err)
p.renderProfile(w, r, sess, user, "", "something went wrong; try again")
return
}
k.CreatedAt = time.Now()
if err := p.keys.CreateUserKey(r.Context(), user.ID, k); err != nil {
if errors.Is(err, store.ErrNotMember) {
p.renderProfile(w, r, sess, user, "", "you are not a member of that project")
return
}
p.log.Error("could not store a key", "err", err)
p.renderProfile(w, r, sess, user, "", "something went wrong; try again")
return
}
p.log.Info("a user created a key", "user", user.ID, "key", k.ID)
// Shown on this response rather than after a redirect: a redirect would
// have to carry the key in a URL or a flash, and both put it somewhere it
// does not belong.
p.renderProfile(w, r, sess, user, secret, "")
}
func (p *AccountPages) revokeKey(w http.ResponseWriter, r *http.Request) {
noStore(w)
sess, user, ok := p.session(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Ownership is enforced in the statement, so a guessed key id from another
// account changes nothing.
err := p.keys.RevokeUserKey(r.Context(), user.ID, r.PathValue("id"), time.Now())
if err != nil && !errors.Is(err, store.ErrNotFound) {
p.log.Error("could not revoke a key", "err", err)
}
http.Redirect(w, r, "/profile", http.StatusSeeOther)
}
func (p *AccountPages) renderProfile(w http.ResponseWriter, r *http.Request,
sess store.Session, user store.User, newKey, errMsg string,
) {
p.render(w, r, sess, user, profileData{NewKey: newKey, Error: errMsg})
}
func (p *AccountPages) render(w http.ResponseWriter, r *http.Request,
sess store.Session, user store.User, d profileData,
) {
d.User = user
d.Nav = p.navFor(r, user, "keys")
d.MinPassword = account.MinPasswordLen
d.BaseURL = baseURL(r)
d.ExampleModel = p.exampleModel()
d.Tools = editorTools
if p.served != nil {
d.Models = p.served()
}
d.Version = p.version
d.Now = time.Now().UTC().Format(time.RFC3339)
// The form token comes from the cookie, never from whatever the request
// happened to carry: echoing a submitted value back into the next form
// would let an attacker choose both halves of the comparison.
d.CSRF = csrfFor(r)
_ = sess
keys, err := p.keys.KeysForUser(r.Context(), user.ID)
if err != nil {
p.log.Error("could not list keys", "err", err)
}
d.Keys = keys
p.fillPlaces(r.Context(), user, &d)
render(w, "profile", d)
}
// personalValue is how a form names the person's own, project-less use.
const personalValue = "personal"
// SetGuardPolicies has the profile say which guardrail policies apply where.
func (p *AccountPages) SetGuardPolicies(f func(guard.Subject) (string, bool)) { p.guardFor = f }
// SetPersonalOff says personal keys are refused on this gateway.
func (p *AccountPages) SetPersonalOff(off bool) { p.personalOff = off }
// fillPlaces adds where the person can make keys, where their keys are, and
// what applies in each place, resolved the way a request is: the whole chain
// above a project, per-member limits, and the person's own.
func (p *AccountPages) fillPlaces(ctx context.Context, user store.User, d *profileData) {
d.Where = map[string]string{}
projects, err := p.keys.ProjectsFor(ctx, user.ID)
if err != nil {
p.log.Warn("could not list a person's projects", "err", err)
}
for _, pp := range projects {
d.Contexts = append(d.Contexts, ChatContext{Value: pp.ID, Label: pp.Path})
d.Where[pp.ID] = pp.Path
}
if !p.personalOff {
d.Contexts = append(d.Contexts, ChatContext{Value: personalValue, Label: "Personal"})
}
if d.Memberships, err = p.keys.MembershipsOf(ctx, user.ID); err != nil {
p.log.Warn("could not list a person's memberships", "err", err)
}
for _, c := range d.Contexts {
project := c.Value
if project == personalValue {
project = ""
}
e, err := p.keys.ResolveFor(ctx, "session:"+user.ID, user.ID, project)
if err != nil {
continue
}
rows := limitRows(e)
if p.guardFor != nil {
names, keepNothing := p.guardFor(guard.Subject{UserID: user.ID, ProjectID: e.ProjectID,
TeamID: e.TeamID, OrgID: e.OrgID})
if names != "" {
v := strings.ReplaceAll(names, ",", ", ")
if keepNothing {
v += " — nothing is kept: no conversations, notes or outside models"
}
rows = append(rows, limitRow{"Guardrails", v})
}
}
d.LimitsBy = append(d.LimitsBy, contextLimits{Label: c.Label, Rows: rows,
project: project, chain: e.Chain})
}
p.fillOwnUse(ctx, user, d)
}
func limitRows(e store.Effective) []limitRow {
var out []limitRow
if e.RPM != nil {
out = append(out, limitRow{"Requests per minute", itoa(*e.RPM)})
}
if e.TPM != nil {
out = append(out, limitRow{"Tokens per minute", itoa(*e.TPM)})
}
if e.Budget != nil {
out = append(out, limitRow{"Budget", units(*e.Budget) + " per " + string(e.BudgetPeriod)})
}
if e.AllowedModels != nil {
if len(e.AllowedModels) == 0 {
out = append(out, limitRow{"Models", "none"})
} else {
out = append(out, limitRow{"Models", strings.Join(e.AllowedModels, ", ")})
}
}
return out
}
// --- helpers ---
func (p *AccountPages) session(r *http.Request) (store.Session, store.User, bool) {
c, err := r.Cookie(SessionCookie)
if err != nil {
return store.Session{}, store.User{}, false
}
sess, user, err := p.accounts.Resolve(r.Context(), c.Value)
if err != nil {
return store.Session{}, store.User{}, false
}
return sess, user, true
}
// csrfFor returns the token a form in this session must carry.
//
// The session stores only a hash, so the value itself has to travel with the
// browser. It rides the same cookie jar as the session under its own name,
// which is the double-submit pattern: an attacker's page can make the browser
// send our cookies but cannot read them to put one in a form field.
func csrfFor(r *http.Request) string {
if c, err := r.Cookie(csrfCookie); err == nil {
return c.Value
}
return ""
}
func setCSRFCookie(w http.ResponseWriter, r *http.Request, csrf string) {
// HttpOnly as well: the server renders it into forms, so no script needs to
// read it, and the attack this defends against works by making the browser
// send cookies it cannot read.
http.SetCookie(w, &http.Cookie{
Name: csrfCookie, Value: csrf, Path: "/",
HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
MaxAge: int(store.SessionTTL.Seconds()),
})
}
func randomKeyID() string {
var buf [10]byte
_, _ = rand.Read(buf[:])
return "key-" + strings.ToLower(
base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf[:]))
}
func itoa(v int) string { return strconv.Itoa(v) }
func units(micros int64) string {
return strconv.FormatFloat(float64(micros)/1e6, 'f', 2, 64)
}
const csrfCookie = "llmgw_csrf"
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookie, Value: token, Path: "/",
HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
MaxAge: int(store.SessionTTL.Seconds()),
})
}
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
for _, name := range []string{SessionCookie, csrfCookie} {
http.SetCookie(w, &http.Cookie{
Name: name, Value: "", Path: "/",
HttpOnly: name == SessionCookie, Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode, MaxAge: -1,
})
}
}
// isHTTPS decides whether to mark cookies Secure.
//
// Behind a reverse proxy this process sees plain HTTP, so the forwarded header
// is the only thing that knows. Getting it wrong in the safe direction — marking
// Secure when the connection is plain — would break sign-in over http://, which
// is why it is not simply hardcoded.
func isHTTPS(r *http.Request) bool {
return clientip.Proto(r) == "https"
}
func noStore(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
}
func (p *AccountPages) changePassword(w http.ResponseWriter, r *http.Request) {
noStore(w)
sess, user, ok := p.session(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
err := p.accounts.ChangePassword(r.Context(), p.passwords, user,
r.FormValue("current"), r.FormValue("new"))
if err != nil {
d := profileData{}
switch {
case errors.Is(err, account.ErrBadCredentials):
d.PasswordError = "that is not your current password"
case errors.Is(err, account.ErrWeakPassword),
errors.Is(err, account.ErrSamePassword),
errors.Is(err, account.ErrTooManyTries):
d.PasswordError = err.Error()
default:
p.log.Error("could not change a password", "err", err)
d.PasswordError = "something went wrong; try again"
}
p.render(w, r, sess, user, d)
return
}
// The change closed every session including this one, which is the point.
// Open a new one for the browser in front of us, so the person who just
// proved their password is not thrown back to the sign-in page.
token, csrf, err := p.accounts.OpenSession(r.Context(), user.ID)
if err != nil {
p.log.Error("could not reopen a session", "err", err)
clearSessionCookie(w, r)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
setSessionCookie(w, r, token)
setCSRFCookie(w, r, csrf)
// The new CSRF cookie is set on this response, so the forms rendered now
// must carry the same value rather than the one the request arrived with.
newSess, newUser, rerr := p.accounts.Resolve(r.Context(), token)
if rerr != nil {
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
}
d := profileData{PasswordChanged: true}
d.CSRF = csrf
p.renderWithCSRF(w, r, newSess, newUser, d)
}
// renderWithCSRF is render for the one case where the token in the cookie jar
// is not yet the one the browser has: the response that sets a new one.
func (p *AccountPages) renderWithCSRF(w http.ResponseWriter, r *http.Request,
sess store.Session, user store.User, d profileData,
) {
csrf := d.CSRF
d.User = user
d.MinPassword = account.MinPasswordLen
d.BaseURL = baseURL(r)
d.ExampleModel = p.exampleModel()
d.Version = p.version
d.Now = time.Now().UTC().Format(time.RFC3339)
d.CSRF = csrf
// The menu's sign-out form too: it is the first form on the page.
d.Nav = p.navFor(r, user, "keys")
d.Nav.CSRF = csrf
_ = sess
keys, err := p.keys.KeysForUser(r.Context(), user.ID)
if err != nil {
p.log.Error("could not list keys", "err", err)
}
d.Keys = keys
p.fillPlaces(r.Context(), user, &d)
render(w, "profile", d)
}
// SetLlamaCppVersion tells the pages which llama.cpp build the nodes run.
func (p *AccountPages) SetLlamaCppVersion(v string) { p.llamaVersion = v }
// errorRef is a short reference for an error a page does not explain, logged
// beside the error itself.
func errorRef() string {
var b [4]byte
_, _ = rand.Read(b[:])
return "err-" + hex.EncodeToString(b[:])
}
// SetHistory gives the pages the metrics store.
func (p *AccountPages) SetHistory(h History) { p.history = h }