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
111 lines
3.9 KiB
Go
111 lines
3.9 KiB
Go
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
|
|
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)
|
|
// Validate a non-empty spec up front. An empty one is the "inherit env"
|
|
// sentinel and needs no check — the env value was validated at boot.
|
|
if model != "" {
|
|
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, model); err != nil {
|
|
return nil, domain.ErrInvalidInput
|
|
}
|
|
}
|
|
return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{
|
|
AgentModel: model,
|
|
AgentEnabled: patch.AgentEnabled,
|
|
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
|
|
}
|