package service import ( "context" "strings" "gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" ) // Instance settings (#79): admin-editable, instance-wide configuration. The // admin gate lives HERE, in the seam, not in the handler — the same rule every // other permission follows. The API's requireAdmin middleware is a cheap early // 403, not the authority. // requireAdmin returns nil iff the actor is an admin, else ErrForbidden. // // ErrForbidden, not ErrNotFound: settings are not a resource whose existence is // masked. A logged-in non-admin knows the instance has settings; they simply may // not touch them. (Contrast objects/lots, where no-access masks existence.) func (s *Service) requireAdmin(ctx context.Context, actorID int64) error { u, err := s.store.GetUserByID(ctx, actorID) if err != nil { return err } if !u.IsAdmin { return domain.ErrForbidden } return nil } // GetInstanceSettings returns the instance settings for an admin. func (s *Service) GetInstanceSettings(ctx context.Context, actorID int64) (*domain.InstanceSettings, error) { if err := s.requireAdmin(ctx, actorID); err != nil { return nil, err } return s.store.GetInstanceSettings(ctx) } // InstanceSettingsPatch is a full replacement of the editable fields plus the // current version. Both agent fields carry their "inherit" sentinel: an empty // AgentModel means fall back to env, a nil AgentEnabled means inherit. type InstanceSettingsPatch struct { AgentModel string AgentEnabled *bool VisionModel string Version int64 } // UpdateInstanceSettings applies an admin's change, version-guarded. It returns // (current row, ErrVersionConflict) on a stale version, like every mutable // resource. The model spec is validated before it is stored, so a typo is a 400 // now rather than a broken assistant on the next turn. // // It does NOT rebuild the running agent — that is the API layer's job, because // the live Runner lives there. The caller rebuilds on success. func (s *Service) UpdateInstanceSettings(ctx context.Context, actorID int64, patch InstanceSettingsPatch) (*domain.InstanceSettings, error) { if err := s.requireAdmin(ctx, actorID); err != nil { return nil, err } model := strings.TrimSpace(patch.AgentModel) vision := strings.TrimSpace(patch.VisionModel) // Validate non-empty specs up front. An empty one is the "inherit env" // sentinel and needs no check — the env value was validated at boot. for _, spec := range []string{model, vision} { if spec != "" { if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, spec); err != nil { return nil, domain.ErrInvalidInput } } } return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{ AgentModel: model, AgentEnabled: patch.AgentEnabled, VisionModel: vision, Version: patch.Version, }) } // EffectiveAgent resolves the agent configuration actually in force: DB settings // override the environment, and the API key always comes from the environment. // // This is internal plumbing (the API layer calls it to build the Runner), NOT an // admin-gated operation — resolving what's configured is not the same as editing // it, and the agent bootstrap must work before any user is even authenticated. type EffectiveAgent struct { Model string Enabled bool APIKey string } // Ready mirrors config.AgentConfig.Ready: enabled, with a key and a model. func (e EffectiveAgent) Ready() bool { return e.Enabled && e.APIKey != "" && e.Model != "" } // EffectiveAgent reads the settings row and layers it over the environment. func (s *Service) EffectiveAgent(ctx context.Context) (EffectiveAgent, error) { st, err := s.store.GetInstanceSettings(ctx) if err != nil { return EffectiveAgent{}, err } eff := EffectiveAgent{ Model: s.cfg.Agent.Model, Enabled: s.cfg.Agent.Enabled, APIKey: s.cfg.Agent.OllamaCloudAPIKey, } if st.AgentModel != "" { eff.Model = st.AgentModel } if st.AgentEnabled != nil { eff.Enabled = *st.AgentEnabled } return eff, nil } // EffectiveVision resolves the vision configuration in force for seed-packet // capture (#81): the model from DB-over-env, the key always from env. type EffectiveVision struct { Model string APIKey string } // Ready reports whether packet capture can be offered: a key and a vision model. // There's no separate enabled flag — configuring a vision model IS enabling it. func (e EffectiveVision) Ready() bool { return e.APIKey != "" && e.Model != "" } // EffectiveVision reads the settings row and layers it over the environment. func (s *Service) EffectiveVision(ctx context.Context) (EffectiveVision, error) { st, err := s.store.GetInstanceSettings(ctx) if err != nil { return EffectiveVision{}, err } eff := EffectiveVision{ Model: s.cfg.Agent.VisionModel, APIKey: s.cfg.Agent.OllamaCloudAPIKey, } if st.VisionModel != "" { eff.Model = st.VisionModel } return eff, nil }