From 19533c8265da355e80c15c739c28bd89773b6009 Mon Sep 17 00:00:00 2001 From: Chirag Date: Mon, 17 Aug 2026 15:42:08 +0530 Subject: [PATCH] fix(proxy): read config files as utf-8 instead of the locale encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five call sites opened config files in text mode without an encoding, so they decoded with locale.getpreferredencoding(). On a Windows host that is cp1252 and a UTF-8 config fails two ways: model_name Café loads as Café with no error raised, and any UTF-8 byte undefined in cp1252 (0x81, 0x8D, 0x8F, 0x90, 0x9D) raises UnicodeDecodeError. Cafe plus U+0301 encodes to 43 61 66 65 CC 81 and is enough to trigger it. Two of the five run at import time, so they are not limited to proxy users, though endpoints.json is ASCII today which keeps that half latent. Three regression tests. The third runs the loader under -X warn_default_encoding, which makes CPython raise on any open() that omits an encoding, so it fails on Linux too rather than only on the platform that has the bug. The filter is armed after the import so it measures the config path and nothing else. --- litellm/containers/endpoint_factory.py | 2 +- litellm/proxy/_lazy_openapi_snapshot.py | 2 +- .../container_endpoints/handler_factory.py | 2 +- litellm/proxy/proxy_server.py | 4 +- tests/test_litellm/proxy/test_proxy_server.py | 59 +++++++++++++++++++ 5 files changed, 64 insertions(+), 5 deletions(-) diff --git a/litellm/containers/endpoint_factory.py b/litellm/containers/endpoint_factory.py index 09bc7eda41f..a1ad175f550 100644 --- a/litellm/containers/endpoint_factory.py +++ b/litellm/containers/endpoint_factory.py @@ -38,7 +38,7 @@ RESPONSE_TYPES: Final[dict[str, type]] = { def _load_endpoints_config() -> dict: """Load the endpoints configuration from JSON file.""" config_path: Final = Path(__file__).parent / "endpoints.json" - with open(config_path) as f: + with open(config_path, encoding="utf-8") as f: return json.load(f) diff --git a/litellm/proxy/_lazy_openapi_snapshot.py b/litellm/proxy/_lazy_openapi_snapshot.py index 41359d44b27..83e65586cd3 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.py +++ b/litellm/proxy/_lazy_openapi_snapshot.py @@ -46,7 +46,7 @@ def load_snapshot() -> dict[str, dict] | None: if not SNAPSHOT_FILE.exists(): return None try: - with SNAPSHOT_FILE.open() as f: + with SNAPSHOT_FILE.open(encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, OSError): return None diff --git a/litellm/proxy/container_endpoints/handler_factory.py b/litellm/proxy/container_endpoints/handler_factory.py index aaee1d3e264..f20a3e076f2 100644 --- a/litellm/proxy/container_endpoints/handler_factory.py +++ b/litellm/proxy/container_endpoints/handler_factory.py @@ -28,7 +28,7 @@ from litellm.proxy.container_endpoints.ownership import ( def _load_endpoints_config() -> dict: """Load the endpoints configuration from JSON file.""" config_path: Final = Path(__file__).parent.parent.parent / "containers" / "endpoints.json" - with open(config_path) as f: + with open(config_path, encoding="utf-8") as f: return json.load(f) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index bda6fc25499..7885074e6c3 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -4096,7 +4096,7 @@ class ProxyConfig: Load and parse a YAML file """ try: - with open(file_path, "r") as file: + with open(file_path, "r", encoding="utf-8") as file: return yaml.safe_load(file) or {} except Exception as e: raise Exception(f"Error loading yaml file {file_path}: {e}") @@ -4117,7 +4117,7 @@ class ProxyConfig: # Load existing config ## Yaml if os.path.exists(f"{file_path}"): - with open(f"{file_path}", "r") as config_file: + with open(f"{file_path}", "r", encoding="utf-8") as config_file: config = yaml.safe_load(config_file) elif file_path is not None: raise Exception(f"Config file not found: {file_path}") diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index ba207242e29..ad28afa21aa 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -11064,3 +11064,62 @@ async def test_ptu_rollup_job_not_registered_without_opt_in(monkeypatch): assert scheduler.get_job(PTU_ROLLUP_JOB_ID) is None assert len(scheduler.get_jobs()) > 0 + + +CONFIG_WITH_NON_ASCII = """model_list: + - model_name: café-gpt + litellm_params: + model: openai/gpt-4o + api_key: sk-test +""" + + +def test_load_yaml_file_reads_non_ascii_config(tmp_path): + """A UTF-8 config decoded with the locale encoding silently yields café-gpt on a cp1252 host, + so the wrong model name reaches routing with no error raised.""" + from litellm.proxy.proxy_server import ProxyConfig + + config_path = tmp_path / "config.yaml" + config_path.write_text(CONFIG_WITH_NON_ASCII, encoding="utf-8") + + config = ProxyConfig()._load_yaml_file(str(config_path)) + + assert config["model_list"][0]["model_name"] == "café-gpt" + + +def test_load_yaml_file_reads_config_with_byte_undefined_in_cp1252(tmp_path): + """Cafe plus U+0301 encodes to a 0x81 byte, which cp1252 has no mapping for, so this raised + UnicodeDecodeError rather than merely mangling the name.""" + from litellm.proxy.proxy_server import ProxyConfig + + config_path = tmp_path / "config.yaml" + config_path.write_text("model_list:\n - model_name: Café\n", encoding="utf-8") + + config = ProxyConfig()._load_yaml_file(str(config_path)) + + assert config["model_list"][0]["model_name"] == "Café" + + +def test_load_yaml_file_specifies_an_encoding(tmp_path): + """Asserting the decoded value passes on any UTF-8 host regardless of the fix, and CI is Linux. + Running the loader under warn_default_encoding fails on every platform instead.""" + config_path = tmp_path / "config.yaml" + config_path.write_text(CONFIG_WITH_NON_ASCII, encoding="utf-8") + + body = ( + "import warnings\n" + "from litellm.proxy.proxy_server import ProxyConfig\n" + 'warnings.simplefilter("error", EncodingWarning)\n' + f'config = ProxyConfig()._load_yaml_file(r"{config_path}")\n' + 'assert config["model_list"][0]["model_name"] == "caf\u00e9-gpt", config\n' + 'print("OK")\n' + ) + proc = subprocess.run( + [sys.executable, "-X", "warn_default_encoding", "-W", "error::EncodingWarning", "-c", body], + capture_output=True, + text=True, + check=False, + ) + + assert "EncodingWarning" not in proc.stderr, proc.stderr + assert proc.returncode == 0, proc.stderr