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>
202 lines
6.2 KiB
Python
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
|