diff --git a/litellm/litellm_core_utils/get_model_cost_map.py b/litellm/litellm_core_utils/get_model_cost_map.py index 6e3769260cc..b68db22183a 100644 --- a/litellm/litellm_core_utils/get_model_cost_map.py +++ b/litellm/litellm_core_utils/get_model_cost_map.py @@ -324,10 +324,16 @@ def _classify_fetch_content(content: bytes, url: str, etag: str | None) -> _Fetc return ModelCostMapReloaded(model_cost_map=model_cost_map, revision=git_blob_id(content), etag=etag) -def _attempt_fetch_s3(url: str, timeout: int) -> _FetchAttemptOutcome: +def _split_url(url: str) -> urllib.parse.SplitResult | ModelCostMapReloadUnavailable: + try: + return urllib.parse.urlsplit(url) + except ValueError as error: + return ModelCostMapReloadUnavailable(reason=f"invalid URL {url}: {error}") + + +def _attempt_fetch_s3(parts: urllib.parse.SplitResult, url: str, timeout: int) -> _FetchAttemptOutcome: from botocore.exceptions import BotoCoreError, ClientError - parts: Final = urllib.parse.urlsplit(url) bucket: Final = parts.netloc key: Final = parts.path.lstrip("/") if not bucket or not key: @@ -343,9 +349,11 @@ def _attempt_fetch_s3(url: str, timeout: int) -> _FetchAttemptOutcome: async def _attempt_fetch(client: _AsyncGetClient, url: str, timeout: int) -> _FetchAttemptOutcome: - parts: Final = urllib.parse.urlsplit(url) + parts: Final = _split_url(url) + if isinstance(parts, ModelCostMapReloadUnavailable): + return parts if parts.scheme == "s3": - return await asyncio.to_thread(_attempt_fetch_s3, url, timeout) + return await asyncio.to_thread(_attempt_fetch_s3, parts, url, timeout) try: response: Final = await client.get(url, timeout=timeout) except (httpx.HTTPError, httpx.InvalidURL) as e: @@ -354,9 +362,11 @@ async def _attempt_fetch(client: _AsyncGetClient, url: str, timeout: int) -> _Fe def _attempt_fetch_sync(client: _SyncGetClient, url: str, timeout: int) -> _FetchAttemptOutcome: - parts: Final = urllib.parse.urlsplit(url) + parts: Final = _split_url(url) + if isinstance(parts, ModelCostMapReloadUnavailable): + return parts if parts.scheme == "s3": - return _attempt_fetch_s3(url, timeout) + return _attempt_fetch_s3(parts, url, timeout) try: response: Final = client.get(url, timeout=timeout) except (httpx.HTTPError, httpx.InvalidURL) as e: diff --git a/tests/unit/litellm_core_utils/test_get_model_cost_map.py b/tests/unit/litellm_core_utils/test_get_model_cost_map.py index 0256ecf24ed..eca9bbdc170 100644 --- a/tests/unit/litellm_core_utils/test_get_model_cost_map.py +++ b/tests/unit/litellm_core_utils/test_get_model_cost_map.py @@ -686,6 +686,25 @@ def test_boot_load_does_not_retry_non_retryable_failure(): assert source["fallback_reason"] is not None +def test_boot_load_falls_back_when_urlsplit_rejects_the_url(): + client, calls = _mock_client([httpx.Response(200, content=_real_map_bytes())], client_cls=httpx.Client) + sleeper = _SyncSleepRecorder() + + cost_map = get_model_cost_map( + url="http://[bad/model_prices.json", + sleep=sleeper, + rng=random.Random(0), + client=client, + ) + assert calls["count"] == 0 + assert sleeper.waits == [] + assert _retry_threads() == [] + assert cost_map.keys() >= _load_root_cost_map().keys() - {"sample_spec", FALLBACK_GENERALIZATIONS_KEY} + source = get_model_cost_map_source_info() + assert source["source"] == "local" + assert "invalid URL" in (source["fallback_reason"] or "") + + def test_boot_load_respects_local_env_override(monkeypatch): """LITELLM_LOCAL_MODEL_COST_MAP=True still short-circuits to the backup with zero HTTP.""" monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")