package api import ( "context" "encoding/json" "errors" "net/http" "github.com/gin-gonic/gin" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" "gitea.stevedudenhoeffer.com/steve/pansy/internal/service" ) // Instance settings (#79): admin-only, instance-wide. The authoritative admin // check is in the service; requireAdmin here is a cheap early 403 that also // keeps the route group readable. // requireAdmin rejects a non-admin actor. It runs after requireAuth, so the // actor is already resolved and carries IsAdmin — no extra query. Returns 403 // (not 404): a logged-in user knows settings exist, they just may not touch them. func (h *handlers) requireAdmin() gin.HandlerFunc { return func(c *gin.Context) { if !mustActor(c).IsAdmin { writeAPIError(c, http.StatusForbidden, "FORBIDDEN", "admin access required") c.Abort() return } c.Next() } } // settingsResponse is what GET/PATCH /settings return. It carries the stored // settings plus a read-only view of what's resolved and live, so the UI can show // "inheriting ollama-cloud/glm-5.2:cloud from the environment" and whether a key // is present — without ever exposing the key itself. type settingsResponse struct { Settings *domain.InstanceSettings `json:"settings"` // Effective is the configuration actually in force after layering settings // over the environment. Effective effectiveView `json:"effective"` } type effectiveView struct { Model string `json:"model"` Enabled bool `json:"enabled"` // HasApiKey reports whether OLLAMA_CLOUD_API_KEY is set. The key itself is // never serialized — an admin may know one exists, not what it is. HasApiKey bool `json:"hasApiKey"` // AgentLive is whether the assistant Runner is actually built right now. It // can be false even when Enabled+HasApiKey are true (an unresolvable model), // which is exactly the case the UI needs to surface. AgentLive bool `json:"agentLive"` // VisionModel is the resolved seed-packet model (DB-over-env). VisionReady is // whether capture can actually be offered (a key and a model). VisionModel string `json:"visionModel"` VisionReady bool `json:"visionReady"` } // 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()) if err != nil { return settingsResponse{}, err } vis, err := h.svc.EffectiveVision(c.Request.Context()) if err != nil { return settingsResponse{}, err } return settingsResponse{ Settings: st, Effective: effectiveView{ Model: eff.Model, Enabled: eff.Enabled, HasApiKey: eff.APIKey != "", AgentLive: h.agent.get() != nil, VisionModel: vis.Model, VisionReady: vis.Ready(), }, }, nil } func (h *handlers) getSettings(c *gin.Context) { st, err := h.svc.GetInstanceSettings(c.Request.Context(), mustActor(c).ID) if err != nil { writeServiceError(c, err) return } 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 // var. agentEnabled is json.RawMessage so an explicit null (inherit) is // distinguishable from an absent field and from true/false. type settingsUpdateRequest struct { AgentModel string `json:"agentModel"` AgentEnabled json.RawMessage `json:"agentEnabled"` VisionModel string `json:"visionModel"` Version int64 `json:"version" binding:"required"` } func (h *handlers) updateSettings(c *gin.Context) { var req settingsUpdateRequest if err := c.ShouldBindJSON(&req); err != nil { writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a current version is required") return } // agentEnabled: absent or null → inherit (nil); true/false → explicit override. // 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 } st, err := h.svc.UpdateInstanceSettings(c.Request.Context(), mustActor(c).ID, service.InstanceSettingsPatch{ AgentModel: req.AgentModel, AgentEnabled: enabled, VisionModel: req.VisionModel, Version: req.Version, }) if err != nil { if errors.Is(err, domain.ErrVersionConflict) { writeVersionConflict(c, st) return } writeServiceError(c, err) return } // Apply the change to the LIVE assistant. Detached from the request context: // the write is committed and the rebuild describes it, so a client that hangs // up now must not leave the running Runner out of step with the stored // settings. Mirrors the same reasoning as the history-write detachment. h.agent.rebuild(context.WithoutCancel(c.Request.Context())) payload, err := h.settingsPayload(c, st) if err != nil { writeServiceError(c, err) return } c.JSON(http.StatusOK, payload) }