feat: model selection at spawn (POST /api/spawn model -> LVMH_MODEL -> bridge createAgentSession); ops prepare resets ops session (fresh context per prepare)

This commit is contained in:
Raphael Westphal
2026-08-20 12:43:55 +02:00
parent 5292a69d66
commit 2d0839357d
11 changed files with 553 additions and 48 deletions
+107
View File
@@ -575,3 +575,110 @@ func TestParseEnabledModels(t *testing.T) {
t.Fatal("invalid json must yield nil")
}
}
func TestAPISpawnModelValidation(t *testing.T) {
ts, _ := newSpawnAPIServer(t)
auth := testToken
// invalid model spec -> 400 before any spawn work
if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", auth,
`{"repo":"g/p","model":"no-slash"}`); code != http.StatusBadRequest {
t.Fatalf("bad model = %d %s, want 400", code, body)
}
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", auth,
`{"repo":"g/p","model":"zai-renaud/glm-5.3"}`); code != http.StatusCreated {
t.Fatal("valid provider/model-id must pass")
}
}
func TestAPIHandlersStoreFailuresCovered(t *testing.T) {
ts, store, _ := newTestServerHub(t)
auth := testToken
_ = store.Close()
paths := []struct {
method, path, body string
want int
}{
{"GET", "/api/sessions", "", http.StatusOK}, // swallows store errors by design
{"GET", "/api/sessions/s1/events", "", http.StatusInternalServerError},
{"GET", "/api/sessions/s1/stats", "", http.StatusInternalServerError},
{"POST", "/api/sessions/s1/prompt", `{"message":"hi"}`, http.StatusConflict},
{"POST", "/api/sessions/s1/abort", "", http.StatusConflict},
{"POST", "/api/sessions/s1/model", `{"provider":"p","modelId":"m"}`, http.StatusConflict},
{"PATCH", "/api/sessions/s1", `{"name":"n"}`, http.StatusInternalServerError},
{"GET", "/api/stats", "", http.StatusInternalServerError},
}
for _, tc := range paths {
code, body := apiReq(t, tc.method, ts.URL+tc.path, auth, tc.body)
if code != tc.want {
t.Errorf("%s %s = %d %s, want %d", tc.method, tc.path, code, body, tc.want)
}
}
}
func TestModelCatalogFallbackPaths(t *testing.T) {
ts, _ := newTestServer(t)
dir := t.TempDir()
minimal := filepath.Join(dir, "minimal-models.json")
if err := os.WriteFile(minimal, []byte(`{"providers":{"p":{"models":[{"id":"m1","name":"M1"}]}}}`), 0o644); err != nil {
t.Fatal(err)
}
settings := filepath.Join(dir, "settings.json")
if err := os.WriteFile(settings, []byte(`{"enabledModels":["anthropic/claude-x","p/m1","junk"]}`), 0o644); err != nil {
t.Fatal(err)
}
t.Run("explicit-file-when-baked-missing", func(t *testing.T) {
t.Setenv(envModelsFile, minimal)
code, body := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, "")
if code != http.StatusOK || !strings.Contains(body, `"id":"m1"`) {
t.Fatalf("explicit = %d %s", code, body)
}
})
t.Run("baked-preferred-with-settings-merge", func(t *testing.T) {
t.Setenv(envModelsFile, "")
// pretend the baked dotfiles models.json exists by pointing the
// test at a temp file via the same stat+read the handler uses.
t.Setenv("LVMH_TEST_BAKED_MODELS", minimal)
t.Setenv(envSettingsFile, "LVMH_SETTINGS_FILE")
t.Setenv("LVMH_SETTINGS_FILE", settings)
code, body := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, "")
if code != http.StatusOK {
t.Fatalf("merge = %d %s", code, body)
}
if !strings.Contains(body, "claude-x") {
t.Fatalf("enabledModels not merged: %s", body)
}
if strings.Count(body, `"id":"m1"`) != 1 {
t.Fatalf("dedupe broken: %s", body)
}
})
t.Run("unreadable-file-500", func(t *testing.T) {
t.Setenv(envModelsFile, filepath.Join(dir, "nope.json"))
code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, "")
if code != http.StatusInternalServerError {
t.Fatalf("missing file = %d, want 500", code)
}
})
t.Run("garbage-json-500", func(t *testing.T) {
bad := filepath.Join(dir, "bad.json")
if err := os.WriteFile(bad, []byte("{{"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv(envModelsFile, bad)
code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, "")
if code != http.StatusInternalServerError {
t.Fatalf("garbage = %d, want 500", code)
}
})
}
func TestRenameAndSetModelBodyValidation(t *testing.T) {
ts, _ := newTestServer(t)
if code, _ := apiReq(t, http.MethodPatch, ts.URL+"/api/sessions/s1", testToken, "not json"); code != http.StatusBadRequest {
t.Fatalf("bad rename body = %d, want 400", code)
}
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/model", testToken, "nope"); code != http.StatusBadRequest {
t.Fatalf("bad model body = %d, want 400", code)
}
}