Admin-gated Settings: runtime model selection, and is_admin enforced for the first time #90

Merged
steve merged 2 commits from feat/settings-admin into main 2026-07-22 02:53:55 +00:00
6 changed files with 51 additions and 47 deletions
Showing only changes of commit 9ab454373b - Show all commits
+3 -1
View File
@@ -48,9 +48,11 @@ type Runner struct {
// constructor serves both boot (from env) and a runtime settings change (from
// the DB) — the Runner has no idea which one configured it.
func NewRunner(svc *service.Service, apiKey, modelSpec string) (*Runner, error) {
if apiKey == "" || strings.TrimSpace(modelSpec) == "" {
if apiKey == "" {
return nil, errors.New("agent: not configured")
}
// agentmodel.Resolve already rejects an empty/blank spec, so don't duplicate
// that guard here — one place decides what a valid spec is.
model, err := agentmodel.Resolve(apiKey, modelSpec)
if err != nil {
return nil, err
+13 -12
View File
@@ -24,10 +24,6 @@ import (
// than assuming a Runner is present.
type agentHolder struct {
svc *service.Service
// apiKey is the OLLAMA_CLOUD_API_KEY from the environment. It never changes at
// runtime — only the model spec and the enabled flag are settings — and it is
// deliberately not something the settings UI can read or write.
apiKey string
ptr atomic.Pointer[agent.Runner]
// rebuildMu serializes rebuilds so two concurrent settings saves can't
@@ -39,8 +35,8 @@ type agentHolder struct {
// newAgentHolder builds the holder and resolves the initial Runner from whatever
// the settings + environment currently say. A resolution failure is logged and
// left as "no assistant", never fatal: a garden planner must still boot.
func newAgentHolder(ctx context.Context, svc *service.Service, apiKey string) *agentHolder {
h := &agentHolder{svc: svc, apiKey: apiKey}
func newAgentHolder(ctx context.Context, svc *service.Service) *agentHolder {
h := &agentHolder{svc: svc}
h.rebuild(ctx)
return h
}
@@ -63,18 +59,23 @@ func (h *agentHolder) rebuild(ctx context.Context) {
eff, err := h.svc.EffectiveAgent(ctx)
if err != nil {
slog.Error("api: could not resolve agent settings; leaving assistant unchanged", "error", err)
// EffectiveAgent errors only if the settings row can't be read — a DB fault,
// and a very transient one when it happens right after a settings write. We
// keep the current Runner rather than tear down a working assistant on a
// blip: the state is already persisted, so the next rebuild (any later save,
// or a restart) reconciles it. Loud, because a persistent failure here means
// the live assistant no longer matches stored settings.
slog.Error("api: could not resolve agent settings; leaving the assistant as-is", "error", err)
return
}
// The env key is authoritative; EffectiveAgent reports it, but the holder was
// handed it directly at construction, so use that.
if !eff.Enabled || h.apiKey == "" || eff.Model == "" {
if !eff.Ready() {
if h.ptr.Swap(nil) != nil {
slog.Info("api: garden assistant turned off", "enabled", eff.Enabled, "hasModel", eff.Model != "")
slog.Info("api: garden assistant turned off",
"enabled", eff.Enabled, "hasKey", eff.APIKey != "", "hasModel", eff.Model != "")
}
return
}
runner, err := agent.NewRunner(h.svc, h.apiKey, eff.Model)
runner, err := agent.NewRunner(h.svc, eff.APIKey, eff.Model)
if err != nil {
// Configured but unusable. Turn the assistant off rather than leaving a
// stale Runner on the old model — an admin who just pointed it at a broken
+3 -3
View File
@@ -132,13 +132,13 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
// The garden assistant. Its routes are registered UNCONDITIONALLY and the live
// Runner sits behind an atomic pointer in the holder, so a settings change can
// turn the assistant on or off at runtime (#79). Each handler nil-checks
// agent.get(); a request while the assistant is off gets a clean 404/"not
// available", not a panic and not a missing route.
// agent.get(); a chat request while the assistant is off gets a clean 503
// (AGENT_DISABLED), not a panic and not a missing route.
//
// The holder resolves its initial Runner from settings + environment at
// construction. If the key never reaches the container, the assistant is off
// and the reason is logged below — the same operability need #72 added.
h.agent = newAgentHolder(context.Background(), svc, cfg.Agent.OllamaCloudAPIKey)
h.agent = newAgentHolder(context.Background(), svc)
if cfg.Agent.OllamaCloudAPIKey == "" {
slog.Info("api: garden assistant has no API key",
"hint", "set OLLAMA_CLOUD_API_KEY in the container's environment (not just the stack's); the model can be chosen in Settings")
+24 -21
View File
@@ -53,22 +53,26 @@ type effectiveView struct {
AgentLive bool `json:"agentLive"`
}
func (h *handlers) settingsPayload(c *gin.Context, st *domain.InstanceSettings) settingsResponse {
// settingsPayload builds the response, or an error. It does NOT swallow an
// EffectiveAgent failure into a misleading empty "effective" view — an empty
// view would report no model and no key, which reads as "nothing configured"
// rather than "we couldn't read it". Since EffectiveAgent re-reads the same row
// GetInstanceSettings just returned, a failure here is a genuine DB fault worth
// surfacing as a 500, not papering over.
func (h *handlers) settingsPayload(c *gin.Context, st *domain.InstanceSettings) (settingsResponse, error) {
eff, err := h.svc.EffectiveAgent(c.Request.Context())
Review

🟡 EffectiveAgent re-reads instance_settings row that GetInstanceSettings just fetched

maintainability, performance · flagged by 2 models

  • internal/api/settings.go:63 and internal/api/settings.go:141settingsPayload calls h.svc.EffectiveAgent, which always does a fresh GetInstanceSettings DB read. In getSettings, the same row was just fetched by GetInstanceSettings. In updateSettings, the row was just written and returned, then rebuild calls EffectiveAgent (second read), then settingsPayload calls it again (third read). A single PATCH can hit the same row 3 times. Pass the already-loaded row into `Effe…

🪰 Gadfly · advisory

🟡 **EffectiveAgent re-reads instance_settings row that GetInstanceSettings just fetched** _maintainability, performance · flagged by 2 models_ - `internal/api/settings.go:63` and `internal/api/settings.go:141` — `settingsPayload` calls `h.svc.EffectiveAgent`, which always does a fresh `GetInstanceSettings` DB read. In `getSettings`, the same row was just fetched by `GetInstanceSettings`. In `updateSettings`, the row was just written and returned, then `rebuild` calls `EffectiveAgent` (second read), then `settingsPayload` calls it again (third read). A single PATCH can hit the same row **3 times**. Pass the already-loaded row into `Effe… <sub>🪰 Gadfly · advisory</sub>
if err != nil {
// The settings row read succeeded to get here, so this is unexpected; fall
// back to an empty effective view rather than failing the whole response.
eff = service.EffectiveAgent{}
return settingsResponse{}, err
}
return settingsResponse{
Settings: st,
Effective: effectiveView{
Model: eff.Model,
Enabled: eff.Enabled,
HasApiKey: h.cfg.Agent.OllamaCloudAPIKey != "",
HasApiKey: eff.APIKey != "",
AgentLive: h.agent.get() != nil,
},
}
}, nil
}
func (h *handlers) getSettings(c *gin.Context) {
@@ -77,7 +81,12 @@ func (h *handlers) getSettings(c *gin.Context) {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, h.settingsPayload(c, st))
payload, err := h.settingsPayload(c, st)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, payload)
}
// settingsUpdateRequest is the PATCH body. agentModel "" means inherit the env
1
@@ -97,7 +106,9 @@ func (h *handlers) updateSettings(c *gin.Context) {
}
// agentEnabled: absent or null → inherit (nil); true/false → explicit override.
enabled, err := parseNullableBool(req.AgentEnabled)
// The shared parseNullable does exactly this three-way decode; present is
// irrelevant here because absent and null both mean "inherit".
enabled, _, err := parseNullable[bool](req.AgentEnabled)
if err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "agentEnabled must be true, false, or null")
return
@@ -123,18 +134,10 @@ func (h *handlers) updateSettings(c *gin.Context) {
// settings. Mirrors the same reasoning as the history-write detachment.
h.agent.rebuild(context.WithoutCancel(c.Request.Context()))
c.JSON(http.StatusOK, h.settingsPayload(c, st))
payload, err := h.settingsPayload(c, st)
if err != nil {
writeServiceError(c, err)
return
}
// parseNullableBool maps a JSON field that may be absent, null, or a bool to a
// *bool: nil for absent/null (inherit), else a pointer to the value.
func parseNullableBool(raw json.RawMessage) (*bool, error) {
if len(raw) == 0 || string(raw) == "null" {
return nil, nil
}
var b bool
if err := json.Unmarshal(raw, &b); err != nil {
return nil, err
}
return &b, nil
c.JSON(http.StatusOK, payload)
}
-7
View File
@@ -76,13 +76,6 @@ type AgentConfig struct {
Enabled bool
}
// Ready reports whether the assistant can actually be offered. Both the route
// registration and whatever advertises capabilities gate on this, so what's
// advertised always matches what's live.
func (a AgentConfig) Ready() bool {
return a.Enabled && a.OllamaCloudAPIKey != "" && a.Model != ""
}
// Enabled reports whether enough OIDC config is present to attempt discovery.
func (o OIDCConfig) Enabled() bool {
return o.Issuer != "" && o.ClientID != ""
+6 -1
View File
@@ -161,8 +161,13 @@ export async function streamChat(
return
}
if (!res.ok || !res.body) {
// 503 is the assistant being turned off at runtime (#79) — the route exists,
// there's just no model behind it. Distinct from a 404, which would mean the
// whole endpoint is absent.
handlers.onError(
res.status === 404
res.status === 503
? "The assistant isn't enabled on this instance."
: res.status === 404
? "This instance doesn't have the assistant configured."
: 'The assistant is not available right now.',
)