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 // constructor serves both boot (from env) and a runtime settings change (from
// the DB) — the Runner has no idea which one configured it. // the DB) — the Runner has no idea which one configured it.
func NewRunner(svc *service.Service, apiKey, modelSpec string) (*Runner, error) { func NewRunner(svc *service.Service, apiKey, modelSpec string) (*Runner, error) {
if apiKey == "" || strings.TrimSpace(modelSpec) == "" { if apiKey == "" {
return nil, errors.New("agent: not configured") 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) model, err := agentmodel.Resolve(apiKey, modelSpec)
if err != nil { if err != nil {
return nil, err return nil, err
+13 -12
View File
@@ -24,10 +24,6 @@ import (
// than assuming a Runner is present. // than assuming a Runner is present.
type agentHolder struct { type agentHolder struct {
svc *service.Service 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] ptr atomic.Pointer[agent.Runner]
// rebuildMu serializes rebuilds so two concurrent settings saves can't // 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 // newAgentHolder builds the holder and resolves the initial Runner from whatever
// the settings + environment currently say. A resolution failure is logged and // the settings + environment currently say. A resolution failure is logged and
// left as "no assistant", never fatal: a garden planner must still boot. // left as "no assistant", never fatal: a garden planner must still boot.
func newAgentHolder(ctx context.Context, svc *service.Service, apiKey string) *agentHolder { func newAgentHolder(ctx context.Context, svc *service.Service) *agentHolder {
h := &agentHolder{svc: svc, apiKey: apiKey} h := &agentHolder{svc: svc}
h.rebuild(ctx) h.rebuild(ctx)
return h return h
} }
@@ -63,18 +59,23 @@ func (h *agentHolder) rebuild(ctx context.Context) {
eff, err := h.svc.EffectiveAgent(ctx) eff, err := h.svc.EffectiveAgent(ctx)
if err != nil { 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 return
} }
// The env key is authoritative; EffectiveAgent reports it, but the holder was if !eff.Ready() {
// handed it directly at construction, so use that.
if !eff.Enabled || h.apiKey == "" || eff.Model == "" {
if h.ptr.Swap(nil) != nil { 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 return
} }
runner, err := agent.NewRunner(h.svc, h.apiKey, eff.Model) runner, err := agent.NewRunner(h.svc, eff.APIKey, eff.Model)
if err != nil { if err != nil {
// Configured but unusable. Turn the assistant off rather than leaving a // 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 // 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 // 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 // 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 // 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 // agent.get(); a chat request while the assistant is off gets a clean 503
// available", not a panic and not a missing route. // (AGENT_DISABLED), not a panic and not a missing route.
// //
// The holder resolves its initial Runner from settings + environment at // The holder resolves its initial Runner from settings + environment at
// construction. If the key never reaches the container, the assistant is off // construction. If the key never reaches the container, the assistant is off
// and the reason is logged below — the same operability need #72 added. // 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 == "" { if cfg.Agent.OllamaCloudAPIKey == "" {
slog.Info("api: garden assistant has no API key", 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") "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"` 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()) 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 { if err != nil {
// The settings row read succeeded to get here, so this is unexpected; fall return settingsResponse{}, err
// back to an empty effective view rather than failing the whole response.
eff = service.EffectiveAgent{}
} }
return settingsResponse{ return settingsResponse{
Settings: st, Settings: st,
Effective: effectiveView{ Effective: effectiveView{
Model: eff.Model, Model: eff.Model,
Enabled: eff.Enabled, Enabled: eff.Enabled,
HasApiKey: h.cfg.Agent.OllamaCloudAPIKey != "", HasApiKey: eff.APIKey != "",
AgentLive: h.agent.get() != nil, AgentLive: h.agent.get() != nil,
}, },
} }, nil
} }
func (h *handlers) getSettings(c *gin.Context) { func (h *handlers) getSettings(c *gin.Context) {
@@ -77,7 +81,12 @@ func (h *handlers) getSettings(c *gin.Context) {
writeServiceError(c, err) writeServiceError(c, err)
return 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 // 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. // 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 { if err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "agentEnabled must be true, false, or null") writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "agentEnabled must be true, false, or null")
return return
@@ -123,18 +134,10 @@ func (h *handlers) updateSettings(c *gin.Context) {
// settings. Mirrors the same reasoning as the history-write detachment. // settings. Mirrors the same reasoning as the history-write detachment.
h.agent.rebuild(context.WithoutCancel(c.Request.Context())) 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)
// parseNullableBool maps a JSON field that may be absent, null, or a bool to a return
// *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 c.JSON(http.StatusOK, payload)
if err := json.Unmarshal(raw, &b); err != nil {
return nil, err
}
return &b, nil
} }
-7
View File
@@ -76,13 +76,6 @@ type AgentConfig struct {
Enabled bool 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. // Enabled reports whether enough OIDC config is present to attempt discovery.
func (o OIDCConfig) Enabled() bool { func (o OIDCConfig) Enabled() bool {
return o.Issuer != "" && o.ClientID != "" return o.Issuer != "" && o.ClientID != ""
+8 -3
View File
@@ -161,10 +161,15 @@ export async function streamChat(
return return
} }
if (!res.ok || !res.body) { 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( handlers.onError(
res.status === 404 res.status === 503
? "This instance doesn't have the assistant configured." ? "The assistant isn't enabled on this instance."
: 'The assistant is not available right now.', : res.status === 404
? "This instance doesn't have the assistant configured."
: 'The assistant is not available right now.',
) )
return return
} }