diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index 53a447bc6ec..4962b6a4881 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -9,7 +9,7 @@ import sys import urllib.parse as urlparse from collections.abc import Iterable, Mapping, Sequence from pathlib import Path -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Any, Final, cast import click import httpx @@ -708,6 +708,16 @@ class ProxyInitializationHelpers: return multiproc_dir +def _value_type_name(value: object) -> str: + """Return the runtime class name of an arbitrary config value. + + The value read from YAML is untyped; annotate the parameter as ``object`` + so ``type()`` receives a known-typed argument and the strict type gate does + not count a ``reportUnknownArgumentType`` error on the error path. + """ + return type(value).__name__ + + @click.command() @click.argument("cli_args", nargs=-1) @click.option("--host", default="0.0.0.0", help="Host for the server to listen on.", envvar="HOST") @@ -1234,6 +1244,12 @@ def run_server( general_settings = _config.get("general_settings", {}) if general_settings is None: general_settings = {} + elif not isinstance(general_settings, dict): + raise ValueError( + "`general_settings` in the proxy config must be a mapping " + f"(got {_value_type_name(cast(object, general_settings))}). Check the " + "`general_settings:` block in your config file." + ) ### LOAD KEY MANAGEMENT SETTINGS FIRST (needed for custom secret manager) ### key_management_settings: Final = general_settings.get("key_management_settings", None) if key_management_settings is not None: diff --git a/tests/unit/proxy/test_proxy_cli.py b/tests/unit/proxy/test_proxy_cli.py index 8fc807cd8a0..5fac51cc014 100644 --- a/tests/unit/proxy/test_proxy_cli.py +++ b/tests/unit/proxy/test_proxy_cli.py @@ -3615,3 +3615,32 @@ class TestValidateConfigFlag: assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}" assert "config OK" in result.output mock_popen.assert_not_called() + + +@pytest.mark.xdist_group("proxy_cli") +class TestGeneralSettingsShape: + """`general_settings` is read straight from user YAML. A non-mapping value + (a bare string when the block is malformed, or `null` from an empty block) + used to reach `general_settings.get(...)` and blow up at startup with an + opaque `AttributeError: 'str' object has no attribute 'get'`. Startup must + either tolerate it (None -> {}) or fail with a message pointing at the config. + """ + + def test_null_general_settings_is_tolerated(self, tmp_path): + """An empty `general_settings:` block parses as None and must not crash.""" + config_path = tmp_path / "config.yaml" + config_path.write_text(yaml.dump({"model_list": [], "general_settings": None})) + + captured = _run_server_and_capture_urls(str(config_path)) + assert captured["DATABASE_URL"].startswith("postgresql://t:t@localhost:5432/t") + + def test_string_general_settings_fails_with_actionable_error(self, tmp_path): + """A non-mapping value must fail fast, not as a cryptic AttributeError.""" + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.dump({"model_list": [], "general_settings": "database_url"}) + ) + + with pytest.raises(ValueError, match=r"general_settings.*must be a mapping"): + _run_server_and_capture_urls(str(config_path)) +