diff --git a/litellm/containers/endpoint_factory.py b/litellm/containers/endpoint_factory.py index 25fc223cde0..a6727b7ce49 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 92578aa43b9..9f70964267b 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.py +++ b/litellm/proxy/_lazy_openapi_snapshot.py @@ -55,7 +55,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 3989bdacef1..837432c0abe 100644 --- a/litellm/proxy/container_endpoints/handler_factory.py +++ b/litellm/proxy/container_endpoints/handler_factory.py @@ -30,7 +30,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 a994293a4d8..66487fd89bd 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -5390,7 +5390,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}") @@ -5411,7 +5411,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 23d319159ca..11f62fb3400 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -14211,6 +14211,63 @@ async def test_ptu_rollup_job_not_registered_without_opt_in(monkeypatch): 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 @pytest.mark.asyncio async def test_moderations_reraises_proxy_exception_unwrapped(): """A 400 ProxyException from request validation must surface as-is,