diff --git a/litellm/llms/azure/containers/transformation.py b/litellm/llms/azure/containers/transformation.py index 586b2e379a0..cd897511585 100644 --- a/litellm/llms/azure/containers/transformation.py +++ b/litellm/llms/azure/containers/transformation.py @@ -1,9 +1,16 @@ from typing import Optional +from urllib.parse import parse_qs, urlparse, urlunparse from litellm.llms.azure.common_utils import BaseAzureLLM from litellm.llms.openai.containers.transformation import OpenAIContainerConfig from litellm.types.router import GenericLiteLLMParams +# Endpoint-specific path suffixes that may appear in a deployment's api_base +# (e.g. the responses endpoint URL is stored as api_base for Azure models). +# Strip these before building the containers URL so we always start from the +# resource root (https://resource.cognitiveservices.azure.com). +_AZURE_ENDPOINT_PATHS = ("/openai/responses",) + class AzureContainerConfig(OpenAIContainerConfig): """ @@ -27,6 +34,27 @@ class AzureContainerConfig(OpenAIContainerConfig): litellm_params=GenericLiteLLMParams(api_key=api_key), ) + @staticmethod + def _normalize_api_base(api_base: Optional[str]) -> Optional[str]: + """Strip endpoint-specific path suffixes from api_base to get the resource root.""" + if not api_base: + return api_base + parsed = urlparse(api_base) + path = parsed.path.rstrip("/") + for ep in _AZURE_ENDPOINT_PATHS: + if path.endswith(ep): + return urlunparse( + (parsed.scheme, parsed.netloc, path[: -len(ep)], "", "", "") + ) + return api_base + + @staticmethod + def _extract_api_version(api_base: Optional[str]) -> Optional[str]: + """Return the api-version query param from api_base if present.""" + if not api_base: + return None + return parse_qs(urlparse(api_base).query).get("api-version", [None])[0] + def get_complete_url( self, api_base: Optional[str], @@ -39,10 +67,19 @@ class AzureContainerConfig(OpenAIContainerConfig): {endpoint}/openai/v1/containers when api_version is 'v1', 'latest', or 'preview'; otherwise: {endpoint}/openai/containers + + The deployment's api_base may be the responses endpoint URL + (e.g. .../openai/responses?api-version=2025-04-01-preview). We + prefer the api-version embedded there over the deployment's + api_version field, which may point to an older chat API version. """ + effective_params = dict(litellm_params) + api_version_from_base = self._extract_api_version(api_base) + if api_version_from_base: + effective_params["api_version"] = api_version_from_base return BaseAzureLLM._get_base_azure_url( - api_base=api_base, - litellm_params=litellm_params, + api_base=self._normalize_api_base(api_base), + litellm_params=effective_params, route="/openai/containers", default_api_version="v1", ) diff --git a/litellm/llms/custom_httpx/container_handler.py b/litellm/llms/custom_httpx/container_handler.py index 599cd705ebf..501390d840b 100644 --- a/litellm/llms/custom_httpx/container_handler.py +++ b/litellm/llms/custom_httpx/container_handler.py @@ -257,14 +257,19 @@ class GenericContainerHandler: returns_binary = endpoint_config.get("returns_binary", False) is_multipart = endpoint_config.get("is_multipart", False) + # An empty dict passed as `params` to httpx strips any existing query + # string from the URL (e.g. ?api-version=...). Use None instead so + # httpx leaves the URL's own query string intact. + effective_params = query_params or None + try: if method == "GET": response = http_client.get( - url=url, headers=headers, params=query_params + url=url, headers=headers, params=effective_params ) elif method == "DELETE": response = http_client.delete( - url=url, headers=headers, params=query_params + url=url, headers=headers, params=effective_params ) elif method == "POST": if is_multipart and "file" in kwargs: @@ -272,11 +277,11 @@ class GenericContainerHandler: kwargs["file"], headers ) response = http_client.post( - url=url, headers=headers, params=query_params, files=files + url=url, headers=headers, params=effective_params, files=files ) else: response = http_client.post( - url=url, headers=headers, params=query_params + url=url, headers=headers, params=effective_params ) else: raise ValueError(f"Unsupported HTTP method: {method}") @@ -376,14 +381,19 @@ class GenericContainerHandler: returns_binary = endpoint_config.get("returns_binary", False) is_multipart = endpoint_config.get("is_multipart", False) + # An empty dict passed as `params` to httpx strips any existing query + # string from the URL (e.g. ?api-version=...). Use None instead so + # httpx leaves the URL's own query string intact. + effective_params = query_params or None + try: if method == "GET": response = await http_client.get( - url=url, headers=headers, params=query_params + url=url, headers=headers, params=effective_params ) elif method == "DELETE": response = await http_client.delete( - url=url, headers=headers, params=query_params + url=url, headers=headers, params=effective_params ) elif method == "POST": if is_multipart and "file" in kwargs: @@ -391,11 +401,11 @@ class GenericContainerHandler: kwargs["file"], headers ) response = await http_client.post( - url=url, headers=headers, params=query_params, files=files + url=url, headers=headers, params=effective_params, files=files ) else: response = await http_client.post( - url=url, headers=headers, params=query_params + url=url, headers=headers, params=effective_params ) else: raise ValueError(f"Unsupported HTTP method: {method}") diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 2ff63cc2d7f..d2af0a3dd52 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -7834,7 +7834,7 @@ class BaseLLMHTTPHandler: response = sync_httpx_client.get( url=url, headers=headers, - params=params, + params=params or None, ) return container_provider_config.transform_container_list_response( @@ -7911,7 +7911,7 @@ class BaseLLMHTTPHandler: response = await async_httpx_client.get( url=url, headers=headers, - params=params, + params=params or None, ) return container_provider_config.transform_container_list_response( @@ -8001,7 +8001,7 @@ class BaseLLMHTTPHandler: response = sync_httpx_client.get( url=url, headers=headers, - params=params, + params=params or None, ) return container_provider_config.transform_container_retrieve_response( @@ -8078,7 +8078,7 @@ class BaseLLMHTTPHandler: response = await async_httpx_client.get( url=url, headers=headers, - params=params, + params=params or None, ) return container_provider_config.transform_container_retrieve_response( @@ -8168,7 +8168,7 @@ class BaseLLMHTTPHandler: response = sync_httpx_client.delete( url=url, headers=headers, - params=params, + params=params or None, ) return container_provider_config.transform_container_delete_response( @@ -8245,7 +8245,7 @@ class BaseLLMHTTPHandler: response = await async_httpx_client.delete( url=url, headers=headers, - params=params, + params=params or None, ) return container_provider_config.transform_container_delete_response( @@ -8341,7 +8341,7 @@ class BaseLLMHTTPHandler: response = sync_httpx_client.get( url=url, headers=headers, - params=params, + params=params or None, ) return container_provider_config.transform_container_file_list_response( @@ -8420,7 +8420,7 @@ class BaseLLMHTTPHandler: response = await async_httpx_client.get( url=url, headers=headers, - params=params, + params=params or None, ) return container_provider_config.transform_container_file_list_response( @@ -8508,7 +8508,7 @@ class BaseLLMHTTPHandler: response = sync_httpx_client.get( url=url, headers=headers, - params=params, + params=params or None, ) return container_provider_config.transform_container_file_content_response( @@ -8584,7 +8584,7 @@ class BaseLLMHTTPHandler: response = await async_httpx_client.get( url=url, headers=headers, - params=params, + params=params or None, ) return container_provider_config.transform_container_file_content_response( diff --git a/litellm/proxy/container_endpoints/endpoints.py b/litellm/proxy/container_endpoints/endpoints.py index 9650604bf81..fc1f77bb684 100644 --- a/litellm/proxy/container_endpoints/endpoints.py +++ b/litellm/proxy/container_endpoints/endpoints.py @@ -328,7 +328,7 @@ async def retrieve_container( custom_llm_provider=custom_llm_provider, ) data.update( - get_container_forwarding_params( + await get_container_forwarding_params( container_id, original_container_id, custom_llm_provider, @@ -433,7 +433,7 @@ async def delete_container( custom_llm_provider=custom_llm_provider, ) data.update( - get_container_forwarding_params( + await get_container_forwarding_params( container_id, original_container_id, custom_llm_provider, diff --git a/litellm/proxy/container_endpoints/handler_factory.py b/litellm/proxy/container_endpoints/handler_factory.py index 4284cdd5d4a..7eeb11fc372 100644 --- a/litellm/proxy/container_endpoints/handler_factory.py +++ b/litellm/proxy/container_endpoints/handler_factory.py @@ -196,10 +196,12 @@ async def _process_binary_request( ) data: Dict[str, Any] = { "file_id": file_id, - **get_container_forwarding_params( - container_id=container_id, - original_container_id=original_container_id, - custom_llm_provider=resolved_provider, + **( + await get_container_forwarding_params( + container_id=container_id, + original_container_id=original_container_id, + custom_llm_provider=resolved_provider, + ) ), } processor = ProxyBaseLLMRequestProcessing(data=data) @@ -316,7 +318,7 @@ async def _process_multipart_upload_request( ) data.update( - get_container_forwarding_params( + await get_container_forwarding_params( container_id=container_id, original_container_id=original_container_id, custom_llm_provider=resolved_provider, @@ -396,7 +398,7 @@ async def _process_request( ) ) data.update( - get_container_forwarding_params( + await get_container_forwarding_params( container_id=path_params["container_id"], original_container_id=original_container_id, custom_llm_provider=resolved_provider, diff --git a/litellm/proxy/container_endpoints/ownership.py b/litellm/proxy/container_endpoints/ownership.py index 568eca523ae..57de6c4a63d 100644 --- a/litellm/proxy/container_endpoints/ownership.py +++ b/litellm/proxy/container_endpoints/ownership.py @@ -23,6 +23,13 @@ CONTAINER_OBJECT_PURPOSE = "container" _NEGATIVE_OWNER_SENTINEL = "__litellm_container_no_owner__" _CONTAINER_OWNER_CACHE = InMemoryCache(max_size_in_memory=10000, default_ttl=60) +# Caches the stored ``unified_object_id`` (the encoded container ID +# captured at create time) so ``get_container_forwarding_params`` can +# recover the deployment ``model_id`` for native upstream IDs without +# re-hitting Prisma on every retrieve/delete. +_NEGATIVE_STORED_ID_SENTINEL = "__litellm_container_no_stored_id__" +_CONTAINER_STORED_ID_CACHE = InMemoryCache(max_size_in_memory=10000, default_ttl=60) + # Per-caller-scope cache for ``GET /v1/containers`` list filtering. Without # this, every list call issues a fresh ``find_many`` against # ``litellm_managedobjecttable``. The cache key is the sorted owner-scope @@ -56,7 +63,7 @@ def decode_container_id_for_ownership( return original_container_id, custom_llm_provider -def get_container_forwarding_params( +async def get_container_forwarding_params( container_id: str, original_container_id: str, custom_llm_provider: str ) -> Dict[str, str]: params = { @@ -65,6 +72,20 @@ def get_container_forwarding_params( } decoded = ResponsesAPIRequestUtils._decode_container_id(container_id) model_id = decoded.get("model_id") + if not (isinstance(model_id, str) and model_id): + # Native upstream IDs (e.g. Azure ``cntr_``) carry no LiteLLM + # routing payload, so decoding the user-supplied id yields no + # ``model_id``. Recover it from the encoded ``unified_object_id`` + # captured on the ownership row at create time — when the router + # selected a specific deployment that ID embeds the model_id. + stored_id = await _get_stored_container_id( + original_container_id, custom_llm_provider + ) + if stored_id and stored_id != container_id: + stored_decoded = ResponsesAPIRequestUtils._decode_container_id(stored_id) + stored_model_id = stored_decoded.get("model_id") + if isinstance(stored_model_id, str) and stored_model_id: + model_id = stored_model_id if isinstance(model_id, str) and model_id: params["model_id"] = model_id return params @@ -168,6 +189,7 @@ async def record_container_owner( ) _CONTAINER_OWNER_CACHE.set_cache(model_object_id, owner) + _CONTAINER_STORED_ID_CACHE.set_cache(model_object_id, container_id) # Drop the caller's own list-cache entry so the just-created container # shows up on their next ``GET /v1/containers``. Other callers with # disjoint scope tuples have their own entries; intersecting-scope @@ -207,9 +229,60 @@ async def _get_container_owner( _CONTAINER_OWNER_CACHE.set_cache( model_object_id, owner if owner is not None else _NEGATIVE_OWNER_SENTINEL ) + stored_id = getattr(row, "unified_object_id", None) if row is not None else None + _CONTAINER_STORED_ID_CACHE.set_cache( + model_object_id, + ( + stored_id + if isinstance(stored_id, str) and stored_id + else _NEGATIVE_STORED_ID_SENTINEL + ), + ) return owner +async def _get_stored_container_id( + original_container_id: str, custom_llm_provider: str +) -> Optional[str]: + """Return the ``unified_object_id`` stored at create time, if any. + + Used by :func:`get_container_forwarding_params` to recover the + deployment ``model_id`` for native upstream container IDs: the stored + value is the encoded form produced by ``encode_container_id_in_response`` + when the router selected a specific deployment. + """ + model_object_id = _container_model_object_id( + original_container_id, custom_llm_provider + ) + + cached = _CONTAINER_STORED_ID_CACHE.get_cache(model_object_id) + if cached == _NEGATIVE_STORED_ID_SENTINEL: + return None + if isinstance(cached, str) and cached: + return cached + + prisma_client = await _get_prisma_client() + if prisma_client is None: + return None + + row = await prisma_client.db.litellm_managedobjecttable.find_first( + where={ + "model_object_id": model_object_id, + "file_purpose": CONTAINER_OBJECT_PURPOSE, + } + ) + stored_id = getattr(row, "unified_object_id", None) if row is not None else None + _CONTAINER_STORED_ID_CACHE.set_cache( + model_object_id, + ( + stored_id + if isinstance(stored_id, str) and stored_id + else _NEGATIVE_STORED_ID_SENTINEL + ), + ) + return stored_id if isinstance(stored_id, str) and stored_id else None + + async def assert_user_can_access_container( container_id: str, user_api_key_dict: UserAPIKeyAuth, diff --git a/litellm/router.py b/litellm/router.py index fac48b45fb3..6edc10ccc28 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -5587,6 +5587,7 @@ class Router: from litellm.responses.utils import ResponsesAPIRequestUtils container_id = kwargs.get("container_id") + _forwarded_model_id = kwargs.get("model_id") if isinstance(container_id, str): decoded = ResponsesAPIRequestUtils._decode_container_id(container_id) original_id = decoded.get("response_id", container_id) @@ -5595,7 +5596,14 @@ class Router: decoded_provider = decoded.get("custom_llm_provider") if decoded_provider and kwargs.get("custom_llm_provider") == "openai": kwargs["custom_llm_provider"] = decoded_provider - model_id = decoded.get("model_id") + # Fall back to the model_id forwarded by the proxy when the container_id + # is a native upstream ID (e.g. Azure hex cntr_) that carries no LiteLLM + # routing payload, so deployment credentials (api_base, api_key) are applied. + model_id = decoded.get("model_id") or ( + _forwarded_model_id.strip() + if isinstance(_forwarded_model_id, str) and _forwarded_model_id.strip() + else None + ) if model_id: kwargs["model"] = model_id return await self._ageneric_api_call_with_fallbacks( diff --git a/tests/test_litellm/containers/test_azure_container_transformation.py b/tests/test_litellm/containers/test_azure_container_transformation.py index 70181f6f03d..cdcccf7c04e 100644 --- a/tests/test_litellm/containers/test_azure_container_transformation.py +++ b/tests/test_litellm/containers/test_azure_container_transformation.py @@ -109,6 +109,31 @@ class TestAzureContainerConfig: assert "/openai/v1/containers" in url + def test_get_complete_url_strips_responses_path_and_preserves_api_version(self): + """When api_base is the responses endpoint URL, get_complete_url must: + - strip /openai/responses (no double-path) + - use the api-version from api_base query string, NOT the deployment's + older api_version (e.g. 2024-08-01-preview → containers need 2025-04-01-preview) + """ + api_base = "https://my-resource.cognitiveservices.azure.com/openai/responses?api-version=2025-04-01-preview" + + url = self.config.get_complete_url( + api_base=api_base, + litellm_params={"api_version": "2024-08-01-preview"}, + ) + + assert ( + "/openai/responses/openai/containers" not in url + ), "path must not double /openai/responses" + assert "my-resource.cognitiveservices.azure.com" in url + assert "/openai/containers" in url or "/openai/v1/containers" in url + assert ( + "2025-04-01-preview" in url + ), "must use version from api_base, not litellm_params" + assert ( + "2024-08-01-preview" not in url + ), "must not fall back to older chat api_version" + def test_get_complete_url_raises_without_api_base(self, monkeypatch): monkeypatch.delenv("AZURE_API_BASE", raising=False) monkeypatch.setattr(litellm, "api_base", None) @@ -531,6 +556,92 @@ class TestAzureContainerKnownFailureRegressions: assert qs.get("api-version") == ["v1"] assert qs.get("foo") == ["bar"] + @pytest.mark.asyncio + async def test_regression_no_container_id_does_not_use_user_supplied_model_id( + self, monkeypatch + ): + """Operations without container_id (create, list) must NOT route via + _ageneric_api_call_with_fallbacks using a caller-supplied model_id. + + Security boundary: only the path that holds a validated container_id + is trusted to fall back to the forwarded model_id. A caller setting + model_id without container_id on POST /v1/containers must not gain + access to an arbitrary deployment UUID. + """ + from litellm.router import Router + + router = Router( + model_list=[ + { + "model_name": "azure-model", + "litellm_params": { + "model": "azure/gpt-4", + "api_base": "https://my-resource.cognitiveservices.azure.com", + "api_key": "test-key", + "api_version": "2025-04-01-preview", + }, + "model_info": {"id": "deployment-uuid-123"}, + } + ] + ) + + fallback_called = {"called": False} + + async def _mock_fallback(original_function, **kwargs): + fallback_called["called"] = True + return {} + + monkeypatch.setattr(router, "_ageneric_api_call_with_fallbacks", _mock_fallback) + + original_called = {"called": False} + + async def _noop(**kwargs): + original_called["called"] = True + return {} + + # No container_id — simulates create/list; caller injects a model_id + await router._init_containers_api_endpoints( + original_function=_noop, + model_id="deployment-uuid-123", + custom_llm_provider="azure", + ) + + assert not fallback_called["called"], ( + "_ageneric_api_call_with_fallbacks must NOT be called when " + "container_id is absent, even if model_id is supplied" + ) + assert original_called["called"], "original_function must be called directly" + + def test_regression_httpx_empty_params_strips_query_string(self): + """httpx erases the URL query-string when params={} (empty dict) is passed. + + Root cause of the Azure container 404s on POST/DELETE: + _build_query_params returns {} when the endpoint has no extra params; + passing that {} as params= to httpx wiped ?api-version=2025-04-01-preview. + + Fix: every container httpx call now uses `params or None` so an empty + dict falls back to None, which tells httpx to leave the URL untouched. + """ + url = ( + "https://resource.cognitiveservices.azure.com" + "/openai/containers/cntr_123?api-version=2025-04-01-preview" + ) + client = httpx.AsyncClient() + + req_none = client.build_request("DELETE", url, params=None) + assert "api-version=2025-04-01-preview" in str(req_none.url) + + req_empty = client.build_request("DELETE", url, params={}) + assert "api-version" not in str( + req_empty.url + ), "Documents root cause: params={} strips the query string" + + effective: dict = {} + req_guarded = client.build_request("DELETE", url, params=effective or None) + assert "api-version=2025-04-01-preview" in str( + req_guarded.url + ), "`params or None` must preserve ?api-version" + def test_regression_proxy_resolves_azure_text_same_as_azure(self): """Router/proxy treat azure_text like azure for container config.""" from litellm.proxy.container_endpoints.handler_factory import ( @@ -770,3 +881,143 @@ class TestAzureContainerKnownFailureRegressions: assert captured["data"]["container_id"] == "cntr_123" assert captured["data"]["custom_llm_provider"] == "azure" assert captured["data"]["model_id"] == "model_abc123" + + @pytest.mark.asyncio + async def test_regression_get_container_forwarding_params_sets_model_id_for_managed_id( + self, + ): + """get_container_forwarding_params must extract model_id from a + LiteLLM-managed encoded container ID and include it in the forwarding + dict. This is the proxy-side half of the native-Azure-ID routing fix: + the router's _init_containers_api_endpoints reads kwargs["model_id"] + which is set here. + """ + from litellm.proxy.container_endpoints.ownership import ( + get_container_forwarding_params, + ) + + encoded_id = ResponsesAPIRequestUtils._build_container_id( + custom_llm_provider="azure", + model_id="deployment-uuid-123", + container_id="cntr_6a058b43d24c8190a226cfb1d35405b20115fb7875ff11df", + ) + + params = await get_container_forwarding_params( + container_id=encoded_id, + original_container_id="cntr_6a058b43d24c8190a226cfb1d35405b20115fb7875ff11df", + custom_llm_provider="azure", + ) + + assert ( + params.get("model_id") == "deployment-uuid-123" + ), "model_id must be forwarded to the router for managed container IDs" + assert params.get("container_id") == ( + "cntr_6a058b43d24c8190a226cfb1d35405b20115fb7875ff11df" + ) + assert params.get("custom_llm_provider") == "azure" + + @pytest.mark.asyncio + async def test_regression_get_container_forwarding_params_recovers_model_id_for_native_id( + self, monkeypatch + ): + """Native Azure IDs (``cntr_``) cannot be decoded, so model_id + must be recovered from the ownership row's ``unified_object_id`` — + the encoded form captured at create time when the router selected a + specific deployment. Without this, the router-side fallback for + native IDs in ``_init_containers_api_endpoints`` is dead code. + """ + from types import SimpleNamespace + from unittest.mock import AsyncMock + + from litellm.proxy.container_endpoints import ownership + from litellm.proxy.container_endpoints.ownership import ( + get_container_forwarding_params, + ) + + native_id = "cntr_6a058b43d24c8190a226cfb1d35405b20115fb7875ff11df" + encoded_stored_id = ResponsesAPIRequestUtils._build_container_id( + custom_llm_provider="azure", + model_id="deployment-uuid-123", + container_id=native_id, + ) + + ownership._CONTAINER_STORED_ID_CACHE.flush_cache() + ownership._CONTAINER_OWNER_CACHE.flush_cache() + + table = AsyncMock() + table.find_first.return_value = SimpleNamespace( + created_by="user-1", + file_purpose=ownership.CONTAINER_OBJECT_PURPOSE, + unified_object_id=encoded_stored_id, + ) + prisma_client = SimpleNamespace( + db=SimpleNamespace(litellm_managedobjecttable=table) + ) + monkeypatch.setattr( + ownership, + "_get_prisma_client", + AsyncMock(return_value=prisma_client), + ) + + params = await get_container_forwarding_params( + container_id=native_id, + original_container_id=native_id, + custom_llm_provider="azure", + ) + + assert params.get("model_id") == "deployment-uuid-123", ( + "model_id must be recovered from the stored unified_object_id " + "for native upstream container IDs" + ) + assert params.get("container_id") == native_id + assert params.get("custom_llm_provider") == "azure" + + @pytest.mark.asyncio + async def test_regression_native_azure_container_id_uses_forwarded_model_id( + self, monkeypatch + ): + """Native Azure container IDs (cntr_ + hex, no LiteLLM payload) must + still route through _ageneric_api_call_with_fallbacks using the + model_id forwarded from the proxy ownership check so that deployment + credentials (api_base) are applied.""" + from litellm.router import Router + + router = Router( + model_list=[ + { + "model_name": "azure-model", + "litellm_params": { + "model": "azure/gpt-4", + "api_base": "https://my-resource.cognitiveservices.azure.com", + "api_key": "test-key", + "api_version": "2025-04-01-preview", + }, + "model_info": {"id": "deployment-uuid-123"}, + } + ] + ) + + called_with: dict = {} + + async def _mock_fallback(original_function, **kwargs): + called_with.update(kwargs) + return {} + + monkeypatch.setattr(router, "_ageneric_api_call_with_fallbacks", _mock_fallback) + + native_azure_id = "cntr_6a058b43d24c8190a226cfb1d35405b20115fb7875ff11df" + + async def _noop(**kwargs): + return {} + + await router._init_containers_api_endpoints( + original_function=_noop, + container_id=native_azure_id, + model_id="deployment-uuid-123", + custom_llm_provider="azure", + ) + + assert called_with.get("model") == "deployment-uuid-123", ( + "_ageneric_api_call_with_fallbacks must be called with the forwarded " + "model_id when the container_id carries no LiteLLM routing payload" + )