mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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:
commit
afb4d76b67
5 changed files with 157 additions and 11 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue