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.
207 lines
7.6 KiB
Go
207 lines
7.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/webauthn"
|
|
)
|
|
|
|
// fakeModelCore is a core that supports the two model methods. It records what
|
|
// the page asked for, so the tests can assert the gate rather than the HTML.
|
|
type fakeModelCore struct {
|
|
ipc.UnimplementedCoreAPI
|
|
|
|
status ipc.ModelStatusResp
|
|
statusErr error
|
|
|
|
swapResp ipc.SwapModelResp
|
|
swapErr error
|
|
swapped []ipc.SwapModelReq
|
|
}
|
|
|
|
func (f *fakeModelCore) ModelStatus(ctx context.Context) (ipc.ModelStatusResp, error) {
|
|
return f.status, f.statusErr
|
|
}
|
|
|
|
func (f *fakeModelCore) SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error) {
|
|
f.swapped = append(f.swapped, req)
|
|
return f.swapResp, f.swapErr
|
|
}
|
|
|
|
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, nil, false)
|
|
return w
|
|
}
|
|
|
|
func modelsPOST(t *testing.T, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool, path string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
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, nil, session, requireStepUp)
|
|
return w
|
|
}
|
|
|
|
func TestModels_GETShowsTheLoadedModelAndTheAllowlist(t *testing.T) {
|
|
core := &fakeModelCore{status: ipc.ModelStatusResp{
|
|
Model: "Qwen3-1.7B-UD-Q4_K_XL",
|
|
ModelPath: "/opt/maven/models/llm/qwen3.gguf",
|
|
BaseURL: "http://127.0.0.1:18099",
|
|
NCtx: 4096,
|
|
Swappable: []string{"/opt/maven/models/llm/qwen3.gguf", "/opt/maven/models/llm/qwen3-cpt.gguf"},
|
|
}}
|
|
w := modelsGET(t, core)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GET /models = %d; want 200", w.Code)
|
|
}
|
|
body := w.Body.String()
|
|
for _, want := range []string{"Qwen3-1.7B-UD-Q4_K_XL", "qwen3-cpt.gguf", "4096"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("page does not mention %q", want)
|
|
}
|
|
}
|
|
if len(core.swapped) != 0 {
|
|
t.Errorf("a GET swapped the model: %v", core.swapped)
|
|
}
|
|
}
|
|
|
|
func TestModels_POSTRequiresStepUpWhenFailingClosed(t *testing.T) {
|
|
// No WebAuthn configured (nil session) + -require-stepup ⇒ deny, exactly
|
|
// like POST /tools. Nothing reaches core.
|
|
core := &fakeModelCore{}
|
|
w := modelsPOST(t, core, nil, true, "/opt/maven/models/llm/qwen3.gguf")
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("POST /models without assertable step-up = %d; want 403", w.Code)
|
|
}
|
|
if len(core.swapped) != 0 {
|
|
t.Fatalf("a denied POST still called SwapModel: %v", core.swapped)
|
|
}
|
|
}
|
|
|
|
func TestModels_POSTSwapsAndReportsTheModelThatAnswered(t *testing.T) {
|
|
core := &fakeModelCore{
|
|
swapResp: ipc.SwapModelResp{Model: "qwen3-cpt", ModelPath: "/m/cpt.gguf", TookMs: 4200},
|
|
status: ipc.ModelStatusResp{Model: "qwen3-cpt", ModelPath: "/m/cpt.gguf"},
|
|
}
|
|
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("POST /models = %d; want 200", w.Code)
|
|
}
|
|
if len(core.swapped) != 1 || core.swapped[0].ModelPath != "/m/cpt.gguf" {
|
|
t.Fatalf("SwapModel calls = %v; want one for /m/cpt.gguf", core.swapped)
|
|
}
|
|
if !strings.Contains(w.Body.String(), "loaded qwen3-cpt") {
|
|
t.Errorf("page does not report which model was loaded:\n%s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestModels_RolledBackSwapSaysSheIsStillAnswering(t *testing.T) {
|
|
core := &fakeModelCore{
|
|
swapResp: ipc.SwapModelResp{Model: "qwen3", ModelPath: "/m/old.gguf", RolledBack: true},
|
|
swapErr: errBrokenModel{},
|
|
status: ipc.ModelStatusResp{Model: "qwen3", ModelPath: "/m/old.gguf"},
|
|
}
|
|
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("POST /models after a rollback = %d; want 200 with the failure rendered", w.Code)
|
|
}
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, "rolled back to qwen3") {
|
|
t.Errorf("page does not say it rolled back:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestModels_RefusedPathIs403(t *testing.T) {
|
|
core := &fakeModelCore{swapErr: ipc.ErrForbidden}
|
|
w := modelsPOST(t, core, nil, false, "/etc/passwd")
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("POST /models with a non-allowlisted path = %d; want 403", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestModels_UnconfiguredCoreRendersOff(t *testing.T) {
|
|
core := &fakeModelCore{statusErr: ipc.ErrUnknownMethod}
|
|
w := modelsGET(t, core)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GET /models against a core without the swap = %d; want 200", w.Code)
|
|
}
|
|
if !strings.Contains(w.Body.String(), "swap not configured") {
|
|
t.Errorf("page does not say the capability is off:\n%s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestModels_CoreWithoutTheMethodsIs503(t *testing.T) {
|
|
// An in-process CoreAPI (no swap methods) must not 500 the page.
|
|
w := modelsGET(t, ipc.UnimplementedCoreAPI{})
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("GET /models on a core without the methods = %d; want 503", w.Code)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|