package main import ( "context" "errors" "html/template" "log" "net/http" "strconv" "strings" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/webauthn" ) // The resident-model surface (Vikunja #250). // // GET shows which model llama-server actually has loaded and which files the // daemon is configured to allow. POST swaps to one of them, behind the same // step-up gate as POST /tools: the loaded model decides how every utterance is // routed and how every reply is worded, so it is an owner action. // // There is nothing on this page Maven can press. The swap is an IPC method rated // AuthStepUp in internal/auth, unreachable from an act, an intent or a timer. // modelController — the two non-CoreAPI methods this page needs. *ipc.Client // satisfies it; a core without a swap allowlist answers ErrUnknownMethod, which // the page renders as "not configured" rather than an error. type modelController interface { ModelStatus(ctx context.Context) (ipc.ModelStatusResp, error) SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error) } var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(shellTopHTML + modelsHTML + shellBottomHTML)) const modelsHTML = `{{template "shellTop" "models"}}

Resident model

swapping requires step-up — assert a passkey first. The old model is unloaded before the new one is loaded (one model fits the iGPU at a time), so turns during the load are refused and fall back to the classifier.

a swap is not remembered. Nothing writes it down, so the next restart of the daemon — including the one mavupdate does — comes back on phraser.model_path from the config. Make it stick by editing that.

{{if .Msg}}
{{.Msg}}
{{end}} {{if .Err}}
{{.Err}}
{{end}} {{if .Off}}

swap not configured

this core has no phraser.swap_models allowlist, so there is nothing to swap to. Add the gguf paths you allow to deploy/mavend.json and restart once.

{{else}}

loaded now

model{{.Status.Model}}
file{{.Status.ModelPath}}
server{{.Status.BaseURL}}
n_ctx{{.Status.NCtx}}
n_gpu_layers{{.Status.NGpuLayers}}

the model name is what llama-server reports for itself, not what the config says it should be.

allowed models {{len .Status.Swappable}}

{{if .Status.Swappable}}
{{range .Status.Swappable}}{{end}}
file
{{.}}
{{else}}
no models allowlisted
{{end}}
{{end}} {{template "shellBottom"}}` type modelsPage struct { Msg string Err string Off bool Status ipc.ModelStatusResp } // handleModels renders the model surface (GET) and applies a swap (POST). // // A failed swap is reported as a failure with the model that is still serving // named, because that is the state the operator needs: the daemon rolled back // and is answering turns, it just is not answering them with what he asked for. // swapConn, when non-nil, is a SECOND connection to the same core, used for // nothing but this page. ipc.Client holds its mutex for a whole roundtrip and // neither side sets a read deadline, so a swap on the shared connection blocks // /dash, /history, /notifications and everything else for as long as the load // takes: a 90s drain plus a 60s launch plus a 30s probe, doubled if it rolls // back. No browser timeout frees them, because the server side keeps reading // the reply. On its own connection the swap only blocks the swap. func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, swapConn modelController, session *webauthn.PasskeySession, requireStepUp bool) { if core == nil { http.Error(w, "models disabled (no -core)", http.StatusServiceUnavailable) return } mc, ok := swapConn, swapConn != nil if !ok { mc, ok = core.(modelController) } if !ok { http.Error(w, "models unavailable: core connection does not support model swap", http.StatusServiceUnavailable) return } ctx := r.Context() page := modelsPage{} if r.Method == http.MethodPost { if !stepUpOK(session, requireStepUp) { http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) return } path := strings.TrimSpace(r.FormValue("model_path")) if path == "" { http.Error(w, "model_path required", http.StatusBadRequest) return } // Only the path comes off the form. n_ctx and n_gpu_layers are load // settings the daemon keeps from what is live, and the resident model is // a Thinking variant whose 4096-token window is sized for reasoning // tokens (CLAUDE.md). A field nothing renders, that a hand-crafted POST // could use to shrink the window under the router, is not worth having. // Changing them is a config edit and a restart. res, err := mc.SwapModel(ctx, ipc.SwapModelReq{ModelPath: path}) switch { case err == nil: page.Msg = "loaded " + res.Model + " (" + strconv.FormatInt(res.TookMs, 10) + "ms)" log.Printf("models: swapped to %s (%s) in %dms", res.ModelPath, res.Model, res.TookMs) case errors.Is(err, ipc.ErrForbidden): http.Error(w, "refused: that model is not in phraser.swap_models, or step-up was not asserted", http.StatusForbidden) return case errors.Is(err, ipc.ErrUnknownMethod): http.Error(w, "swap not configured on this core", http.StatusServiceUnavailable) return case res.NoBackend: page.Err = "swap failed AND the rollback failed — no model is loaded. She is answering from templates and routing on the classifier. Try loading a model again; a restart is not needed." log.Printf("models: swap to %s failed and the rollback failed, no model loaded: %v", path, err) case res.RolledBack: page.Err = "swap failed, rolled back to " + res.Model + " — she is still answering, with the old model" log.Printf("models: swap to %s failed, rolled back: %v", path, err) default: page.Err = "swap failed: " + err.Error() log.Printf("models: swap to %s failed: %v", path, err) } } st, err := mc.ModelStatus(ctx) if err != nil { if errors.Is(err, ipc.ErrUnknownMethod) { page.Off = true } else { log.Printf("models: status: %v", err) http.Error(w, "core read failed", http.StatusBadGateway) return } } page.Status = st w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := modelsTmpl.Execute(w, page); err != nil { log.Printf("models render: %v", err) } }