Skip to content

Commit fd76842

Browse files
authored
Merge pull request #23 from nangsontay/feat/setting-add
Feat/setting add
2 parents 6abe3bf + 27d1df3 commit fd76842

15 files changed

Lines changed: 1816 additions & 233 deletions

docs/environment-variables.md

Lines changed: 476 additions & 0 deletions
Large diffs are not rendered by default.

headroom/cli/proxy.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,9 +1183,12 @@ def proxy(
11831183
cloudcode_api_url=provider_api_overrides.cloudcode,
11841184
vertex_api_url=provider_api_overrides.vertex,
11851185
mode=effective_mode,
1186-
optimize=not no_optimize,
1187-
cache_enabled=not no_cache,
1188-
rate_limit_enabled=not no_rate_limit,
1186+
# CLI flag disables; else honor the env toggle (settings.json path).
1187+
# When the env var is unset _get_env_bool returns the True default, so
1188+
# behavior is identical to the historic `not no_<flag>`.
1189+
optimize=not no_optimize and _get_env_bool("HEADROOM_OPTIMIZE", True),
1190+
cache_enabled=not no_cache and _get_env_bool("HEADROOM_CACHE_ENABLED", True),
1191+
rate_limit_enabled=not no_rate_limit and _get_env_bool("HEADROOM_RATE_LIMIT_ENABLED", True),
11891192
rate_limit_requests_per_minute=rpm if rpm is not None else 60,
11901193
rate_limit_tokens_per_minute=tpm if tpm is not None else 100_000,
11911194
compress_user_messages=_get_env_bool("HEADROOM_COMPRESS_USER_MESSAGES", False),

headroom/dashboard/templates/dashboard.html

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,7 @@ <h3 class="mb-3 text-sm font-medium text-gray-300">Live Activity</h3>
469469
</div>
470470

471471
<!-- Prefix Cache Impact: current process only -->
472-
<template x-if="cacheSessionActive">
472+
<template x-if="hasPrefixCacheImpact">
473473
<div class="bg-surface rounded-lg p-4 border border-border mb-6">
474474
<div class="flex justify-between items-center mb-3">
475475
<div class="text-sm font-medium text-gray-300">Prefix Cache Impact</div>
@@ -516,6 +516,13 @@ <h3 class="mb-3 text-sm font-medium text-gray-300">Live Activity</h3>
516516
<div class="text-xs text-gray-500" x-show="!cacheSessionActive">no activity since restart</div>
517517
</div>
518518
</div>
519+
<template x-if="lifetimeCacheReadTokens > 0 || lifetimeCacheSavingsUsd > 0">
520+
<div class="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3 mb-4">
521+
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Cache Reads (lifetime)</div>
522+
<div class="text-2xl font-light tabular-nums text-emerald-400" x-text="formatNumber(lifetimeCacheReadTokens)"></div>
523+
<div class="text-xs text-emerald-400/70" x-text="'$' + formatCurrency(lifetimeCacheSavingsUsd) + ' saved'"></div>
524+
</div>
525+
</template>
519526
<!-- Cache efficiency bar (session-scoped; hidden until traffic arrives) -->
520527
<div x-show="cacheSessionActive">
521528
<div class="flex justify-between text-xs text-gray-500 mb-1">
@@ -2478,6 +2485,20 @@ <h3 class="mb-3 text-sm font-medium text-gray-300">Live Activity</h3>
24782485
return (this.stats.prefix_cache?.totals?.requests || 0) > 0;
24792486
},
24802487

2488+
get lifetimeCacheReadTokens() {
2489+
return this.stats.persistent_savings?.lifetime?.cache_read_tokens || 0;
2490+
},
2491+
2492+
get lifetimeCacheSavingsUsd() {
2493+
return this.stats.persistent_savings?.lifetime?.cache_savings_usd || 0;
2494+
},
2495+
2496+
get hasPrefixCacheImpact() {
2497+
return this.cacheSessionActive
2498+
|| this.lifetimeCacheReadTokens > 0
2499+
|| this.lifetimeCacheSavingsUsd > 0;
2500+
},
2501+
24812502
get cacheSavingsPercent() {
24822503
const t = this.stats.prefix_cache?.totals || {};
24832504
const total = (t.cache_read_tokens || 0) + (t.cache_write_tokens || 0);

headroom/dashboard/templates/settings.html

Lines changed: 240 additions & 177 deletions
Large diffs are not rendered by default.

headroom/proxy/runtime_env.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def getenv(name: str, default: str | None = None) -> str | None:
103103
return os.environ.get(name, default)
104104

105105

106-
def set_overrides(values: dict[str, object]) -> dict[str, str]:
106+
def set_overrides(values: Mapping[str, object]) -> dict[str, str]:
107107
"""Apply hot-reload overrides for known knobs. Returns what was applied.
108108
109109
Unknown keys and non-string values are ignored (the endpoint is loopback-only
@@ -126,6 +126,17 @@ def clear_overrides() -> None:
126126
_overrides.clear()
127127

128128

129+
def clear_override(name: str) -> bool:
130+
"""Drop a single override so ``getenv`` falls back to the environment.
131+
132+
Returns True if an override was present. Used when a live knob is unset in
133+
the settings GUI: removing the override (rather than pushing ``""``) lets
134+
the reader see the value as genuinely absent again.
135+
"""
136+
with _lock:
137+
return _overrides.pop(name, None) is not None
138+
139+
129140
def explicit_env(environ: Mapping[str, str] | None = None) -> dict[str, str]:
130141
"""Knobs *explicitly* set (non-empty) in ``environ`` — the wrap push payload.
131142

headroom/proxy/server.py

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -944,7 +944,12 @@ def _router_config_for(kompress_disabled: bool) -> ContentRouterConfig:
944944
self._code_aware_status = "lazy" if config.code_aware_enabled else "disabled"
945945

946946
_intercept_prefix: list = []
947-
if os.environ.get("HEADROOM_INTERCEPT_ENABLED"):
947+
if os.environ.get("HEADROOM_INTERCEPT_ENABLED", "").strip().lower() in (
948+
"1",
949+
"true",
950+
"yes",
951+
"on",
952+
):
948953
from headroom.proxy.interceptors import ToolResultInterceptorTransform
949954

950955
_intercept_prefix = [ToolResultInterceptorTransform()]
@@ -2387,12 +2392,23 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
23872392

23882393
# Defensive re-apply of file-backed settings for embedded/non-CLI callers
23892394
# that construct the app without going through the `headroom` CLI entrypoint
2390-
# (which already applies them before Click parsing). setdefault keeps
2391-
# explicit env exports authoritative; fail-open so it never blocks startup.
2395+
# (which already applies them before Click parsing). settings.json wins over
2396+
# a shell-exported env var here — highest priority below an explicit CLI arg;
2397+
# manifest_managed knobs keep the export. Fail-open so it never blocks startup.
23922398
try:
23932399
from headroom import settings_store
23942400

2395-
settings_store.apply_to_environ(settings_store.load())
2401+
stored = settings_store.load()
2402+
settings_store.apply_to_environ(stored)
2403+
# Live (Output Shaping) knobs are intentionally NOT written to os.environ
2404+
# (that would env-lock them in the GUI). Seed them into the hot-reload
2405+
# override store so persisted values stay active after a restart yet
2406+
# remain editable. The override wins over os.environ, so the stored
2407+
# value takes precedence over a shell export, matching settings-first.
2408+
_live_seed = settings_store.runtime_overrides(
2409+
settings_store.live_keys(list(stored)), stored
2410+
)
2411+
runtime_env.set_overrides(_live_seed)
23962412
except Exception: # noqa: BLE001 — settings load must never break startup
23972413
pass
23982414

@@ -3359,6 +3375,15 @@ async def settings_schema(_request: Request):
33593375
schema["supervised"] = mode != "foreground"
33603376
except Exception: # noqa: BLE001 — schema must render even if detection fails
33613377
schema["supervised"] = False
3378+
# Live (runtime_env) knobs apply without a restart, so their true current
3379+
# value is the hot-reload override — not the launch-time environ the store
3380+
# read. Reflect that so the form shows what is actually active right now.
3381+
for field_schema in schema["fields"]:
3382+
if field_schema.get("live"):
3383+
field_schema["value"] = settings_store.coerce_env_value(
3384+
field_schema["key"], runtime_env.getenv(field_schema["env"])
3385+
)
3386+
schema["values"] = {f["key"]: f["value"] for f in schema["fields"]}
33623387
return JSONResponse(status_code=200, content=schema)
33633388

33643389
@app.get("/settings", dependencies=[Depends(_require_loopback)])
@@ -3399,6 +3424,20 @@ async def settings_post(request: Request):
33993424
)
34003425
after = settings_store.load()
34013426
changed_keys = sorted(k for k in set(before) | set(after) if before.get(k) != after.get(k))
3427+
# Live (Output Shaping) knobs hot-reload with no restart: push their new
3428+
# values to the runtime-env override store so they take effect on the
3429+
# next request. Every other knob is startup-captured and needs a restart.
3430+
live_changed = settings_store.live_keys(changed_keys)
3431+
if live_changed:
3432+
runtime_env.set_overrides(settings_store.runtime_overrides(live_changed, after))
3433+
# A live knob cleared to its default is absent from `after`; drop its
3434+
# override so the reader falls back to the adaptive default right away.
3435+
for key in live_changed:
3436+
if key not in after:
3437+
env = settings_store.env_for(key)
3438+
if env:
3439+
runtime_env.clear_override(env)
3440+
restart_changed = [k for k in changed_keys if k not in live_changed]
34023441
record_admin_action(
34033442
request=request,
34043443
action="settings_update",
@@ -3407,7 +3446,12 @@ async def settings_post(request: Request):
34073446
)
34083447
return JSONResponse(
34093448
status_code=200,
3410-
content={"ok": True, "needs_restart": bool(changed_keys), "changed_keys": changed_keys},
3449+
content={
3450+
"ok": True,
3451+
"needs_restart": bool(restart_changed),
3452+
"changed_keys": changed_keys,
3453+
"live_keys": live_changed,
3454+
},
34113455
)
34123456

34133457
@app.post(

0 commit comments

Comments
 (0)