package main import ( "context" _ "embed" "errors" "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) } //go:embed models.html var modelsHTML string var modelsTmpl = parsePage("models", modelsHTML, nil) 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 renderPage(w, modelsTmpl, page) }