-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathconfig.go
More file actions
320 lines (289 loc) · 11.5 KB
/
Copy pathconfig.go
File metadata and controls
320 lines (289 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"tailscale.com/atomicfile"
)
// CompressSettings are the runtime-tunable knobs forwarded to headroom.compress.
// They mirror headroom's CompressConfig field names exactly (the worker passes
// them straight through as kwargs), and default to headroom's own defaults so
// behavior is unchanged until someone tunes them.
//
// TargetRatio and KompressModel are pointers because their "unset" value is
// JSON null with real meaning (target_ratio null = "model decides ~15%";
// kompress_model null = headroom's default model). SavingsProfile is likewise a
// pointer (null = no profile).
//
// Deliberately NOT mirrored: CompressConfig.frozen_message_count (added in
// headroom 0.32.0). It is not a static policy knob — it's a per-request value
// ("the message count of the previous request") that a caller managing its own
// conversation loop passes so compression won't rewrite the already-cached
// prefix. A single process-global value here would be meaningless (or harmful if
// nonzero). Setting it correctly requires per-session prior-turn state, i.e. the
// stateful/delta-worker rework tracked in TODO.md; until then we leave it at
// headroom's default (0 = no frozen prefix), which is the pre-0.32 behavior.
type CompressSettings struct {
CompressUserMessages bool `json:"compress_user_messages"`
CompressSystemMessages bool `json:"compress_system_messages"`
ProtectRecent int `json:"protect_recent"`
ProtectAnalysisContext bool `json:"protect_analysis_context"`
TargetRatio *float64 `json:"target_ratio"`
MinTokensToCompress int `json:"min_tokens_to_compress"`
KompressModel *string `json:"kompress_model"`
// SavingsProfile selects a headroom named profile (>= 0.26.0). When set it
// OVERRIDES the individual knobs above. null = no profile (default).
SavingsProfile *string `json:"savings_profile"`
}
// savingsProfiles is the set of named profiles headroom exposes via the library
// compress() path. Validation rejects anything else so an unknown name can never
// reach compress() — where it would raise (it's applied before compress()'s own
// fail-open try) and make the pool recycle the worker on every request.
//
// agent-90 and balanced date to 0.26.0; the coding and general workload personas
// were added in 0.30.0. All four apply cleanly via compress() (the library path
// sets only the standard CompressConfig knobs each profile carries). Keep this in
// sync with headroom.agent_savings._PROFILES.
var savingsProfiles = map[string]bool{
"agent-90": true,
"balanced": true,
"coding": true,
"general": true,
}
// defaultSettings mirrors headroom's CompressConfig defaults (compress.py).
func defaultSettings() CompressSettings {
return CompressSettings{
CompressUserMessages: false,
CompressSystemMessages: true,
ProtectRecent: 4,
ProtectAnalysisContext: true,
TargetRatio: nil,
MinTokensToCompress: 250,
KompressModel: nil,
}
}
// validate rejects nonsensical settings so a bad PUT can't wedge the service.
func (s CompressSettings) validate() error {
if s.ProtectRecent < 0 {
return fmt.Errorf("protect_recent must be >= 0")
}
if s.MinTokensToCompress < 0 {
return fmt.Errorf("min_tokens_to_compress must be >= 0")
}
if s.TargetRatio != nil && (*s.TargetRatio <= 0 || *s.TargetRatio > 1) {
return fmt.Errorf("target_ratio must be in (0, 1] or null")
}
if s.KompressModel != nil && *s.KompressModel == "" {
return fmt.Errorf("kompress_model must be a non-empty string or null")
}
if s.SavingsProfile != nil && !savingsProfiles[*s.SavingsProfile] {
return fmt.Errorf("savings_profile must be one of %s, or null", strings.Join(sortedProfiles(), ", "))
}
return nil
}
// sortedProfiles returns the accepted savings_profile names in stable order, so
// the validation error text is deterministic and always matches savingsProfiles.
func sortedProfiles() []string {
names := make([]string, 0, len(savingsProfiles))
for name := range savingsProfiles {
names = append(names, name)
}
sort.Strings(names)
return names
}
// textEnabled reports whether a knob that drives the ML text compressor
// (Kompress) is on. It's the single source of truth for whether workers should
// preload that model at startup (passed to them via TSHEADROOM_PRELOAD; see
// worker.py's _warmup). Keep this in sync with any new ML-driving knob added to
// CompressSettings.
//
// CompressSystemMessages defaults to true, so with headroom-ai[ml] installed the
// ML model is needed for ordinary traffic even under an otherwise-default config
// (and headroom also uses Kompress as its fallback for tool/mixed content). We
// therefore include it here, which makes preload on by default — workers come up
// warm instead of cold-loading the ~600MB model on their first live request.
func (s CompressSettings) textEnabled() bool {
return s.CompressUserMessages || s.CompressSystemMessages || s.TargetRatio != nil
}
// settingsStore holds the current settings behind an atomic pointer (read once
// per request, swapped on update) and persists them to disk so the service
// starts up with the last state.
type settingsStore struct {
cur atomic.Pointer[CompressSettings]
path string // "" disables persistence
log *slog.Logger
mu sync.Mutex // serializes writers (the full read-merge-validate-save-swap)
}
// loadSettings builds a store, seeding it from path if present and valid,
// otherwise from defaults. A missing or corrupt file is non-fatal.
func loadSettings(path string, log *slog.Logger) *settingsStore {
st := &settingsStore{path: path, log: log}
s := defaultSettings()
if path != "" {
switch b, err := os.ReadFile(path); {
case err == nil:
var loaded CompressSettings
if jerr := json.Unmarshal(b, &loaded); jerr != nil {
log.Warn("config file unparseable; using defaults", "path", path, "err", jerr)
} else if verr := loaded.validate(); verr != nil {
log.Warn("config file invalid; using defaults", "path", path, "err", verr)
} else {
s = loaded
log.Info("loaded config", "path", path)
}
case os.IsNotExist(err):
log.Info("no config file yet; using defaults", "path", path)
default:
log.Warn("config file unreadable; using defaults", "path", path, "err", err)
}
}
st.cur.Store(&s)
return st
}
// get returns a snapshot of the current settings.
func (st *settingsStore) get() CompressSettings { return *st.cur.Load() }
// set validates, persists, then swaps in a full settings value. On a
// persistence error the in-memory settings are left unchanged so disk and
// memory stay consistent.
func (st *settingsStore) set(s CompressSettings) error {
st.mu.Lock()
defer st.mu.Unlock()
return st.applyLocked(s)
}
// merge applies a partial JSON update onto the current settings as one atomic
// read-modify-write, so concurrent PUTs can't lose each other's changes.
// Returns the resulting settings.
func (st *settingsStore) merge(body []byte) (CompressSettings, error) {
st.mu.Lock()
defer st.mu.Unlock()
merged := *st.cur.Load()
// Unmarshaling onto the current value leaves omitted fields untouched
// (partial update) and lets explicit null clear a pointer field.
if err := json.Unmarshal(body, &merged); err != nil {
return CompressSettings{}, err
}
if err := st.applyLocked(merged); err != nil {
return CompressSettings{}, err
}
return merged, nil
}
// applyLocked validates, persists, and swaps in s. The caller must hold st.mu.
func (st *settingsStore) applyLocked(s CompressSettings) error {
if err := s.validate(); err != nil {
return err
}
if err := st.save(s); err != nil {
return fmt.Errorf("persist config: %w", err)
}
st.cur.Store(&s)
return nil
}
// save atomically writes settings to disk. The caller must hold st.mu.
func (st *settingsStore) save(s CompressSettings) error {
if st.path == "" {
return nil
}
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
if dir := filepath.Dir(st.path); dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
// atomicfile.WriteFile writes to a unique temp file in the same dir, fsyncs,
// and renames into place (cleaning up the temp on error).
return atomicfile.WriteFile(st.path, append(b, '\n'), 0o644)
}
// configHandler serves the runtime tuning API: GET returns the current
// settings (plus the detected headroom_version); PUT merges the provided fields
// onto the current settings (partial updates allowed), validates, persists, and
// returns the result.
type configHandler struct {
store *settingsStore
log *slog.Logger
// headroomVersion reports the detected headroom-ai version and whether it's
// known yet. Used to surface the version on GET and to reject setting
// savings_profile on a version that predates it. nil in tests = unknown.
headroomVersion func() (string, bool)
}
// configView is the GET response: the settings plus the read-only detected
// headroom version. Embedding flattens CompressSettings' fields, so
// savings_profile stays visible even when the running headroom can't honor it.
type configView struct {
CompressSettings
HeadroomVersion string `json:"headroom_version,omitempty"`
}
const maxConfigBody = 64 << 10
func (h *configHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
view := configView{CompressSettings: h.store.get()}
if h.headroomVersion != nil {
if ver, ok := h.headroomVersion(); ok {
view.HeadroomVersion = ver
}
}
writeJSON(w, http.StatusOK, view)
case http.MethodPut, http.MethodPost:
body, err := io.ReadAll(io.LimitReader(r.Body, maxConfigBody))
if err != nil {
http.Error(w, "read body failed", http.StatusBadRequest)
return
}
// Reject *setting* savings_profile on a headroom too old to honor it
// (where it would be a silent no-op), before we validate/persist.
// Clearing it (null) or leaving it untouched is always allowed.
if h.settingNonNullSavingsProfile(body) {
if ver, ok := h.detectedVersion(); ok && !supportsSavingsProfile(ver) {
http.Error(w, fmt.Sprintf("savings_profile requires headroom-ai >= 0.26.0 (detected %s)", ver), http.StatusBadRequest)
return
}
}
updated, err := h.store.merge(body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
h.log.Info("config updated", "settings", updated)
writeJSON(w, http.StatusOK, updated)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// detectedVersion returns the detected headroom version and whether it's known,
// nil-safe for tests that don't wire the accessor.
func (h *configHandler) detectedVersion() (string, bool) {
if h.headroomVersion == nil {
return "", false
}
return h.headroomVersion()
}
// settingNonNullSavingsProfile reports whether the PUT body assigns
// savings_profile a non-null value (i.e. is trying to turn it on). Absent key or
// explicit null is not "setting" it.
func (h *configHandler) settingNonNullSavingsProfile(body []byte) bool {
var probe map[string]json.RawMessage
if err := json.Unmarshal(body, &probe); err != nil {
return false // malformed body; merge will reject it with a clearer error
}
raw, ok := probe["savings_profile"]
return ok && strings.TrimSpace(string(raw)) != "null"
}
// writeJSON writes v as a JSON response with the given status.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}