35c6ff5a71
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
137 lines
5.3 KiB
Go
137 lines
5.3 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"errors"
|
|
"fmt"
|
|
"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 {
|
|
writeProblem(w, r, http.StatusServiceUnavailable, problemCoreUnavailable,
|
|
"models disabled (no -core)", nil)
|
|
return
|
|
}
|
|
mc, ok := swapConn, swapConn != nil
|
|
if !ok {
|
|
mc, ok = core.(modelController)
|
|
}
|
|
if !ok {
|
|
writeProblem(w, r, http.StatusServiceUnavailable, problemModelsUnavailable,
|
|
"models unavailable: core connection does not support model swap", nil)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
page := modelsPage{}
|
|
|
|
if r.Method == http.MethodPost {
|
|
if !stepUpOK(session, requireStepUp) {
|
|
writeProblem(w, r, http.StatusForbidden, problemStepUpRequired,
|
|
"step-up required: assert a passkey first", nil)
|
|
return
|
|
}
|
|
path := strings.TrimSpace(r.FormValue("model_path"))
|
|
if path == "" {
|
|
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
|
"model_path required", nil)
|
|
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):
|
|
writeProblem(w, r, http.StatusForbidden, problemModelsForbidden,
|
|
"refused: that model is not in phraser.swap_models, or step-up was not asserted",
|
|
fmt.Errorf("swap model %q: %w", path, err))
|
|
return
|
|
case errors.Is(err, ipc.ErrUnknownMethod):
|
|
writeProblem(w, r, http.StatusServiceUnavailable, problemModelsUnavailable,
|
|
"swap not configured on this core", fmt.Errorf("swap model %q: %w", path, err))
|
|
return
|
|
case res.NoBackend:
|
|
page.Err = inlineProblem(r, problemModelsUnavailable,
|
|
"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.",
|
|
fmt.Errorf("swap model %q and rollback: %w", path, err))
|
|
case res.RolledBack:
|
|
page.Err = inlineProblem(r, problemModelsUnavailable,
|
|
"swap failed, rolled back to "+res.Model+" — she is still answering, with the old model",
|
|
fmt.Errorf("swap model %q, rolled back to %q: %w", path, res.Model, err))
|
|
default:
|
|
page.Err = inlineProblem(r, problemModelsUnavailable,
|
|
"swap failed; the current model state is shown below",
|
|
fmt.Errorf("swap model %q: %w", path, err))
|
|
}
|
|
}
|
|
|
|
st, err := mc.ModelStatus(ctx)
|
|
if err != nil {
|
|
if errors.Is(err, ipc.ErrUnknownMethod) {
|
|
page.Off = true
|
|
} else {
|
|
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
|
"core read failed", fmt.Errorf("read model status: %w", err))
|
|
return
|
|
}
|
|
}
|
|
page.Status = st
|
|
renderPage(w, modelsTmpl, page)
|
|
}
|