3ff2a9340a
The drain counted only the phrasing paths in internal/phraser. The router, the replier, the mail extractor and the memory evaluator reach llama-server through llm.Client, so quiesce could report zero requests in flight while the router was mid-generation, and the old server was killed under it. The turn then finished on the new model, which is the split turn the swap exists to prevent. llm.Client now enters an optional Gate before every completion and LLMPhraser implements it, so one counter covers every holder of the base URL. A total failure also reported itself as a rollback. Swap set RolledBack on the path where the rollback failed too, so the page rendered "rolled back to — she is still answering, with the old model" over an empty model name and a daemon with no model at all. The total failure has its own flag now, LiveModel stops naming a gguf that is not loaded, and the log says another attempt can recover without a restart, which is true. The swap also ran on the connection every other page shares. ipc.Client holds its mutex for a whole roundtrip with no read deadline on either side, so a load froze /dash, /history and /notifications for minutes. mavweb dials a second connection for /models alone. POST /models joins the route table, and the load settings no longer come off a form that renders no input for them. Found in review of #68.
162 lines
7.0 KiB
Go
162 lines
7.0 KiB
Go
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"}}
|
|
<h1>Resident model</h1>
|
|
<p class=hint>swapping requires step-up — <a href=/auth/passkey>assert a passkey</a> 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.</p>
|
|
<p class=hint>a swap is not remembered. Nothing writes it down, so the next restart of the daemon — including the one <code>mavupdate</code> does — comes back on <code>phraser.model_path</code> from the config. Make it stick by editing that.</p>
|
|
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
|
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
|
|
{{if .Off}}
|
|
<section class=card>
|
|
<h2 class=card-title>swap not configured</h2>
|
|
<p class=hint>this core has no <code>phraser.swap_models</code> allowlist, so there is nothing to swap to. Add the gguf paths you allow to <code>deploy/mavend.json</code> and restart once.</p>
|
|
</section>
|
|
{{else}}
|
|
<section class=card>
|
|
<h2 class=card-title>loaded now</h2>
|
|
<div class=scroll><table>
|
|
<tr><th>model</th><td><code>{{.Status.Model}}</code></td></tr>
|
|
<tr><th>file</th><td><code>{{.Status.ModelPath}}</code></td></tr>
|
|
<tr><th>server</th><td><code>{{.Status.BaseURL}}</code></td></tr>
|
|
<tr><th>n_ctx</th><td>{{.Status.NCtx}}</td></tr>
|
|
<tr><th>n_gpu_layers</th><td>{{.Status.NGpuLayers}}</td></tr>
|
|
</table></div>
|
|
<p class=hint>the model name is what llama-server reports for itself, not what the config says it should be.</p>
|
|
</section>
|
|
<section class=card>
|
|
<h2 class=card-title>allowed models <span class=badge>{{len .Status.Swappable}}</span></h2>
|
|
{{if .Status.Swappable}}<div class=scroll><table><tr><th>file</th><th></th></tr>
|
|
{{range .Status.Swappable}}<tr><td><code>{{.}}</code></td>
|
|
<td><form method=post action=/models class=inline-form>
|
|
<input type=hidden name=model_path value="{{.}}">
|
|
<button class=btn>load this one</button></form></td></tr>{{end}}
|
|
</table></div>
|
|
{{else}}<div class=empty><div>no models allowlisted</div></div>{{end}}
|
|
</section>
|
|
{{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)
|
|
}
|
|
}
|