From e4c87edf14a2dc92dd957600a2c4aed3f9e2a950 Mon Sep 17 00:00:00 2001 From: axelray-dev <110029405+axelray-dev@users.noreply.github.com> Date: Fri, 7 Aug 2026 02:47:17 +0800 Subject: [PATCH] fix(settings): accept documented HEADROOM_* env names as settings keys --- headroom/settings_store.py | 24 +++++++++++++++ tests/test_proxy/test_settings_store.py | 41 +++++++++++++++++++++++++ tests/test_proxy_settings_endpoints.py | 1 + 3 files changed, 66 insertions(+) diff --git a/headroom/settings_store.py b/headroom/settings_store.py index c085c181f..b686d3b86 100644 --- a/headroom/settings_store.py +++ b/headroom/settings_store.py @@ -697,6 +697,7 @@ SETTINGS: tuple[SettingField, ...] = ( ) _BY_KEY: dict[str, SettingField] = {f.key: f for f in SETTINGS} +_BY_ENV: dict[str, SettingField] = {f.env: f for f in SETTINGS} class SettingsValidationError(Exception): @@ -714,6 +715,27 @@ class SettingsValidationError(Exception): ) +def _normalize_values(values: dict[str, Any]) -> dict[str, Any]: + """Rewrite known env aliases to their JSON/API keys.""" + normalized: dict[str, Any] = {} + source_keys: dict[str, str] = {} + conflicts: dict[str, str] = {} + for incoming_key, value in values.items(): + field = _BY_ENV.get(incoming_key) + key = field.key if field is not None else incoming_key + if key in normalized: + if normalized[key] != value: + conflicts[key] = ( + f"conflicting values supplied for {source_keys[key]!r} and {incoming_key!r}" + ) + continue + normalized[key] = value + source_keys[key] = incoming_key + if conflicts: + raise SettingsValidationError([], conflicts) + return normalized + + def _coerce(field: SettingField, value: Any) -> Any: """Coerce a raw JSON/env value to the field's Python type. @@ -792,6 +814,7 @@ def validate(values: dict[str, Any]) -> dict[str, Any]: Raises :class:`SettingsValidationError` when any key is unknown or any value fails coercion. Returns the coerced dict (``None`` values dropped) on success. """ + values = _normalize_values(values) unknown = [key for key in values if key not in _BY_KEY] field_errors: dict[str, str] = {} coerced: dict[str, Any] = {} @@ -879,6 +902,7 @@ def save(values: dict[str, Any]) -> None: secret's display value verbatim when the user hasn't touched it; anything else is validated/coerced and stored. """ + values = _normalize_values(values) clear_keys = {key for key, value in values.items() if value is None and key in _BY_KEY} retained_keys = { key diff --git a/tests/test_proxy/test_settings_store.py b/tests/test_proxy/test_settings_store.py index c6c1876cc..58f6bce66 100644 --- a/tests/test_proxy/test_settings_store.py +++ b/tests/test_proxy/test_settings_store.py @@ -63,6 +63,37 @@ class TestRoundTrip: class TestValidation: + def test_validate_accepts_env_aliases(self, workspace): + assert settings_store.validate({"HEADROOM_LOSSLESS": True, "HEADROOM_RPM": "30"}) == { + "lossless": True, + "rpm": 30, + } + + def test_save_accepts_env_alias_and_persists_short_key(self, workspace): + settings_store.save({"HEADROOM_LOSSLESS": True}) + + assert settings_store.load() == {"lossless": True} + + def test_env_alias_clear_removes_short_key(self, workspace): + settings_store.save({"lossless": True}) + + settings_store.save({"HEADROOM_LOSSLESS": None}) + + assert settings_store.load() == {} + + def test_conflicting_env_alias_and_short_key_rejected(self, workspace): + with pytest.raises(settings_store.SettingsValidationError) as exc: + settings_store.validate({"HEADROOM_LOSSLESS": True, "lossless": False}) + + assert exc.value.unknown_keys == [] + assert "lossless" in exc.value.field_errors + assert "HEADROOM_LOSSLESS" in exc.value.field_errors["lossless"] + + def test_same_env_alias_and_short_key_value_is_accepted(self, workspace): + assert settings_store.validate({"HEADROOM_LOSSLESS": True, "lossless": True}) == { + "lossless": True + } + def test_save_rejects_unknown_key(self, workspace): with pytest.raises(settings_store.SettingsValidationError) as exc: settings_store.save({"nope": 1}) @@ -156,6 +187,7 @@ class TestSecretMasking: ) monkeypatch.setattr(settings_store, "SETTINGS", registry) monkeypatch.setattr(settings_store, "_BY_KEY", {f.key: f for f in registry}) + monkeypatch.setattr(settings_store, "_BY_ENV", {f.env: f for f in registry}) settings_store.save({"log_file": "/tmp/secret.log"}) stored = settings_store.stored_values() @@ -187,6 +219,15 @@ class TestSecretMasking: "Saving _MASK should retain the stored value, not overwrite it" ) + def test_anthropic_extra_headers_retain_on_env_alias_mask(self, workspace, monkeypatch): + """Saving _MASK through the env alias retains the stored value.""" + _clear_env(monkeypatch) + settings_store.save({"anthropic_extra_headers": '{"Api-Key": "secret123"}'}) + + settings_store.save({"ANTHROPIC_TARGET_API_HEADERS": settings_store._MASK}) + + assert settings_store.load().get("anthropic_extra_headers") == '{"Api-Key": "secret123"}' + def test_anthropic_extra_headers_clear_on_none(self, workspace, monkeypatch): """Saving None for anthropic_extra_headers removes it.""" _clear_env(monkeypatch) diff --git a/tests/test_proxy_settings_endpoints.py b/tests/test_proxy_settings_endpoints.py index 31880c5ab..1e51f1001 100644 --- a/tests/test_proxy_settings_endpoints.py +++ b/tests/test_proxy_settings_endpoints.py @@ -207,6 +207,7 @@ class TestSecretMasking: ) monkeypatch.setattr(settings_store, "SETTINGS", registry) monkeypatch.setattr(settings_store, "_BY_KEY", {f.key: f for f in registry}) + monkeypatch.setattr(settings_store, "_BY_ENV", {f.env: f for f in registry}) settings_store.save({"log_file": "/tmp/secret.log"}) assert client.get("/settings").json()["log_file"] == settings_store._MASK