Files
hermes-webui/tests/test_issue3717_context_length_provider_overrides.py
T
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

202 lines
6.2 KiB
Python

"""Regression coverage for #3717 provider-scoped context-length overrides."""
import sys
import types
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
STREAMING_PY = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
ROUTES_PY = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
def _install_fake_context_resolver(monkeypatch):
calls = []
mod = types.ModuleType("agent.model_metadata")
def _fake(
model,
base_url="",
*args,
config_context_length=None,
provider="",
custom_providers=None,
**kwargs,
):
calls.append(
{
"model": model,
"base_url": base_url,
"config_context_length": config_context_length,
"provider": provider,
"custom_providers": custom_providers,
}
)
return config_context_length or 256000
mod.get_model_context_length = _fake
if "agent" not in sys.modules:
agent_pkg = types.ModuleType("agent")
agent_pkg.__path__ = []
monkeypatch.setitem(sys.modules, "agent", agent_pkg)
monkeypatch.setitem(sys.modules, "agent.model_metadata", mod)
return calls
def test_route_resolver_uses_provider_model_context_length(monkeypatch):
import api.config as config
import api.routes as routes
calls = _install_fake_context_resolver(monkeypatch)
monkeypatch.setattr(
config,
"get_config",
lambda *a, **k: {
"model": {
"default": "default-model",
"context_length": 123456,
},
"providers": {
"openrouter": {
"base_url": "https://openrouter.example/v1",
"models": {
"provider-model": {"context_length": 777000},
},
},
},
"custom_providers": [],
},
)
result = routes._resolve_context_length_for_session_model(
"provider-model",
"openrouter",
)
assert result == 777000
assert calls[-1]["config_context_length"] == 777000
assert calls[-1]["base_url"] == "https://openrouter.example/v1"
assert calls[-1]["provider"] == "openrouter"
def test_route_resolver_uses_provider_model_context_length_without_base_url(monkeypatch):
import api.config as config
import api.routes as routes
calls = _install_fake_context_resolver(monkeypatch)
monkeypatch.setattr(
config,
"get_config",
lambda *a, **k: {
"model": {
"default": "default-model",
"context_length": 123456,
},
"providers": {
"anthropic": {
"models": {
"provider-model": {"context_length": 777000},
},
},
},
"custom_providers": [],
},
)
result = routes._resolve_context_length_for_session_model(
"provider-model",
"anthropic",
)
assert result == 777000
assert calls[-1]["config_context_length"] == 777000
assert calls[-1]["base_url"] == ""
assert calls[-1]["provider"] == "anthropic"
def test_route_resolver_uses_named_custom_provider_base_url(monkeypatch):
import api.config as config
import api.routes as routes
custom_providers = [
{
"name": "ZenMux",
"base_url": "https://zenmux.example/v1",
"models": {
"custom-model": {"context_length": "888000"},
},
}
]
calls = _install_fake_context_resolver(monkeypatch)
monkeypatch.setattr(
config,
"get_config",
lambda *a, **k: {
"model": {
"default": "default-model",
"context_length": 123456,
},
"custom_providers": custom_providers,
},
)
result = routes._resolve_context_length_for_session_model(
"custom-model",
"custom:zenmux",
)
assert result == 888000
assert calls[-1]["config_context_length"] == 888000
assert calls[-1]["base_url"] == "https://zenmux.example/v1"
assert calls[-1]["provider"] == "custom:zenmux"
assert calls[-1]["custom_providers"] is custom_providers
def test_global_context_length_remains_default_model_only(monkeypatch):
import api.config as config
import api.routes as routes
calls = _install_fake_context_resolver(monkeypatch)
monkeypatch.setattr(
config,
"get_config",
lambda *a, **k: {
"model": {
"default": "default-model",
"context_length": 123456,
},
"providers": {
"openai": {
"base_url": "https://openai.example/v1",
"models": {},
},
},
},
)
result = routes._resolve_context_length_for_session_model("other-model", "openai")
assert result == 256000
assert calls[-1]["config_context_length"] is None
assert calls[-1]["base_url"] == "https://openai.example/v1"
def test_streaming_fallbacks_use_shared_provider_context_helper():
assert STREAMING_PY.count("_context_length_lookup_inputs_for_model(") >= 2
assert "_cfg_base_url = getattr(agent, 'base_url', '') or resolved_base_url or ''" in STREAMING_PY
assert "base_url=_cfg_base_url" in STREAMING_PY
assert "config_context_length=_cfg_ctx_len" in STREAMING_PY
assert "provider=_cfg_provider" in STREAMING_PY
assert "custom_providers=_cfg_custom_providers" in STREAMING_PY
assert "_cfg_base_url" in STREAMING_PY
def test_route_helper_keeps_all_context_length_sources_aligned():
assert "def _context_length_lookup_inputs_for_model" 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