Files
hermes-webui/tests/test_none_providers_config_guard.py
nesquena-hermesandlidi1011 94d952c3bb fix: harden None providers config across onboarding/providers/routes (salvage of #3967)
A config.yaml with an explicit empty/null `providers:` key parses to None in
PyYAML. `cfg.get("providers", {})` only returns the {} default when the key is
*absent*, so an explicit null yields None instead of an empty mapping. master
already guards each read with `isinstance(providers_cfg, dict)`, but the value
fed to that guard is still a footgun: the natural chained form
`cfg.get("providers", {}).get(...)` (already fixed once in api/config.py)
crashes with AttributeError on None.

Harden every providers-key read in the three remaining files to
`cfg.get("providers") or {}` so an explicit null degrades to an empty dict at
the source, matching the existing api/config.py convention. Sites hardened:

  api/onboarding.py:578  (_provider_api_key_present)
  api/providers.py:1051  (_provider_has_shadowed_codex_oauth_value)
  api/providers.py:1219  (_provider_has_key)
  api/providers.py:1265  (_get_provider_api_key)
  api/providers.py:2348  (get_providers catalog)
  api/providers.py:2781  (_clean_provider_key_from_config)
  api/routes.py:5045     (_context_length_config_api_key_for_provider)
  api/routes.py:5110     (_context_length_lookup_inputs_for_model)
  api/routes.py:15916    (_handle_live_models)

Adds tests/test_none_providers_config_guard.py: per-file source-form pins (fail
on master / on any single-file revert) plus behavioural checks driving the real
functions with `providers: None` to prove no crash and parity with the
empty-mapping config. Updates the existing test_issue3717 routes-string pin to
match the hardened form.

This is a focused salvage of grab-bag PR #3967 -- only the None-providers
hardening is taken; the PR's unrelated changes (account-usage worker-pool
refactor, function removals) are intentionally dropped.

Co-authored-by: lidi1011 <lidi1011@users.noreply.github.com>
2026-06-28 08:57:39 +00:00

190 lines
7.8 KiB
Python

"""Regression guard: a config with an explicit null ``providers:`` key must not
break the provider-config read paths in onboarding / providers / routes, and
must behave exactly as if ``providers`` were an empty mapping.
Background (salvage of grab-bag PR #3967, thanks @lidi1011)
----------------------------------------------------------
``cfg.get("providers", {})`` only returns the ``{}`` default when the key is
*absent*. When ``config.yaml`` contains an explicit empty/null key::
providers:
PyYAML parses that as ``None``, so ``cfg.get("providers", {})`` yields ``None``
(not ``{}``). master already wraps each of these reads in an
``isinstance(providers_cfg, dict)`` guard, so the None value silently disables
the provider lookup -- but the value handed to that guard is still a footgun:
any future maintainer who chains ``cfg.get("providers", {}).get(...)`` (the
natural-looking pattern, already used in api/config.py before its own #3967-style
fix) would crash with ``AttributeError: 'NoneType' object has no attribute
'get'``.
The hardening replaces every such read with ``cfg.get("providers") or {}`` so an
explicit null degrades to an empty dict at the *source*, matching the existing
api/config.py convention.
This module pins the fix two ways:
1. **Source-form pins** (``test_<file>_hardens_providers_key_read``): assert that
onboarding / providers / routes no longer contain the unguarded
``cfg.get("providers", {})`` form and *do* contain the hardened
``cfg.get("providers") or {}`` form. These FAIL on master (where the
unguarded form is present) and would FAIL again if anyone reverts the guard
in any one of the three files -- so the test is non-vacuous per file.
2. **Behavioural checks**: drive the real functions with ``providers: None`` and
assert they neither raise nor diverge from the ``providers: {}`` / absent-key
behaviour. This guards the runtime contract end to end.
"""
import ast
import pathlib
import pytest
import api.config as config
import api.onboarding as onboarding
import api.providers as providers
import api.routes as routes
_REPO = pathlib.Path(__file__).resolve().parents[1]
_UNGUARDED = 'cfg.get("providers", {})'
_HARDENED = 'cfg.get("providers") or {}'
def _src(name: str) -> str:
return (_REPO / "api" / name).read_text(encoding="utf-8")
# --- source-form pins (fail on master / on any per-file revert) -----------
@pytest.mark.parametrize("filename", ["onboarding.py", "providers.py", "routes.py"])
def test_file_hardens_providers_key_read_against_none(filename):
"""Each target file must read the providers key as ``... or {}``.
Non-vacuous: on master the unguarded ``cfg.get("providers", {})`` form is
present in every one of these files, so this assertion fails there. Reverting
the guard in any single file re-fails it -- the guard is pinned per file.
"""
source = _src(filename)
# The hardened form must appear at least once.
assert _HARDENED in source, (
f"api/{filename} should read the providers key as `{_HARDENED}` so an "
f"explicit null `providers:` degrades to an empty mapping (salvage #3967)."
)
# The unguarded form must not appear as live code. It may legitimately appear
# inside an explanatory comment, so strip comment-only occurrences before
# asserting absence.
code_lines = []
for line in source.splitlines():
stripped = line.lstrip()
if stripped.startswith("#"):
continue
# Drop trailing inline comments crudely (good enough: the token we look
# for contains no '#').
code_lines.append(line.split("#", 1)[0])
code = "\n".join(code_lines)
assert _UNGUARDED not in code, (
f"api/{filename} still reads the providers key as the unguarded "
f"`{_UNGUARDED}`; an explicit `providers:` (None) in config.yaml would "
f"yield None instead of an empty mapping (salvage #3967)."
)
@pytest.mark.parametrize("filename", ["onboarding.py", "providers.py", "routes.py"])
def test_target_files_parse(filename):
"""Sanity: the hardened files are valid Python (guards real edits)."""
ast.parse(_src(filename), filename=filename)
# --- behavioural: onboarding ----------------------------------------------
def _onboarding_cfg(providers_value):
return {
"model": {"provider": "custom", "api_key": "", "base_url": "http://localhost:1"},
"providers": providers_value,
"custom_providers": [],
}
def test_onboarding_provider_api_key_present_handles_none_providers():
"""``_provider_api_key_present`` must treat ``providers: None`` like ``{}``."""
none_cfg = _onboarding_cfg(None)
empty_cfg = _onboarding_cfg({})
for provider in ("custom", "openai", "anthropic"):
got_none = onboarding._provider_api_key_present(provider, none_cfg, {})
got_empty = onboarding._provider_api_key_present(provider, empty_cfg, {})
assert got_none == got_empty
assert got_none is False # no key configured anywhere
def test_onboarding_none_providers_does_not_mask_configured_key():
"""A provider key set under model.api_key is still found with providers: None."""
cfg = _onboarding_cfg(None)
cfg["model"] = {"provider": "custom", "api_key": "sk-xyz", "base_url": "http://x"}
assert onboarding._provider_api_key_present("custom", cfg, {}) is True
# --- behavioural: providers (catalog + key lookup) ------------------------
def _patch_get_config(monkeypatch, cfg):
"""Force api.providers.get_config()/api.config.get_config() to return ``cfg``."""
monkeypatch.setattr(providers, "get_config", lambda: cfg)
# Some helpers import get_config from api.config at call time.
monkeypatch.setattr(config, "get_config", lambda: cfg, raising=False)
def _providers_cfg(providers_value):
return {
"model": {"provider": "custom", "api_key": "", "base_url": "http://localhost:1"},
"providers": providers_value,
"custom_providers": [],
}
def test_providers_has_key_handles_none_providers(monkeypatch):
"""``_provider_has_key`` must not crash and must match the empty-dict result."""
monkeypatch.setattr(providers, "_provider_env_var_for", lambda pid: None)
monkeypatch.setattr(
providers, "_load_env_file", lambda path: {}, raising=False
)
for providers_value, label in ((None, "none"), ({}, "empty")):
_patch_get_config(monkeypatch, _providers_cfg(providers_value))
# Should return False (no key) without raising on the None config.
assert providers._provider_has_key("custom") is False, label
assert providers._get_provider_api_key("custom") is None, label
# --- behavioural: routes (context-length helper + key resolution) ----------
def test_routes_context_length_helper_handles_none_providers():
"""``_context_length_lookup_inputs_for_model`` must accept providers: None."""
cfg_none = {
"model": {"provider": "custom", "base_url": "http://localhost:1"},
"providers": None,
"custom_providers": [],
}
cfg_empty = dict(cfg_none, providers={})
# Pass cfg explicitly so the helper does not hit real config/disk.
out_none = routes._context_length_lookup_inputs_for_model(
"@custom:my-model", "custom", cfg=cfg_none
)
out_empty = routes._context_length_lookup_inputs_for_model(
"@custom:my-model", "custom", cfg=cfg_empty
)
# No crash, and the None config produces the same lookup inputs as the
# empty-mapping config (no provider override available in either case).
# ``_ContextLengthLookupInputs`` is a __slots__ class without __eq__, so
# compare the materialised fields rather than object identity.
fields = ("config_context_length", "custom_providers", "base_url", "provider", "api_key")
snapshot_none = {f: getattr(out_none, f) for f in fields}
snapshot_empty = {f: getattr(out_empty, f) for f in fields}
assert snapshot_none == snapshot_empty