diff --git a/headroom/agent_savings.py b/headroom/agent_savings.py index a201dbb44..a62cf4f6c 100644 --- a/headroom/agent_savings.py +++ b/headroom/agent_savings.py @@ -390,7 +390,9 @@ def proxy_pipeline_kwargs(config: object) -> dict[str, object]: return kwargs -def seed_proxy_env_defaults(env: MutableMapping[str, str] | None = None) -> None: +def seed_proxy_env_defaults( + env: MutableMapping[str, str] | None = None, +) -> frozenset[str]: """Seed the process env with the savings-profile defaults (default: coding). Call at proxy EXECUTABLE entry points (the ``headroom proxy`` command and the @@ -403,9 +405,11 @@ def seed_proxy_env_defaults(env: MutableMapping[str, str] | None = None) -> None keep clean (unseeded) defaults and test isolation is preserved. """ target = os.environ if env is None else env + before = set(target) # apply_agent_savings_env_defaults honors an explicit HEADROOM_SAVINGS_PROFILE # already in the env and otherwise falls back to DEFAULT_PROFILE (coding). apply_agent_savings_env_defaults(target) + return frozenset(key for key in target if key not in before) def with_target_savings( diff --git a/headroom/proxy/models.py b/headroom/proxy/models.py index 6b85689c3..ed68a9551 100644 --- a/headroom/proxy/models.py +++ b/headroom/proxy/models.py @@ -174,6 +174,10 @@ class ProxyConfig: # Optimization mode: "token" (rewrite for max compression) or # "cache" (freeze prior turns for prefix-cache stability). mode: str = "token" + # Environment keys generated by the savings profile at process startup. + # They are runtime defaults, not explicit operator exports; the settings + # schema uses this provenance to avoid reporting false env overrides. + profile_seeded_env_keys: frozenset[str] = field(default_factory=frozenset, repr=False) # Optimization optimize: bool = True diff --git a/headroom/proxy/server.py b/headroom/proxy/server.py index f90b082ee..ba3f76134 100644 --- a/headroom/proxy/server.py +++ b/headroom/proxy/server.py @@ -3670,7 +3670,10 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: ) async def settings_schema(_request: Request): """Registry + grouped fields + effective values for the settings form.""" - schema = settings_store.to_schema() + schema = settings_store.to_schema( + runtime_values=_settings_runtime_values(config), + env_override_exclusions=config.profile_seeded_env_keys, + ) # Tell the UI whether this is a supervised (docker/service) install, where # manifest-baked knobs (HEADROOM_PORT/HEADROOM_HOST) are owned by the # install manifest and must be rendered read-only. Foreground proxies @@ -5188,7 +5191,7 @@ def _json_ready(value: Any) -> Any: return {field.name: _json_ready(getattr(value, field.name)) for field in fields(value)} if isinstance(value, dict): return {str(key): _json_ready(item) for key, item in value.items()} - if isinstance(value, list | tuple | set): + if isinstance(value, list | tuple | set | frozenset): return [_json_ready(item) for item in value] return value @@ -5209,6 +5212,51 @@ def _proxy_config_payload(config: ProxyConfig) -> dict[str, Any]: return payload +_SETTINGS_CONFIG_ALIASES: dict[str, str] = { + "workers": "worker_processes", + "rpm": "rate_limit_requests_per_minute", + "tpm": "rate_limit_tokens_per_minute", + "budget": "budget_limit_usd", + "log_messages": "log_full_messages", + "ccr_inline_resolve": "ccr_resolve_markers_inline", + "subscription_poll_interval": "subscription_poll_interval_s", + "request_timeout": "request_timeout_seconds", + "min_evidence": "traffic_learning_min_evidence", + "memory_project_root": "memory_project_root_override", + "anthropic_base_url": "anthropic_api_url", + "openai_base_url": "openai_api_url", + "region": "bedrock_region", +} + + +def _settings_runtime_values(config: ProxyConfig) -> dict[str, Any]: + """Project the live proxy config onto the curated settings registry.""" + from headroom import settings_store + + values: dict[str, Any] = {} + for setting in settings_store.SETTINGS: + attribute = _SETTINGS_CONFIG_ALIASES.get(setting.key, setting.key) + if not hasattr(config, attribute): + continue + value = getattr(config, attribute) + if isinstance(value, set | frozenset): + value = ",".join(sorted(value)) + values[setting.key] = value + + values.update( + { + "no_ccr": not ( + config.ccr_inject_tool or config.ccr_inject_marker or config.ccr_handle_responses + ), + "no_ccr_proactive_expansion": not config.ccr_proactive_expansion, + "no_subscription_tracking": not config.subscription_tracking_enabled, + "no_memory_tools": not config.memory_inject_tools, + "no_memory_context": not config.memory_inject_context, + } + ) + return values + + def _proxy_config_from_env() -> ProxyConfig: raw_config = os.environ.get(_MULTI_WORKER_CONFIG_ENV) if raw_config: @@ -5221,6 +5269,8 @@ def _proxy_config_from_env() -> ProxyConfig: from headroom.rollout import RolloutSnapshot values["rollout"] = RolloutSnapshot.from_internal_dict(rollout_value) + if "profile_seeded_env_keys" in values: + values["profile_seeded_env_keys"] = frozenset(values["profile_seeded_env_keys"]) return ProxyConfig(**values) except (KeyError, TypeError, ValueError, json.JSONDecodeError): logger.warning( @@ -5296,8 +5346,12 @@ def create_app_from_env() -> FastAPI: # tool-search, dedupe, read protection, …). setdefault → explicit env wins. from headroom.agent_savings import seed_proxy_env_defaults - seed_proxy_env_defaults() - return create_app(_proxy_config_from_env()) + seeded_env_keys = seed_proxy_env_defaults() + config = _proxy_config_from_env() + config.profile_seeded_env_keys = frozenset( + set(config.profile_seeded_env_keys) | set(seeded_env_keys) + ) + return create_app(config) def _get_code_aware_banner_status(config: ProxyConfig) -> str: @@ -5364,9 +5418,12 @@ def run_server( # already resolved into `config` above via their inline defaults. from headroom.agent_savings import seed_proxy_env_defaults - seed_proxy_env_defaults() + seeded_env_keys = seed_proxy_env_defaults() config = config or ProxyConfig() + config.profile_seeded_env_keys = frozenset( + set(config.profile_seeded_env_keys) | set(seeded_env_keys) + ) if workers < 1: raise ValueError("workers must be >= 1") config.worker_processes = workers diff --git a/headroom/settings_store.py b/headroom/settings_store.py index ad134ebc0..7aed75ff9 100644 --- a/headroom/settings_store.py +++ b/headroom/settings_store.py @@ -63,6 +63,20 @@ class SettingField: # the body-resolved HEADROOM_CODE_AWARE_ENABLED reader both accept). SETTINGS: tuple[SettingField, ...] = ( # --- Compression --- + SettingField( + "HEADROOM_MODE", + "mode", + "Proxy mode", + "Compression", + "enum", + default="cache", + choices=("token", "cache"), + help=( + "Optimization posture: token rewrites history for maximum compression; " + "cache preserves prior turns for prefix-cache stability." + ), + tier="basic", + ), SettingField( "HEADROOM_SAVINGS_PROFILE", "savings_profile", @@ -947,8 +961,25 @@ def apply_to_environ(values: dict[str, Any]) -> None: os.environ.setdefault(field.env, _serialize(field, value)) -def effective_values(stored: dict[str, Any] | None = None) -> dict[str, Any]: - """The value actually active now for each knob: default ← file ← environ.""" +def _coerce_runtime_values(runtime_values: dict[str, Any] | None) -> dict[str, Any]: + """Coerce known live-config values without exposing invalid internal state.""" + result: dict[str, Any] = {} + for key, value in (runtime_values or {}).items(): + field = _BY_KEY.get(key) + if field is None: + continue + try: + result[key] = _coerce(field, value) + except (ValueError, TypeError): + continue + return result + + +def effective_values( + stored: dict[str, Any] | None = None, + runtime_values: dict[str, Any] | None = None, +) -> dict[str, Any]: + """The active value for each knob: default ← file ← environ ← runtime.""" if stored is None: stored = load() result: dict[str, Any] = {} @@ -961,6 +992,7 @@ def effective_values(stored: dict[str, Any] | None = None) -> dict[str, Any]: except (ValueError, TypeError): pass # unparseable env: keep the file/default value result[field.key] = value + result.update(_coerce_runtime_values(runtime_values)) return result @@ -978,14 +1010,26 @@ def stored_values(mask_secrets: bool = True) -> dict[str, Any]: return {key: _mask(_BY_KEY[key], value) for key, value in values.items()} -def to_schema() -> dict[str, Any]: +def to_schema( + *, + runtime_values: dict[str, Any] | None = None, + env_override_exclusions: set[str] | frozenset[str] = frozenset(), +) -> dict[str, Any]: """Registry + grouped fields + effective values for the UI. Secrets masked. All curated knobs are startup-captured, so every key is restart-required; ``needs_restart_keys`` lists them for the UI's "restart to apply" banner. + + ``runtime_values`` is the live ``ProxyConfig`` tier. It wins over values + re-read from ``os.environ``, which can contain profile defaults that were + seeded only after CLI options had already been resolved. Those generated + defaults belong in ``env_override_exclusions`` so they are not presented as + explicit operator exports. """ stored = load() - effective = effective_values(stored) + environment_effective = effective_values(stored) + runtime = _coerce_runtime_values(runtime_values) + effective = {**environment_effective, **runtime} fields: list[dict[str, Any]] = [] for field in SETTINGS: fields.append( @@ -1003,7 +1047,10 @@ def to_schema() -> dict[str, Any]: "minimum": field.minimum, "maximum": field.maximum, "tier": field.tier, - "env_override": bool(os.environ.get(field.env)), + "env_override": bool(os.environ.get(field.env)) + and field.env not in env_override_exclusions, + "runtime_override": field.key in runtime + and runtime[field.key] != environment_effective[field.key], "value": _mask(field, effective.get(field.key)), "stored": _mask(field, stored.get(field.key)), } diff --git a/tests/test_agent_savings.py b/tests/test_agent_savings.py index 691a24b3e..acffdf2fe 100644 --- a/tests/test_agent_savings.py +++ b/tests/test_agent_savings.py @@ -15,6 +15,7 @@ from headroom.agent_savings import ( apply_agent_savings_profile, get_agent_savings_profile, proxy_pipeline_kwargs, + seed_proxy_env_defaults, with_target_savings, ) from headroom.cli import wrap as wrap_module @@ -171,6 +172,18 @@ def test_agent_savings_env_defaults_preserve_user_overrides() -> None: assert env["HEADROOM_SMART_CRUSHER_COMPACTION"] == "0" +def test_seed_proxy_env_defaults_reports_only_generated_keys() -> None: + env = {"HEADROOM_MODE": "token", "HEADROOM_LOSSLESS": "1"} + + seeded = seed_proxy_env_defaults(env) + + assert "HEADROOM_MODE" not in seeded + assert "HEADROOM_LOSSLESS" not in seeded + assert "HEADROOM_SAVINGS_PROFILE" in seeded + assert env["HEADROOM_MODE"] == "token" + assert env["HEADROOM_LOSSLESS"] == "1" + + def test_unknown_agent_savings_profile_falls_back_to_default( caplog: pytest.LogCaptureFixture, ) -> None: diff --git a/tests/test_proxy/test_settings_store.py b/tests/test_proxy/test_settings_store.py index 58f6bce66..0a352499c 100644 --- a/tests/test_proxy/test_settings_store.py +++ b/tests/test_proxy/test_settings_store.py @@ -175,6 +175,45 @@ class TestEffectiveValues: monkeypatch.setenv("HEADROOM_SAVINGS_PROFILE", "general") assert settings_store.effective_values()["savings_profile"] == "general" + def test_runtime_values_override_environment(self, workspace, monkeypatch): + _clear_env(monkeypatch) + monkeypatch.setenv("HEADROOM_LOSSLESS", "0") + + values = settings_store.effective_values(runtime_values={"lossless": True}) + + assert values["lossless"] is True + + def test_schema_marks_runtime_difference_without_mislabeling_seeded_env( + self, workspace, monkeypatch + ): + _clear_env(monkeypatch) + monkeypatch.setenv("HEADROOM_MODE", "cache") + monkeypatch.setenv("HEADROOM_LOSSLESS", "0") + + schema = settings_store.to_schema( + runtime_values={"mode": "token", "lossless": True}, + env_override_exclusions={"HEADROOM_MODE", "HEADROOM_LOSSLESS"}, + ) + by_key = {field["key"]: field for field in schema["fields"]} + + assert by_key["mode"]["value"] == "token" + assert by_key["mode"]["runtime_override"] is True + assert by_key["mode"]["env_override"] is False + assert by_key["lossless"]["value"] is True + assert by_key["lossless"]["runtime_override"] is True + assert by_key["lossless"]["env_override"] is False + + def test_schema_keeps_explicit_environment_override(self, workspace, monkeypatch): + _clear_env(monkeypatch) + monkeypatch.setenv("HEADROOM_LOSSLESS", "1") + + schema = settings_store.to_schema(runtime_values={"lossless": True}) + lossless = next(field for field in schema["fields"] if field["key"] == "lossless") + + assert lossless["value"] is True + assert lossless["runtime_override"] is False + assert lossless["env_override"] is True + class TestSecretMasking: def test_schema_and_stored_mask_secret(self, workspace, monkeypatch): diff --git a/tests/test_proxy_settings_endpoints.py b/tests/test_proxy_settings_endpoints.py index 1e51f1001..2b3e9b5bf 100644 --- a/tests/test_proxy_settings_endpoints.py +++ b/tests/test_proxy_settings_endpoints.py @@ -21,20 +21,24 @@ from headroom import settings_store # noqa: E402 from headroom.proxy.server import ProxyConfig, create_app # noqa: E402 -def _make_app(): - return create_app( - ProxyConfig( - optimize=False, - cache_enabled=False, - rate_limit_enabled=False, - cost_tracking_enabled=False, - log_requests=False, - ccr_inject_tool=False, - ccr_handle_responses=False, - ccr_context_tracking=False, - image_optimize=False, - ) - ) +def _make_config(**overrides): + values = { + "optimize": False, + "cache_enabled": False, + "rate_limit_enabled": False, + "cost_tracking_enabled": False, + "log_requests": False, + "ccr_inject_tool": False, + "ccr_handle_responses": False, + "ccr_context_tracking": False, + "image_optimize": False, + } + values.update(overrides) + return ProxyConfig(**values) + + +def _make_app(**overrides): + return create_app(_make_config(**overrides)) @pytest.fixture @@ -77,6 +81,28 @@ class TestSchemaAndRead: assert resp.status_code == 200, resp.text assert resp.json() == {"target_ratio": 0.5, "savings_profile": "balanced"} + def test_schema_reports_live_config_and_seed_provenance(self, workspace, monkeypatch): + monkeypatch.setenv("HEADROOM_MODE", "cache") + monkeypatch.setenv("HEADROOM_LOSSLESS", "0") + app = _make_app( + mode="token", + lossless=True, + worker_processes=2, + profile_seeded_env_keys=frozenset({"HEADROOM_MODE", "HEADROOM_LOSSLESS"}), + ) + client = TestClient(app, base_url="http://127.0.0.1", client=("127.0.0.1", 12345)) + + resp = client.get("/settings/schema") + + assert resp.status_code == 200, resp.text + by_key = {field["key"]: field for field in resp.json()["fields"]} + assert by_key["mode"]["value"] == "token" + assert by_key["mode"]["env_override"] is False + assert by_key["lossless"]["value"] is True + assert by_key["lossless"]["runtime_override"] is True + assert by_key["lossless"]["env_override"] is False + assert by_key["workers"]["value"] == 2 + class TestEndpointsGroup: def test_schema_includes_endpoints_group_and_secret_flags(self, client): diff --git a/tests/test_rollout.py b/tests/test_rollout.py index 1c2e0f43c..7c6abd0e8 100644 --- a/tests/test_rollout.py +++ b/tests/test_rollout.py @@ -223,7 +223,11 @@ def test_multi_worker_config_round_trip_preserves_typed_rollout(monkeypatch) -> "HEADROOM_DISABLE_FEATURES": "read_maturation", } ) - original = ProxyConfig(rollout=rollout, worker_processes=2) + original = ProxyConfig( + rollout=rollout, + worker_processes=2, + profile_seeded_env_keys=frozenset({"HEADROOM_MODE", "HEADROOM_LOSSLESS"}), + ) monkeypatch.setenv(_MULTI_WORKER_CONFIG_ENV, json.dumps(_proxy_config_payload(original))) restored = _proxy_config_from_env() @@ -233,6 +237,7 @@ def test_multi_worker_config_round_trip_preserves_typed_rollout(monkeypatch) -> assert restored.rollout.is_enabled("proxy_output_shaper") is True assert restored.rollout.is_enabled("read_maturation") is False assert restored.worker_processes == 2 + assert restored.profile_seeded_env_keys == frozenset({"HEADROOM_MODE", "HEADROOM_LOSSLESS"}) def test_documented_proxy_json_without_internal_snapshot_is_preserved(monkeypatch) -> None: