mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/usestrix/strix.git
synced 2026-09-20 08:03:42 +08:00
persist_current rewrote the config file with only the env vars set in the shell, so a run whose STRIX_LLM or LLM_API_KEY came from the file erased them and the next launch failed with MISSING REQUIRED ENVIRONMENT VARIABLES. Start from the stored env block, let a set env var override or replace the aliases of its field, and let an empty env var clear it.
295 lines
10 KiB
Python
295 lines
10 KiB
Python
"""Tests for strix.config.loader: JSON overrides, alias resolution, persistence."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
from pydantic import AliasChoices, Field, ValidationError
|
|
from pydantic.fields import FieldInfo
|
|
|
|
from strix.config import loader
|
|
from strix.config.settings import ContextSettings
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
_LLM_ENV_KEYS = [
|
|
"STRIX_LLM",
|
|
"LLM_API_KEY",
|
|
"OPENAI_API_KEY",
|
|
"LLM_API_BASE",
|
|
"OPENAI_API_BASE",
|
|
"OPENAI_BASE_URL",
|
|
"LITELLM_BASE_URL",
|
|
"OLLAMA_API_BASE",
|
|
"STRIX_REASONING_EFFORT",
|
|
"STRIX_FORCE_REQUIRED_TOOL_CHOICE",
|
|
"LLM_TIMEOUT",
|
|
"PERPLEXITY_API_KEY",
|
|
# RuntimeSettings
|
|
"STRIX_IMAGE",
|
|
"STRIX_RUNTIME_BACKEND",
|
|
# TelemetrySettings
|
|
"STRIX_TELEMETRY",
|
|
]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_loader_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Reset module globals and clear known env vars for deterministic runs."""
|
|
for key in _LLM_ENV_KEYS:
|
|
monkeypatch.delenv(key, raising=False)
|
|
monkeypatch.setattr(loader, "_cached", None)
|
|
monkeypatch.setattr(loader, "_override", None)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# _read_json_overrides
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_read_json_overrides_missing_file(tmp_path: Path) -> None:
|
|
assert loader._read_json_overrides(tmp_path / "nope.json") == {}
|
|
|
|
|
|
def test_read_json_overrides_corrupt_json(tmp_path: Path) -> None:
|
|
path = tmp_path / "cli-config.json"
|
|
path.write_text("{not valid json", encoding="utf-8")
|
|
assert loader._read_json_overrides(path) == {}
|
|
|
|
|
|
def test_read_json_overrides_non_dict_env(tmp_path: Path) -> None:
|
|
path = tmp_path / "cli-config.json"
|
|
path.write_text(json.dumps({"env": ["not", "a", "dict"]}), encoding="utf-8")
|
|
assert loader._read_json_overrides(path) == {}
|
|
|
|
|
|
def test_read_json_overrides_maps_to_nested_settings(tmp_path: Path) -> None:
|
|
path = tmp_path / "cli-config.json"
|
|
path.write_text(
|
|
json.dumps({"env": {"STRIX_LLM": "my-model", "PERPLEXITY_API_KEY": "pk"}}),
|
|
encoding="utf-8",
|
|
)
|
|
assert loader._read_json_overrides(path) == {
|
|
"llm": {"model": "my-model"},
|
|
"integrations": {"perplexity_api_key": "pk"},
|
|
}
|
|
|
|
|
|
def test_read_json_overrides_skips_keys_already_in_environ(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("STRIX_LLM", "from-env")
|
|
path = tmp_path / "cli-config.json"
|
|
path.write_text(json.dumps({"env": {"STRIX_LLM": "from-file"}}), encoding="utf-8")
|
|
# env wins -> the JSON value is not surfaced as an init kwarg.
|
|
assert loader._read_json_overrides(path) == {}
|
|
|
|
|
|
def test_read_json_overrides_env_wins_across_field_aliases(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# api_key resolves from AliasChoices("LLM_API_KEY", "OPENAI_API_KEY"). The env
|
|
# sets one alias while the persisted file holds another. Env must still win, so
|
|
# the stale file value must not be surfaced as an init kwarg (which outranks env).
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-env")
|
|
path = tmp_path / "cli-config.json"
|
|
path.write_text(json.dumps({"env": {"LLM_API_KEY": "sk-file"}}), encoding="utf-8")
|
|
assert loader._read_json_overrides(path) == {}
|
|
|
|
|
|
def test_read_json_overrides_env_wins_case_insensitively(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# Settings use case_sensitive=False, so a lowercase env var also counts as set.
|
|
monkeypatch.setenv("strix_llm", "from-env")
|
|
path = tmp_path / "cli-config.json"
|
|
path.write_text(json.dumps({"env": {"STRIX_LLM": "from-file"}}), encoding="utf-8")
|
|
assert loader._read_json_overrides(path) == {}
|
|
|
|
|
|
def test_read_json_overrides_uses_json_when_no_alias_in_environ(tmp_path: Path) -> None:
|
|
# No alias of api_key is set in the environment -> the file value is used, even
|
|
# when it is stored under a non-first alias.
|
|
path = tmp_path / "cli-config.json"
|
|
path.write_text(json.dumps({"env": {"OPENAI_API_KEY": "sk-file"}}), encoding="utf-8")
|
|
assert loader._read_json_overrides(path) == {"llm": {"api_key": "sk-file"}}
|
|
|
|
|
|
def test_tool_output_max_bytes_rejects_sub_notice_values() -> None:
|
|
with pytest.raises(ValidationError):
|
|
ContextSettings(STRIX_TOOL_OUTPUT_MAX_BYTES=64)
|
|
|
|
|
|
def test_tool_output_max_bytes_accepts_floor() -> None:
|
|
assert ContextSettings(STRIX_TOOL_OUTPUT_MAX_BYTES=1024).tool_output_max_bytes == 1024
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# _aliases_for
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_aliases_for_simple_alias() -> None:
|
|
finfo = FieldInfo(alias="SIMPLE_ALIAS")
|
|
assert loader._aliases_for(finfo) == ["SIMPLE_ALIAS"]
|
|
|
|
|
|
def test_aliases_for_alias_choices() -> None:
|
|
finfo: FieldInfo = Field( # type: ignore[assignment]
|
|
default=None,
|
|
validation_alias=AliasChoices("FIRST", "SECOND"),
|
|
)
|
|
assert loader._aliases_for(finfo) == ["FIRST", "SECOND"]
|
|
|
|
|
|
def test_aliases_for_string_validation_alias() -> None:
|
|
finfo: FieldInfo = Field(default=None, validation_alias="STR_ALIAS") # type: ignore[assignment]
|
|
assert loader._aliases_for(finfo) == ["STR_ALIAS"]
|
|
|
|
|
|
def test_aliases_for_no_alias() -> None:
|
|
assert loader._aliases_for(FieldInfo()) == []
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# apply_config_override + load_settings round-trip
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_apply_override_and_load_settings_round_trip(tmp_path: Path) -> None:
|
|
path = tmp_path / "cli-config.json"
|
|
path.write_text(
|
|
json.dumps({"env": {"STRIX_LLM": "round-trip-model", "PERPLEXITY_API_KEY": "pk"}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
loader.apply_config_override(path)
|
|
settings = loader.load_settings()
|
|
|
|
assert settings.llm.model == "round-trip-model"
|
|
assert settings.integrations.perplexity_api_key == "pk"
|
|
# Second call is memoized -> same object.
|
|
assert loader.load_settings() is settings
|
|
|
|
|
|
def test_apply_config_override_invalidates_cache(tmp_path: Path) -> None:
|
|
first = tmp_path / "first.json"
|
|
first.write_text(json.dumps({"env": {"STRIX_LLM": "first-model"}}), encoding="utf-8")
|
|
second = tmp_path / "second.json"
|
|
second.write_text(json.dumps({"env": {"STRIX_LLM": "second-model"}}), encoding="utf-8")
|
|
|
|
loader.apply_config_override(first)
|
|
assert loader.load_settings().llm.model == "first-model"
|
|
|
|
loader.apply_config_override(second)
|
|
assert loader.load_settings().llm.model == "second-model"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# persist_current
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_persist_current_writes_env_block(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("STRIX_LLM", "persisted-model")
|
|
target = tmp_path / "sub" / "cli-config.json"
|
|
loader.apply_config_override(target)
|
|
|
|
loader.persist_current()
|
|
|
|
assert target.exists()
|
|
assert json.loads(target.read_text(encoding="utf-8")) == {
|
|
"env": {"STRIX_LLM": "persisted-model"}
|
|
}
|
|
|
|
|
|
def test_persist_current_keeps_file_values_when_env_is_unset(tmp_path: Path) -> None:
|
|
target = tmp_path / "cli-config.json"
|
|
target.write_text(
|
|
json.dumps({"env": {"STRIX_LLM": "file-model", "LLM_API_KEY": "file-key"}}),
|
|
encoding="utf-8",
|
|
)
|
|
loader.apply_config_override(target)
|
|
assert loader.load_settings().llm.model == "file-model"
|
|
|
|
loader.persist_current()
|
|
|
|
assert json.loads(target.read_text(encoding="utf-8")) == {
|
|
"env": {"STRIX_LLM": "file-model", "LLM_API_KEY": "file-key"}
|
|
}
|
|
|
|
|
|
def test_persist_current_env_overrides_file_value(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
target = tmp_path / "cli-config.json"
|
|
target.write_text(
|
|
json.dumps({"env": {"STRIX_LLM": "file-model", "LLM_API_KEY": "file-key"}}),
|
|
encoding="utf-8",
|
|
)
|
|
loader.apply_config_override(target)
|
|
monkeypatch.setenv("STRIX_LLM", "env-model")
|
|
|
|
loader.persist_current()
|
|
|
|
assert json.loads(target.read_text(encoding="utf-8")) == {
|
|
"env": {"STRIX_LLM": "env-model", "LLM_API_KEY": "file-key"}
|
|
}
|
|
|
|
|
|
def test_persist_current_env_alias_replaces_other_alias_in_file(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
target = tmp_path / "cli-config.json"
|
|
target.write_text(json.dumps({"env": {"OPENAI_API_KEY": "old-key"}}), encoding="utf-8")
|
|
loader.apply_config_override(target)
|
|
monkeypatch.setenv("LLM_API_KEY", "new-key")
|
|
|
|
loader.persist_current()
|
|
|
|
assert json.loads(target.read_text(encoding="utf-8")) == {"env": {"LLM_API_KEY": "new-key"}}
|
|
|
|
|
|
def test_persist_current_empty_env_clears_file_value(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
target = tmp_path / "cli-config.json"
|
|
target.write_text(
|
|
json.dumps({"env": {"STRIX_LLM": "file-model", "PERPLEXITY_API_KEY": "pplx"}}),
|
|
encoding="utf-8",
|
|
)
|
|
loader.apply_config_override(target)
|
|
monkeypatch.setenv("PERPLEXITY_API_KEY", "")
|
|
|
|
loader.persist_current()
|
|
|
|
assert json.loads(target.read_text(encoding="utf-8")) == {"env": {"STRIX_LLM": "file-model"}}
|
|
|
|
|
|
def test_persist_current_replaces_corrupt_file(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
target = tmp_path / "cli-config.json"
|
|
target.write_text("{not json", encoding="utf-8")
|
|
loader.apply_config_override(target)
|
|
monkeypatch.setenv("STRIX_LLM", "env-model")
|
|
|
|
loader.persist_current()
|
|
|
|
assert json.loads(target.read_text(encoding="utf-8")) == {"env": {"STRIX_LLM": "env-model"}}
|
|
|
|
|
|
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"
|
|
loader.apply_config_override(target)
|
|
|
|
loader.persist_current()
|
|
|
|
assert target.stat().st_mode & 0o777 == 0o600
|