diff --git a/strix/config/loader.py b/strix/config/loader.py index e6edd548..9edee5bc 100644 --- a/strix/config/loader.py +++ b/strix/config/loader.py @@ -6,7 +6,8 @@ import json import logging import os from pathlib import Path -from typing import TYPE_CHECKING, Any +from types import UnionType +from typing import TYPE_CHECKING, Any, Union, get_args, get_origin from pydantic import AliasChoices, BaseModel @@ -126,13 +127,36 @@ def _read_json_overrides(path: Path) -> dict[str, dict[str, Any]]: continue # env wins under some alias; skip the JSON file for this field for alias in aliases: if alias in env_block_upper: - sub_data[fname] = env_block_upper[alias] + sub_data[fname] = _decode_saved_value(finfo, env_block_upper[alias]) break if sub_data: nested[sub_name] = sub_data return nested +def _decode_saved_value(finfo: FieldInfo, value: Any) -> Any: + """Decode a JSON string saved for a dict field, as pydantic does for env vars. + + ``persist_current`` saves env values verbatim, so a dict setting such as + ``LLM_EXTRA_HEADERS`` lands in the file as JSON text. Values that are + already objects, or aren't valid JSON, are left for validation to judge. + """ + if not isinstance(value, str) or not _is_dict_field(finfo): + return value + try: + return json.loads(value) + except ValueError: + return value + + +def _is_dict_field(finfo: FieldInfo) -> bool: + annotation = finfo.annotation + candidates = ( + get_args(annotation) if get_origin(annotation) in (Union, UnionType) else (annotation,) + ) + return any(c is dict or get_origin(c) is dict for c in candidates) + + def _first_alias_value(aliases: list[str], source: Mapping[str, Any]) -> Any | None: return next((source[alias] for alias in aliases if alias in source), None) diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py index d083236a..463853d2 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -37,6 +37,12 @@ _LLM_ENV_KEYS = [ "STRIX_RUNTIME_BACKEND", # TelemetrySettings "STRIX_TELEMETRY", + # JSON-object settings + "LLM_EXTRA_HEADERS", + "LLM_EXTRA_BODY", + "STRIX_DEDUPE_MODEL", + "DEDUPE_LLM_EXTRA_HEADERS", + "DEDUPE_LLM_EXTRA_BODY", ] @@ -406,6 +412,57 @@ def test_persist_current_replaces_corrupt_file( assert json.loads(target.read_text(encoding="utf-8")) == {"env": {"STRIX_LLM": "env-model"}} +@pytest.mark.parametrize( + ("env_var", "section", "field"), + [ + ("LLM_EXTRA_HEADERS", "llm", "extra_headers"), + ("LLM_EXTRA_BODY", "llm", "extra_body"), + ("DEDUPE_LLM_EXTRA_HEADERS", "dedupe", "extra_headers"), + ("DEDUPE_LLM_EXTRA_BODY", "dedupe", "extra_body"), + ], +) +def test_persisted_json_object_setting_reloads_without_env( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, env_var: str, section: str, field: str +) -> None: + loader.apply_config_override(tmp_path / "cli-config.json") + monkeypatch.setenv(env_var, '{"X-Tenant": "acme"}') + loader.load_settings() + loader.persist_current() + monkeypatch.delenv(env_var) + loader.apply_config_override(tmp_path / "cli-config.json") + + settings = loader.load_settings() + + assert getattr(getattr(settings, section), field) == {"X-Tenant": "acme"} + + +def test_json_object_setting_saved_as_object_still_loads(tmp_path: Path) -> None: + target = tmp_path / "cli-config.json" + target.write_text( + json.dumps({"env": {"LLM_EXTRA_BODY": {"service_tier": "flex"}}}), encoding="utf-8" + ) + loader.apply_config_override(target) + + assert loader.load_settings().llm.extra_body == {"service_tier": "flex"} + + +def test_invalid_saved_json_object_setting_is_rejected(tmp_path: Path) -> None: + target = tmp_path / "cli-config.json" + target.write_text(json.dumps({"env": {"LLM_EXTRA_BODY": "not json"}}), encoding="utf-8") + loader.apply_config_override(target) + + with pytest.raises(ValidationError): + loader.load_settings() + + +def test_saved_string_setting_is_not_json_decoded(tmp_path: Path) -> None: + target = tmp_path / "cli-config.json" + target.write_text(json.dumps({"env": {"STRIX_LLM": '{"not": "decoded"}'}}), encoding="utf-8") + loader.apply_config_override(target) + + assert loader.load_settings().llm.model == '{"not": "decoded"}' + + def test_persist_current_sets_0600_mode(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("STRIX_LLM", "persisted-model") target = tmp_path / "cli-config.json"