Moves the agent model out of env-only config into an admin-editable Settings
section, and enforces is_admin for the first time — it has been in the schema
since migration 0001, plumbed to the client, and checked nowhere.
Backend:
- Migration 0010: instance_settings, a single-row (CHECK id=1) table — pansy's
first instance-level state. Holds agent_model ('' = inherit env) and
agent_enabled (NULL = inherit env), version-guarded like every mutable row.
SECRETS STAY IN ENV: OLLAMA_CLOUD_API_KEY is never stored here.
- requireAdmin at the service seam (authoritative) plus a cheap middleware
early-403. Non-admin gets 403, not 404 — settings existence isn't masked.
- EffectiveAgent resolves DB-over-env (model, enabled); key always from env.
- The live Runner is hot-swapped, not built once. agentHolder holds it behind
an atomic.Pointer; the chat routes are now registered UNCONDITIONALLY and
nil-check agent.get(), so a settings change turns the assistant on/off/onto a
new model with no restart and no race against in-flight readers. /capabilities
reads the pointer, so it reports what's live, not what booted.
- internal/agentmodel is a new leaf package holding the one place that knows how
to turn a spec into a model. Both agent (to run) and service (to validate a
spec before storing it) import it; it can't live in agent, which imports
service. Settings PATCH validates the spec via Parse, so a typo is a 400 now
rather than a broken assistant on the next turn.
Frontend:
- /settings route (admin guard), a Settings page (model field, tri-state
enabled, live status), nav link shown only to admins.
- useCapabilities drops staleTime:Infinity — the assistant can now change under
a running page — and the settings save invalidates it.
Contract change: chat routes always exist, so "assistant off" is a runtime 503
+ capabilities:false, not a missing route. Updated the test that asserted the
old shape.
Verified live against the built binary: disable flips capabilities to false and
logs it; re-enable with a new model swaps it back; a bad spec is rejected 400;
the setting persists across a restart. Swap is race-clean under `go test -race`.
Docs: README (precedence + key-stays-in-env), DESIGN (decision + routes),
CLAUDE (don't re-add conditional route registration; key never in the DB).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
209 lines
8.1 KiB
Go
209 lines
8.1 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
|
)
|
|
|
|
// agentCfg is a config with the assistant configured — a (fake) key, on, and a
|
|
// resolvable model. The key isn't real, but ValidateAgentModel/NewRunner only
|
|
// PARSE the spec (no live call), so a Runner still builds and capabilities
|
|
// reports it live. That's enough to exercise the runtime on/off swap.
|
|
func agentCfg() *config.Config {
|
|
c := localCfg()
|
|
c.Agent = config.AgentConfig{
|
|
Model: "ollama-cloud/glm-5.2:cloud",
|
|
OllamaCloudAPIKey: "test-key",
|
|
Enabled: true,
|
|
}
|
|
return c
|
|
}
|
|
|
|
func settingsVersion(t *testing.T, r *gin.Engine, cookie *http.Cookie) int64 {
|
|
t.Helper()
|
|
w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, cookie)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("get settings: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
st, _ := decodeMap(t, w.Body.Bytes())["settings"].(map[string]any)
|
|
return int64(st["version"].(float64))
|
|
}
|
|
|
|
// TestSettingsAdminOnly: the first registered user is admin and can read/write
|
|
// settings; a second user is not and gets 403 (not 404 — settings aren't a
|
|
// masked resource).
|
|
func TestSettingsAdminOnly(t *testing.T) {
|
|
r := authEngine(t, localCfg())
|
|
admin := registerAndCookie(t, r, "[email protected]") // first user → admin
|
|
member := registerAndCookie(t, r, "[email protected]")
|
|
|
|
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, admin); w.Code != http.StatusOK {
|
|
t.Fatalf("admin GET settings: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, member); w.Code != http.StatusForbidden {
|
|
t.Errorf("member GET settings: status %d, want 403", w.Code)
|
|
}
|
|
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
|
map[string]any{"agentModel": "x", "version": 1}, member); w.Code != http.StatusForbidden {
|
|
t.Errorf("member PATCH settings: status %d, want 403", w.Code)
|
|
}
|
|
// Unauthenticated is 401, before the admin check.
|
|
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, nil); w.Code != http.StatusUnauthorized {
|
|
t.Errorf("anonymous GET settings: status %d, want 401", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestSettingsInheritFromEnv: an untouched instance reports the env model as
|
|
// effective, and an empty stored model keeps inheriting it.
|
|
func TestSettingsInheritFromEnv(t *testing.T) {
|
|
r := authEngine(t, agentCfg())
|
|
admin := registerAndCookie(t, r, "[email protected]")
|
|
|
|
w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, admin)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("get: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
body := decodeMap(t, w.Body.Bytes())
|
|
st := body["settings"].(map[string]any)
|
|
eff := body["effective"].(map[string]any)
|
|
|
|
if st["agentModel"] != "" {
|
|
t.Errorf("stored model = %v, want empty (inherit)", st["agentModel"])
|
|
}
|
|
if eff["model"] != "ollama-cloud/glm-5.2:cloud" {
|
|
t.Errorf("effective model = %v, want the env value", eff["model"])
|
|
}
|
|
if eff["hasApiKey"] != true || eff["agentLive"] != true {
|
|
t.Errorf("effective = %+v, want a key present and the agent live", eff)
|
|
}
|
|
}
|
|
|
|
// TestSettingsUpdateSwapsTheRunner is the core of #79: changing settings takes
|
|
// effect on the LIVE assistant, with no restart. Driven entirely through HTTP.
|
|
func TestSettingsUpdateSwapsTheRunner(t *testing.T) {
|
|
r := authEngine(t, agentCfg())
|
|
admin := registerAndCookie(t, r, "[email protected]")
|
|
|
|
capsAgent := func() bool {
|
|
w := doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, admin)
|
|
return decodeMap(t, w.Body.Bytes())["agent"] == true
|
|
}
|
|
|
|
// Configured and on out of the box.
|
|
if !capsAgent() {
|
|
t.Fatal("assistant should be live at boot with a key + enabled")
|
|
}
|
|
|
|
// Turn it OFF via settings → capabilities flips immediately.
|
|
v := settingsVersion(t, r, admin)
|
|
w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
|
map[string]any{"agentModel": "", "agentEnabled": false, "version": v}, admin)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("disable: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
if capsAgent() {
|
|
t.Error("assistant still live after being disabled — the runner wasn't swapped")
|
|
}
|
|
// Chat now refuses, at runtime, on a route that still exists.
|
|
gid := createGardenAPI(t, r, admin, "G")
|
|
if w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat",
|
|
map[string]any{"gardenId": gid, "message": "hi"}, admin); w.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("chat while disabled: status %d, want 503", w.Code)
|
|
}
|
|
|
|
// Turn it back ON, with an explicit model, and confirm it's live again and the
|
|
// effective model reflects the change.
|
|
v = settingsVersion(t, r, admin)
|
|
w = doJSON(t, r, http.MethodPatch, "/api/v1/settings", map[string]any{
|
|
"agentModel": "ollama-cloud/kimi-k2.6:cloud", "agentEnabled": true, "version": v,
|
|
}, admin)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("re-enable: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
if eff := decodeMap(t, w.Body.Bytes())["effective"].(map[string]any); eff["model"] != "ollama-cloud/kimi-k2.6:cloud" {
|
|
t.Errorf("effective model = %v after change, want the new one", eff["model"])
|
|
}
|
|
if !capsAgent() {
|
|
t.Error("assistant not live after being re-enabled")
|
|
}
|
|
}
|
|
|
|
// TestSettingsRejectsBadModel: a spec that won't resolve is a 400 at save time,
|
|
// not a broken assistant on the next turn.
|
|
func TestSettingsRejectsBadModel(t *testing.T) {
|
|
r := authEngine(t, agentCfg())
|
|
admin := registerAndCookie(t, r, "[email protected]")
|
|
v := settingsVersion(t, r, admin)
|
|
|
|
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
|
map[string]any{"agentModel": "nonesuch/model", "version": v}, admin); w.Code != http.StatusBadRequest {
|
|
t.Errorf("bad model: status %d, want 400", w.Code)
|
|
}
|
|
// agentEnabled must be a bool or null, not a string.
|
|
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
|
map[string]any{"agentModel": "", "agentEnabled": "yes", "version": v}, admin); w.Code != http.StatusBadRequest {
|
|
t.Errorf("string agentEnabled: status %d, want 400", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestSettingsVersionConflict: a stale version 409s and carries the current row.
|
|
func TestSettingsVersionConflict(t *testing.T) {
|
|
r := authEngine(t, agentCfg())
|
|
admin := registerAndCookie(t, r, "[email protected]")
|
|
v := settingsVersion(t, r, admin)
|
|
|
|
// First write succeeds and bumps the version.
|
|
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
|
map[string]any{"agentModel": "", "agentEnabled": false, "version": v}, admin); w.Code != http.StatusOK {
|
|
t.Fatalf("first update: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
// Reusing the old version conflicts.
|
|
w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
|
map[string]any{"agentModel": "", "agentEnabled": true, "version": v}, admin)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("stale update: status %d, want 409", w.Code)
|
|
}
|
|
if cur, ok := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); !ok || cur["version"].(float64) != float64(v+1) {
|
|
t.Errorf("409 body missing the current row at the bumped version: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestSettingsSwapUnderRace runs settings saves concurrently with chat requests,
|
|
// so `go test -race` proves the atomic swap of the live Runner is safe against
|
|
// in-flight readers. The whole point of the atomic.Pointer is this: without it,
|
|
// toggling the assistant while a request reads it is a data race.
|
|
func TestSettingsSwapUnderRace(t *testing.T) {
|
|
r := authEngine(t, agentCfg())
|
|
admin := registerAndCookie(t, r, "[email protected]")
|
|
|
|
done := make(chan struct{})
|
|
// Readers hammer capabilities, whose h.agent.get() is the SAME atomic Load the
|
|
// chat handler does — so this races the pointer read against the writer's swap
|
|
// without ever invoking the model (a real Run would hit the network on a fake
|
|
// key). If get() is race-clean here it is race-clean in chat.
|
|
for i := 0; i < 4; i++ {
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
default:
|
|
doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, admin)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
// Writer: flip the assistant on and off, swapping the pointer each time.
|
|
for i := 0; i < 12; i++ {
|
|
v := settingsVersion(t, r, admin)
|
|
doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
|
map[string]any{"agentModel": "", "agentEnabled": i%2 == 0, "version": v}, admin)
|
|
}
|
|
close(done)
|
|
}
|