Skip to content

Commit 9e7c6a7

Browse files
Alexey Panfilovclaude
andcommitted
feat(adminapi): Settings tab + DB overrides for scalar config
Adds typed kv_settings helpers (GetIntSetting/GetBoolSetting/GetStringSetting with safe defaults) and a Settings admin page that edits config scalars at runtime without a config.yaml reload. First wave of scalars: - classifier_timeout: read per request in Router.classify, DB > cfg > 15s - tool_filter.top_k: read at startup, applies on next restart Settings page POSTs to /settings/{key}/set with an allowlist check against the settingSpecs list. Empty value deletes the override so the config default wins. Structure lets future scalars (web_search.enabled, tts.voice, etc.) drop in as one-line additions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d8870e8 commit 9e7c6a7

8 files changed

Lines changed: 271 additions & 9 deletions

File tree

cmd/agent/main.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,11 +236,19 @@ func main() {
236236
mcpClient.Initialize(initCtx)
237237
cancel()
238238

239-
if emb, ok := cfg.Models["embedding"]; ok && cfg.ToolFilter.TopK > 0 && (emb.APIKey != "" || emb.BaseURL != "") {
240-
mcpClient.EnableEmbeddings(emb, cfg.ToolFilter.TopK)
239+
// top_k: DB override wins over config; disables filtering if 0.
240+
topK := cfg.ToolFilter.TopK
241+
if settingsStore, ok := s.(llm.SettingsStore); ok {
242+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
243+
topK = llm.GetIntSetting(ctx, settingsStore, llm.SettingKeyToolFilterTopK, topK)
244+
cancel()
245+
}
246+
if emb, ok := cfg.Models["embedding"]; ok && topK > 0 && (emb.APIKey != "" || emb.BaseURL != "") {
247+
mcpClient.EnableEmbeddings(emb, topK)
241248
embedCtx, embedCancel := context.WithTimeout(context.Background(), 60*time.Second)
242249
mcpClient.EmbedTools(embedCtx)
243250
embedCancel()
251+
logger.Info("tool filter enabled", "top_k", topK)
244252
}
245253
}
246254

internal/adminapi/handlers.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,111 @@ func (s *Server) firstGeminiAPIKey() string {
661661
return ""
662662
}
663663

664+
// --- Settings page ---
665+
666+
// settingSpec describes one user-editable scalar for the Settings tab.
667+
// Stays in the handlers file so adding a new setting is a one-liner.
668+
type settingSpec struct {
669+
Key string // kv_settings key (e.g. llm.SettingKeyClassifierTimeout)
670+
Label string
671+
Description string
672+
Default string // shown in UI placeholder + as "default: X" hint
673+
InputType string // HTML <input type>; "number" for ints, "text" otherwise
674+
Value string // current DB value, empty if unset
675+
}
676+
677+
// settingSpecs returns the list of scalars exposed in the Settings tab, in
678+
// display order. Extend this slice to surface more config keys.
679+
func (s *Server) settingSpecs() []settingSpec {
680+
defs := []settingSpec{
681+
{
682+
Key: llm.SettingKeyClassifierTimeout,
683+
Label: "Classifier timeout (seconds)",
684+
Description: "Max time to wait for the classifier model per request before defaulting to level 2.",
685+
Default: fmt.Sprintf("%d", s.cfgRef.Routing.ClassifierTimeout),
686+
InputType: "number",
687+
},
688+
{
689+
Key: llm.SettingKeyToolFilterTopK,
690+
Label: "Tool filter top-K",
691+
Description: "Number of most-relevant MCP tools to include per request (0 = disabled). Applies on next restart.",
692+
Default: fmt.Sprintf("%d", s.cfgRef.ToolFilter.TopK),
693+
InputType: "number",
694+
},
695+
}
696+
if s.settings != nil {
697+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
698+
defer cancel()
699+
for i := range defs {
700+
if v, ok, _ := s.settings.GetSetting(ctx, defs[i].Key); ok {
701+
defs[i].Value = v
702+
}
703+
}
704+
}
705+
return defs
706+
}
707+
708+
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
709+
data := map[string]any{
710+
"ActiveTab": "settings",
711+
"Settings": s.settingSpecs(),
712+
}
713+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
714+
if err := render(w, viewSettings, data); err != nil {
715+
s.logger.Error("render settings", "err", err)
716+
http.Error(w, "render error", http.StatusInternalServerError)
717+
}
718+
}
719+
720+
// handleSettingSet: POST /settings/{key}/set with body value=...
721+
// Validates the key against the allowlist derived from settingSpecs so an
722+
// attacker can't smuggle arbitrary kv_settings keys through this endpoint.
723+
func (s *Server) handleSettingSet(w http.ResponseWriter, r *http.Request) {
724+
if r.Method != http.MethodPost {
725+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
726+
return
727+
}
728+
key := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/settings/"), "/set")
729+
allowed := false
730+
for _, sp := range s.settingSpecs() {
731+
if sp.Key == key {
732+
allowed = true
733+
break
734+
}
735+
}
736+
if !allowed {
737+
http.Error(w, "unknown setting", http.StatusBadRequest)
738+
return
739+
}
740+
if err := r.ParseForm(); err != nil {
741+
http.Error(w, "parse form", http.StatusBadRequest)
742+
return
743+
}
744+
value := strings.TrimSpace(r.FormValue("value"))
745+
if s.settings == nil {
746+
http.Error(w, "settings store not available", http.StatusServiceUnavailable)
747+
return
748+
}
749+
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
750+
defer cancel()
751+
if value == "" {
752+
// Empty string = revert to config default. Delete the DB key so the
753+
// GetIntSetting fallback kicks in.
754+
if err := s.settings.PutSetting(ctx, key, ""); err != nil {
755+
s.logger.Warn("setting delete failed", "key", key, "err", err)
756+
http.Error(w, "save failed", http.StatusInternalServerError)
757+
return
758+
}
759+
} else if err := s.settings.PutSetting(ctx, key, value); err != nil {
760+
s.logger.Warn("setting save failed", "key", key, "err", err)
761+
http.Error(w, "save failed", http.StatusInternalServerError)
762+
return
763+
}
764+
s.logger.Info("setting updated", "key", key, "value", value)
765+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
766+
w.Write([]byte(`<span class="save-status ok">Saved</span>`)) //nolint:errcheck
767+
}
768+
664769
// handlePrompts: GET /prompts — full page with current prompt values from DB.
665770
func (s *Server) handlePrompts(w http.ResponseWriter, r *http.Request) {
666771
type promptsData struct {

internal/adminapi/server.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,6 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
115115
mux.Handle("/analytics", authed(http.HandlerFunc(s.handleAnalytics)))
116116
mux.Handle("/prompts", authed(http.HandlerFunc(s.handlePrompts)))
117117
mux.Handle("/prompts/", authed(http.HandlerFunc(s.handlePromptSet))) // POST /prompts/{key}/set
118+
mux.Handle("/settings", authed(http.HandlerFunc(s.handleSettings)))
119+
mux.Handle("/settings/", authed(http.HandlerFunc(s.handleSettingSet))) // POST /settings/{key}/set
118120
}

internal/adminapi/templates.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const (
1919
viewUsage = "usage"
2020
viewAnalytics = "analytics"
2121
viewPrompts = "prompts"
22+
viewSettings = "settings"
2223
)
2324

2425
// tmpls is the parsed template set. Entries are keyed by view name and
@@ -43,6 +44,7 @@ var tmpls = func() map[string]*template.Template {
4344
viewUsage: "templates/usage.html",
4445
viewAnalytics: "templates/analytics.html",
4546
viewPrompts: "templates/prompts.html",
47+
viewSettings: "templates/settings.html",
4648
}
4749
funcs := template.FuncMap{
4850
"priceUSD": func(v float64) string {

internal/adminapi/templates/partials_layout.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,6 @@
5454
<a class="admin-tabs__tab {{if eq .ActiveTab "routing"}}admin-tabs__tab--active{{end}}" href="/">Routing & Models</a>
5555
<a class="admin-tabs__tab {{if eq .ActiveTab "analytics"}}admin-tabs__tab--active{{end}}" href="/analytics">Analytics</a>
5656
<a class="admin-tabs__tab {{if eq .ActiveTab "prompts"}}admin-tabs__tab--active{{end}}" href="/prompts">Prompts</a>
57+
<a class="admin-tabs__tab {{if eq .ActiveTab "settings"}}admin-tabs__tab--active{{end}}" href="/settings">Settings</a>
5758
</nav>
5859
{{end}}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{{define "settings"}}
2+
<!doctype html>
3+
<html>
4+
<head>{{template "admin_head" .}}<title>Settings — Admin</title>
5+
<style>
6+
.settings-form { max-width: 42rem; }
7+
.settings-row { display: grid; grid-template-columns: 1fr auto auto; gap: 0.5rem; align-items: center; padding: 0.75rem 0; border-bottom: 1px solid rgba(0,0,0,0.08); }
8+
.settings-row:last-child { border-bottom: none; }
9+
.settings-label { font-weight: 600; }
10+
.settings-desc { color: var(--text-secondary, rgba(0,0,0,0.6)); font-size: 0.85em; grid-column: 1 / -1; margin-top: 0.25rem; }
11+
.settings-input { width: 8rem; }
12+
.save-status.ok { color: #16a34a; font-size: 0.85em; }
13+
</style>
14+
</head>
15+
<body>
16+
<h1>Admin</h1>
17+
{{template "admin_tabs" .}}
18+
19+
<div class="card settings-form">
20+
<div class="card__header"><strong>Runtime Settings</strong></div>
21+
<div class="card__body">
22+
<p style="color:var(--text-secondary);margin:0 0 0.75rem 0;font-size:0.9em;">
23+
These override <code>config.yaml</code> scalars. Values take effect on the next relevant read —
24+
classifier timeout is picked up per request, <code>tool_filter.top_k</code> on next restart.
25+
Leave blank to fall back to the config default.
26+
</p>
27+
28+
{{range .Settings}}
29+
<form class="settings-row"
30+
hx-post="/settings/{{.Key}}/set"
31+
hx-swap="innerHTML"
32+
hx-target="find .save-status">
33+
<div>
34+
<div class="settings-label">{{.Label}}</div>
35+
<div class="settings-desc">{{.Description}} <span style="opacity:0.7;">(default: <code>{{.Default}}</code>)</span></div>
36+
</div>
37+
<input class="form-input settings-input" name="value" type="{{.InputType}}" value="{{.Value}}" placeholder="{{.Default}}">
38+
<button class="btn btn--small btn--primary" type="submit">Save</button>
39+
<span class="save-status" style="grid-column: 1 / -1;"></span>
40+
</form>
41+
{{end}}
42+
</div>
43+
</div>
44+
</body>
45+
</html>
46+
{{end}}

internal/llm/router.go

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,17 @@ func (r *Router) currentSlotStates() map[string]slotState {
299299
// overrides is stored in the settings store.
300300
const settingsKeyRoutingOverrides = "routing.overrides"
301301

302+
// Scalar setting keys for values that were historically in config.yaml and
303+
// are now DB-overridable via the admin UI. Reads use GetIntSetting/
304+
// GetBoolSetting with config-provided defaults.
305+
//
306+
// Note: classifier_min_length is NOT here — it lives in the routing.overrides
307+
// JSON blob (legacy persistence) and is set via Router.SetClassifierMinLen.
308+
const (
309+
SettingKeyClassifierTimeout = "cfg.classifier_timeout"
310+
SettingKeyToolFilterTopK = "cfg.tool_filter_top_k"
311+
)
312+
302313
// saveOverrides writes current cfg via the settings store (preferred) or the
303314
// legacy file path. Must be called with mu held.
304315
func (r *Router) saveOverrides() {
@@ -836,11 +847,16 @@ func (r *Router) classify(ctx context.Context, text string) int {
836847
if provider == nil {
837848
return 2
838849
}
839-
timeout := time.Duration(r.cfg.ClassifierTimeout) * time.Second
840-
if timeout <= 0 {
841-
timeout = 15 * time.Second
850+
// Timeout: DB override wins, then cfg, then 15s default.
851+
r.mu.RLock()
852+
settings := r.settings
853+
cfgTimeout := r.cfg.ClassifierTimeout
854+
r.mu.RUnlock()
855+
timeoutSec := GetIntSetting(ctx, settings, SettingKeyClassifierTimeout, cfgTimeout)
856+
if timeoutSec <= 0 {
857+
timeoutSec = 15
842858
}
843-
classifierCtx, cancel := context.WithTimeout(ctx, timeout)
859+
classifierCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second)
844860
defer cancel()
845861

846862
// Truncate to save tokens — classifier only needs the beginning to judge complexity.
@@ -853,9 +869,6 @@ func (r *Router) classify(ctx context.Context, text string) int {
853869
prompt = defaultClassifierPrompt
854870
}
855871
// DB override takes precedence over config/default.
856-
r.mu.RLock()
857-
settings := r.settings
858-
r.mu.RUnlock()
859872
if settings != nil {
860873
if v, ok, _ := settings.GetSetting(classifierCtx, "prompts.classifier"); ok && v != "" {
861874
prompt = v

internal/llm/settings_helpers.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package llm
2+
3+
import (
4+
"context"
5+
"strconv"
6+
"strings"
7+
)
8+
9+
// Typed helpers over the generic SettingsStore. Values are stored as strings
10+
// (see sqlite/postgres schema) so every getter converts on read with a safe
11+
// fallback. Nil stores are tolerated — callers can pass settings directly
12+
// without guarding against a missing DB.
13+
//
14+
// All helpers return the default on: nil store, missing key, empty string,
15+
// or parse error. They never log or panic; treat storage as best-effort.
16+
17+
// GetIntSetting reads an integer setting, returning def when the value is
18+
// absent or unparseable.
19+
func GetIntSetting(ctx context.Context, s SettingsStore, key string, def int) int {
20+
if s == nil {
21+
return def
22+
}
23+
v, ok, err := s.GetSetting(ctx, key)
24+
if err != nil || !ok {
25+
return def
26+
}
27+
n, err := strconv.Atoi(strings.TrimSpace(v))
28+
if err != nil {
29+
return def
30+
}
31+
return n
32+
}
33+
34+
// GetBoolSetting reads a boolean setting. Accepts "1"/"0", "true"/"false",
35+
// "yes"/"no", and "on"/"off" (case-insensitive). Returns def on miss.
36+
func GetBoolSetting(ctx context.Context, s SettingsStore, key string, def bool) bool {
37+
if s == nil {
38+
return def
39+
}
40+
v, ok, err := s.GetSetting(ctx, key)
41+
if err != nil || !ok {
42+
return def
43+
}
44+
switch strings.ToLower(strings.TrimSpace(v)) {
45+
case "1", "true", "yes", "on":
46+
return true
47+
case "0", "false", "no", "off":
48+
return false
49+
}
50+
return def
51+
}
52+
53+
// GetStringSetting reads a string setting, returning def when absent.
54+
// Whitespace is preserved; trim at the call site if needed.
55+
func GetStringSetting(ctx context.Context, s SettingsStore, key string, def string) string {
56+
if s == nil {
57+
return def
58+
}
59+
v, ok, err := s.GetSetting(ctx, key)
60+
if err != nil || !ok {
61+
return def
62+
}
63+
return v
64+
}
65+
66+
// PutIntSetting stores an integer — thin wrapper for symmetry with the
67+
// getters.
68+
func PutIntSetting(ctx context.Context, s SettingsStore, key string, n int) error {
69+
if s == nil {
70+
return nil
71+
}
72+
return s.PutSetting(ctx, key, strconv.Itoa(n))
73+
}
74+
75+
// PutBoolSetting stores a boolean as "true"/"false".
76+
func PutBoolSetting(ctx context.Context, s SettingsStore, key string, v bool) error {
77+
if s == nil {
78+
return nil
79+
}
80+
str := "false"
81+
if v {
82+
str = "true"
83+
}
84+
return s.PutSetting(ctx, key, str)
85+
}

0 commit comments

Comments
 (0)