Merge branch 'fix/g07' into fix/integrated
# Conflicts: # internal/ipc/api.go # internal/ipc/client.go # internal/llm/client.go
This commit is contained in:
+13
-1
@@ -364,6 +364,11 @@ func main() {
|
||||
flag.Parse()
|
||||
|
||||
var core ipc.CoreAPI
|
||||
// swapConn — a second connection, for /models and nothing else. A model swap
|
||||
// is a multi-minute IPC call and ipc.Client serialises everything on one
|
||||
// mutex, so sharing the connection would freeze every other page for the
|
||||
// length of the load. See handleModels.
|
||||
var swapConn modelController
|
||||
if *coreSock != "" {
|
||||
c, err := ipc.DialWait(*coreSock, 60*time.Second)
|
||||
if err != nil {
|
||||
@@ -371,6 +376,12 @@ func main() {
|
||||
}
|
||||
defer c.Close()
|
||||
core = c
|
||||
if sc, err := ipc.Dial(*coreSock); err != nil {
|
||||
log.Printf("models: second core connection failed (%v) — /models will share the main one and a swap will block the other pages", err)
|
||||
} else {
|
||||
defer sc.Close()
|
||||
swapConn = sc
|
||||
}
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
@@ -516,13 +527,14 @@ func main() {
|
||||
// /tools, and for a comparable reason: which model is loaded decides how every
|
||||
// utterance is routed and how every reply is worded. GET is read-only.
|
||||
mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleModels(w, r, core, stepUpSession, *requireStepUp)
|
||||
handleModels(w, r, core, swapConn, stepUpSession, *requireStepUp)
|
||||
})
|
||||
|
||||
// State-changing routes on this server, and their gate (Vikunja #317):
|
||||
//
|
||||
// POST /tools step-up — defines argv that internal/tool executes
|
||||
// POST /routines step-up — accepting schedules recurring firing
|
||||
// POST /models step-up — replaces the model that routes and phrases
|
||||
// POST /api/revert step-up — voids the latest fact for a key
|
||||
// POST /api/chat step-up — reaches the router, LLM and the act path
|
||||
// POST /api/ptt step-up — audio into runTurn, so the same router,
|
||||
|
||||
+23
-7
@@ -36,6 +36,7 @@ var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(
|
||||
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}}
|
||||
@@ -80,12 +81,22 @@ type modelsPage struct {
|
||||
// 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.
|
||||
func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
// 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 := core.(modelController)
|
||||
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
|
||||
@@ -103,11 +114,13 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sess
|
||||
http.Error(w, "model_path required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req := ipc.SwapModelReq{ModelPath: path}
|
||||
if v, err := strconv.Atoi(r.FormValue("n_ctx")); err == nil {
|
||||
req.NCtx = v
|
||||
}
|
||||
res, err := mc.SwapModel(ctx, req)
|
||||
// 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)"
|
||||
@@ -118,6 +131,9 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sess
|
||||
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)
|
||||
|
||||
@@ -36,7 +36,7 @@ func (f *fakeModelCore) SwapModel(ctx context.Context, req ipc.SwapModelReq) (ip
|
||||
func modelsGET(t *testing.T, core ipc.CoreAPI) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
handleModels(w, httptest.NewRequest(http.MethodGet, "/models", nil), core, nil, false)
|
||||
handleModels(w, httptest.NewRequest(http.MethodGet, "/models", nil), core, nil, nil, false)
|
||||
return w
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func modelsPOST(t *testing.T, core ipc.CoreAPI, session *webauthn.PasskeySession
|
||||
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path="+path))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
handleModels(w, r, core, session, requireStepUp)
|
||||
handleModels(w, r, core, nil, session, requireStepUp)
|
||||
return w
|
||||
}
|
||||
|
||||
@@ -148,3 +148,59 @@ func TestModels_CoreWithoutTheMethodsIs503(t *testing.T) {
|
||||
type errBrokenModel struct{}
|
||||
|
||||
func (errBrokenModel) Error() string { return "llm: server did not start" }
|
||||
|
||||
func TestModels_TotalFailureDoesNotSaySheIsStillAnswering(t *testing.T) {
|
||||
// The load failed and so did the rollback: nothing is loaded. The page used
|
||||
// to branch on RolledBack first and render "rolled back to — she is still
|
||||
// answering, with the old model" over an empty model name.
|
||||
core := &fakeModelCore{
|
||||
swapResp: ipc.SwapModelResp{NoBackend: true},
|
||||
swapErr: errBrokenModel{},
|
||||
status: ipc.ModelStatusResp{Model: "unknown"},
|
||||
}
|
||||
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST /models after a total failure = %d; want 200 with the failure rendered", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "still answering") {
|
||||
t.Errorf("the page claims she is still answering while no model is loaded:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "no model is loaded") {
|
||||
t.Errorf("the page does not name the state the operator is in:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModels_POSTIgnoresLoadSettingsOffTheForm(t *testing.T) {
|
||||
// n_ctx was read off a form that renders no such input, so only a
|
||||
// hand-crafted POST could set it. The resident model is a Thinking variant
|
||||
// whose window is sized for reasoning tokens; shrinking it from the wire is
|
||||
// not a capability this page offers.
|
||||
core := &fakeModelCore{swapResp: ipc.SwapModelResp{Model: "qwen3-cpt"}}
|
||||
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path=/m/cpt.gguf&n_ctx=512&n_gpu_layers=0"))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
handleModels(w, r, core, nil, nil, false)
|
||||
if len(core.swapped) != 1 {
|
||||
t.Fatalf("SwapModel calls = %v; want one", core.swapped)
|
||||
}
|
||||
if got := core.swapped[0]; got.NCtx != 0 || got.NGpuLayers != 0 {
|
||||
t.Errorf("swap request = %+v; want the load settings left to the daemon", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModels_SwapUsesItsOwnConnection(t *testing.T) {
|
||||
// A swap is a multi-minute IPC call and ipc.Client serialises everything on
|
||||
// one mutex, so it must not run on the connection every other page shares.
|
||||
shared := &fakeModelCore{status: ipc.ModelStatusResp{Model: "qwen3"}}
|
||||
swapConn := &fakeModelCore{swapResp: ipc.SwapModelResp{Model: "qwen3-cpt"}}
|
||||
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path=/m/cpt.gguf"))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
handleModels(httptest.NewRecorder(), r, shared, swapConn, nil, false)
|
||||
if len(shared.swapped) != 0 {
|
||||
t.Errorf("the swap went out on the shared connection: %v", shared.swapped)
|
||||
}
|
||||
if len(swapConn.swapped) != 1 {
|
||||
t.Errorf("the swap did not use the dedicated connection: %v", swapConn.swapped)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user