Merge pull request #39220 from BerriAI/litellm_containers_route_model_list_creds

fix(proxy): route container create and list through model_list deployments
This commit is contained in:
Mateo Wang 2026-09-02 13:04:04 -07:00 committed by GitHub
commit afb4d76b67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 157 additions and 11 deletions

View file

@ -1,7 +1,7 @@
import asyncio
import contextvars
import json
from collections.abc import Coroutine, Mapping
from collections.abc import Callable, Coroutine, Mapping
from functools import partial
from typing import Final, Literal, overload
@ -47,6 +47,13 @@ __all__ = [
##### Container Create #######################
async def _encode_created_container_id(
pending: Coroutine[object, object, ContainerObject],
encode: Callable[[ContainerObject], ContainerObject],
) -> ContainerObject:
return encode(await pending)
@client
async def acreate_container(
name: str,
@ -256,16 +263,16 @@ def create_container(
_is_async=_is_async,
)
# Encode container_id with provider/model metadata for routing
encode: Final = partial(
ContainerRequestUtils.encode_container_id_in_response,
custom_llm_provider=custom_llm_provider,
litellm_metadata=kwargs.get("litellm_metadata"),
extra_body=extra_body,
)
if isinstance(container_obj, ContainerObject):
container_obj = ContainerRequestUtils.encode_container_id_in_response(
response_obj=container_obj,
custom_llm_provider=custom_llm_provider,
litellm_metadata=kwargs.get("litellm_metadata"),
extra_body=extra_body,
)
return encode(container_obj)
return container_obj
return _encode_created_container_id(pending=container_obj, encode=encode)
except Exception as e:
raise litellm.exception_type(

View file

@ -208,7 +208,7 @@ async def list_containers(
# Read query parameters
query_params: Final = dict(request.query_params)
data: Final[dict[str, Any]] = {"query_params": query_params}
data: Final[dict[str, Any]] = {"query_params": query_params, "model": query_params.get("model")}
# Extract custom_llm_provider using priority chain
custom_llm_provider: Final = (

View file

@ -6714,7 +6714,10 @@ class Router:
metadata. When present, decode the ID, replace ``container_id`` with the
upstream value, and route through ``_ageneric_api_call_with_fallbacks`` so
deployment credentials (e.g. regional ``api_base`` for Azure) match
:meth:`_init_responses_api_endpoints`. Otherwise call the handler directly.
:meth:`_init_responses_api_endpoints`. Create/list calls carry no container ID, so
they route through the deployment named by ``model`` when the caller passes one,
falling back to the direct call when no deployment matches. Otherwise call the
handler directly with global provider credentials.
"""
if custom_llm_provider and "custom_llm_provider" not in kwargs:
kwargs["custom_llm_provider"] = custom_llm_provider
@ -6746,6 +6749,14 @@ class Router:
**kwargs,
)
requested_model: Final = kwargs.get("model")
if isinstance(requested_model, str) and requested_model.strip():
return await self._ageneric_api_call_with_fallbacks(
original_function=original_function,
passthrough_on_no_deployment=True,
**kwargs,
)
return await original_function(**kwargs)
async def _init_responses_api_endpoints(

View file

@ -1324,6 +1324,98 @@ async def test_init_containers_api_endpoints_managed_id_without_model_id_applies
assert call_kw["custom_llm_provider"] == "azure"
@pytest.mark.asyncio
async def test_init_containers_api_endpoints_create_with_model_uses_deployment_credentials(monkeypatch):
"""
``POST /v1/containers`` carries no container ID, so a ``model`` in the request
body is the only way to pick a deployment. The upstream call must receive that
deployment's ``api_key``/``api_base`` instead of falling back to the global
``OPENAI_API_KEY`` (which may be unset on the proxy).
"""
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
router = Router(
model_list=[
{
"model_name": "gpt-5.4",
"litellm_params": {
"model": "openai/gpt-5.4",
"api_key": "sk-model-list-key",
"api_base": "https://custom.openai.example/v1",
},
}
]
)
mock_original_function = AsyncMock(return_value={"id": "cntr_test", "name": "Test Container"})
await router._init_containers_api_endpoints(
original_function=mock_original_function,
custom_llm_provider="openai",
name="Test Container",
model="gpt-5.4",
)
mock_original_function.assert_called_once()
call_kw = mock_original_function.call_args.kwargs
assert call_kw["api_key"] == "sk-model-list-key"
assert call_kw["api_base"] == "https://custom.openai.example/v1"
assert call_kw["model"] == "openai/gpt-5.4"
assert call_kw["name"] == "Test Container"
@pytest.mark.asyncio
async def test_init_containers_api_endpoints_create_without_model_calls_directly():
"""
Without ``model`` (or with ``model=None`` as the proxy forwards it), create/list
must keep calling the handler directly with global provider credentials.
"""
router = Router(model_list=[])
router._ageneric_api_call_with_fallbacks = AsyncMock()
mock_original_function = AsyncMock(return_value={"id": "cntr_test"})
await router._init_containers_api_endpoints(
original_function=mock_original_function,
custom_llm_provider="openai",
name="Test Container",
model=None,
)
router._ageneric_api_call_with_fallbacks.assert_not_called()
mock_original_function.assert_called_once_with(custom_llm_provider="openai", name="Test Container", model=None)
@pytest.mark.asyncio
async def test_init_containers_api_endpoints_create_with_unknown_model_passes_through(monkeypatch):
"""
A ``model`` that names no configured deployment must not turn into a 400. The call
falls through to the handler with the caller's model and no injected deployment
credentials, matching the behaviour before model-based routing existed.
"""
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
router = Router(
model_list=[
{
"model_name": "gpt-5.4",
"litellm_params": {"model": "openai/gpt-5.4", "api_key": "sk-model-list-key"},
}
]
)
mock_original_function = AsyncMock(return_value={"id": "cntr_test"})
await router._init_containers_api_endpoints(
original_function=mock_original_function,
custom_llm_provider="openai",
name="Test Container",
model="does-not-exist",
)
mock_original_function.assert_called_once()
call_kw = mock_original_function.call_args.kwargs
assert call_kw["model"] == "does-not-exist"
assert call_kw["name"] == "Test Container"
assert "api_key" not in call_kw
assert "api_base" not in call_kw
def test_router_model_group_encrypted_content_affinity_callback_registration():
from litellm.router_utils.pre_call_checks.deployment_affinity_check import (
DeploymentAffinityCheck,

View file

@ -152,6 +152,42 @@ class TestContainerAPI:
assert response.id == "cntr_async_123"
assert response.name == "Async Test Container"
@pytest.mark.asyncio
async def test_acreate_container_encodes_router_model_id(self):
"""
The async handler returns a coroutine, so the managed-ID encoding must run
after it resolves. Otherwise follow-up calls (retrieve/delete/files) lose the
deployment and fall back to global provider credentials.
"""
upstream_response = ContainerObject(
id="cntr_upstream_123",
object="container",
created_at=1747857508,
status="running",
expires_after={"anchor": "last_active_at", "minutes": 20},
last_active_at=1747857508,
name="Routed Container",
)
async def _resolve_upstream():
return upstream_response
with patch.object( # test-quality-ok: create_container exposes no client seam, only the handler
base_llm_http_handler,
"container_create_handler",
side_effect=lambda **kwargs: _resolve_upstream() if kwargs["_is_async"] else upstream_response,
):
response = await acreate_container(
name="Routed Container",
custom_llm_provider="openai",
litellm_metadata={"model_info": {"id": "deployment-abc"}},
)
decoded = ResponsesAPIRequestUtils._decode_container_id(response.id)
assert decoded["model_id"] == "deployment-abc"
assert decoded["custom_llm_provider"] == "openai"
assert decoded["response_id"] == "cntr_upstream_123"
@pytest.mark.asyncio
async def test_alist_containers_basic(self):
"""Test basic async container listing functionality."""