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>
This commit is contained in:
nesquena-hermesandlidi1011 committed 2026-06-28 08:57:39 +00:00
1 parent c9677d6f45
commit 94d952c3bb
5 files changed
+205 -10

No files matched your search

+4 -1
View File
@@ -572,7 +572,10 @@ def _provider_api_key_present(
if isinstance(model_cfg, dict) and str(model_cfg.get("api_key") or "").strip():
return True
providers_cfg = cfg.get("providers", {})
# ``cfg.get("providers", {})`` only returns the default when the key is
# absent; an explicit ``providers:`` (null) in config.yaml yields ``None``.
# ``... or {}`` degrades that null to an empty mapping (salvage of #3967).
providers_cfg = cfg.get("providers") or {}
if isinstance(providers_cfg, dict):
provider_cfg = providers_cfg.get(provider, {})
if (
+5 -5
View File
@@ -1048,7 +1048,7 @@ def _provider_has_shadowed_codex_oauth_value(provider_id: str) -> bool:
active_provider = str(model_cfg.get("provider") or "").strip().lower()
if active_provider == provider_id:
values.append(model_cfg.get("api_key"))
providers_cfg = cfg.get("providers", {})
providers_cfg = cfg.get("providers") or {}
if isinstance(providers_cfg, dict):
provider_cfg = providers_cfg.get(provider_id, {})
if isinstance(provider_cfg, dict):
@@ -1216,7 +1216,7 @@ def _provider_has_key(provider_id: str) -> bool:
if _provider_value_counts_as_api_key(provider_id, model_cfg.get("api_key")):
return True
# Check providers.<id>.api_key
providers_cfg = cfg.get("providers", {})
providers_cfg = cfg.get("providers") or {}
if isinstance(providers_cfg, dict):
provider_cfg = providers_cfg.get(provider_id, {})
if isinstance(provider_cfg, dict) and str(provider_cfg.get("api_key") or "").strip():
@@ -1262,7 +1262,7 @@ def _get_provider_api_key(provider_id: str) -> str | None:
if model_key and active_provider == provider_id and _provider_value_counts_as_api_key(provider_id, model_key):
return model_key
providers_cfg = cfg.get("providers", {})
providers_cfg = cfg.get("providers") or {}
if isinstance(providers_cfg, dict):
provider_cfg = providers_cfg.get(provider_id, {})
if isinstance(provider_cfg, dict):
@@ -2345,7 +2345,7 @@ def get_providers() -> dict[str, Any]:
# Also detect providers from config.yaml providers section
cfg = get_config()
providers_cfg = cfg.get("providers", {})
providers_cfg = cfg.get("providers") or {}
if isinstance(providers_cfg, dict):
known_ids.update(providers_cfg.keys())
@@ -2778,7 +2778,7 @@ def _clean_provider_key_from_config(provider_id: str) -> None:
return
# 1. Clean providers.<id>.api_key
providers_cfg = cfg.get("providers", {})
providers_cfg = cfg.get("providers") or {}
if isinstance(providers_cfg, dict):
provider_cfg = providers_cfg.get(provider_id, {})
if isinstance(provider_cfg, dict) and provider_cfg.get("api_key"):
+3 -3
View File
@@ -5042,7 +5042,7 @@ def _context_length_config_api_key_for_provider(
return resolved
return ""
providers_cfg = cfg.get("providers", {})
providers_cfg = cfg.get("providers") or {}
if isinstance(providers_cfg, dict):
for provider_key, provider_cfg in providers_cfg.items():
if not isinstance(provider_cfg, dict):
@@ -5107,7 +5107,7 @@ def _context_length_lookup_inputs_for_model(
custom_providers = None
provider_context_length = None
providers_cfg = cfg.get("providers", {}) if isinstance(cfg, dict) else {}
providers_cfg = (cfg.get("providers") or {}) if isinstance(cfg, dict) else {}
if isinstance(providers_cfg, dict):
for provider_key, provider_cfg in providers_cfg.items():
if not isinstance(provider_cfg, dict):
@@ -16243,7 +16243,7 @@ def _handle_live_models(handler, parsed):
if _ep:
try:
import urllib.request
_providers_cfg = cfg.get("providers", {})
_providers_cfg = cfg.get("providers") or {}
_prov = _providers_cfg.get(provider, {}) if isinstance(_providers_cfg, dict) else {}
# Only use a provider-scoped key. A top-level model.api_key
# is safe here only when it belongs to the requested provider;
@@ -193,6 +193,9 @@ def test_streaming_fallbacks_use_shared_provider_context_helper():
def test_route_helper_keeps_all_context_length_sources_aligned():
assert "def _context_length_lookup_inputs_for_model" in ROUTES_PY
assert 'cfg.get("providers", {})' in ROUTES_PY
# Hardened against an explicit null ``providers:`` key (salvage of #3967):
# ``cfg.get("providers") or {}`` instead of ``cfg.get("providers", {})`` so
# a config.yaml with ``providers:`` (None) degrades to an empty mapping.
assert 'cfg.get("providers") or {}' in ROUTES_PY
assert 'cfg.get("custom_providers")' in ROUTES_PY
assert "_model_matches_configured_default" in ROUTES_PY
+189
View File
@@ -0,0 +1,189 @@
"""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