Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion litellm/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
import httpx
import openai
from openai import AsyncOpenAI
from pydantic import BaseModel
from pydantic import BaseModel, TypeAdapter, ValidationError
from typing_extensions import overload

import litellm
Expand Down Expand Up @@ -383,6 +383,28 @@ def _stream_chunks_have_generated_content(chunks: Sequence[ModelResponseStream])
return False


_NO_SESSION_KWARGS: Final[Mapping[str, Mapping[str, object]]] = MappingProxyType({})
_SESSION_ADAPTER: Final = TypeAdapter(Mapping[str, object])


def _with_router_resolved_session_model(session: object, model_name: str) -> Mapping[str, Mapping[str, object]]:
"""
Realtime client-secret requests carry the model inside ``session`` as well, and the caller's copy of it still
holds the pre-routing model group name, so it has to follow the deployment the router just picked.

Returns kwargs to merge into the downstream call, empty when there is no session model to resolve.
"""
try:
typed_session: Final = _SESSION_ADAPTER.validate_python(session)
except ValidationError:
return _NO_SESSION_KWARGS
if "model" not in typed_session:
return _NO_SESSION_KWARGS
return MappingProxyType(
{"session": {**typed_session, "model": model_name}} # mutable-ok: callees deepcopy and JSON-dump session
)


# Router._aanthropic_messages_streaming_iterator buffers lifecycle chunks
# until real content commits the primary stream; a hostile or slow-starting
# upstream that never emits content or an error could otherwise grow that
Expand Down Expand Up @@ -4930,6 +4952,7 @@ async def _ageneric_api_call_with_fallbacks_helper(self, model: str, original_ge
"caching": self.cache_responses,
**kwargs,
"model": model_name,
**_with_router_resolved_session_model(kwargs.get("session"), model_name),
}
# Only set custom_llm_provider if it's not None
if custom_llm_provider is not None:
Expand Down
86 changes: 86 additions & 0 deletions tests/test_litellm/test_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import os
import threading
from types import SimpleNamespace
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch

import httpx
Expand Down Expand Up @@ -1537,6 +1538,91 @@ def inject_alias_into_kwargs(deployment, kwargs, function_name=None):
), f"Expected deployment model 'vertex_ai/gemini-2.5-flash', got '{captured['model']}'"


@pytest.mark.asyncio
async def test_ageneric_api_call_resolves_realtime_session_model():
"""
Regression for #36742: realtime client secret requests carry the model inside `session` too, and the proxy
fills it with the pre-routing model group name. The underlying litellm function reads session.model first,
so it must see the resolved deployment, while a caller's nested transcription model stays untouched.
"""
routed: Final = AsyncMock(return_value={"result": "ok"})

router = litellm.Router(
model_list=[
{
"model_name": "my-realtime-group",
"litellm_params": {
"model": "openai/gpt-realtime-2.1-mini",
"api_key": "fake-key",
},
"model_info": {"mode": "realtime"},
}
]
)

await router._ageneric_api_call_with_fallbacks(
model="my-realtime-group",
original_function=routed,
session={
"type": "realtime",
"model": "my-realtime-group",
"audio": {"input": {"transcription": {"model": "gpt-4o-transcribe"}}},
},
)

sent: Final = routed.call_args.kwargs
assert sent["model"] == "openai/gpt-realtime-2.1-mini"
assert sent["session"]["model"] == "openai/gpt-realtime-2.1-mini"
assert sent["session"]["audio"]["input"]["transcription"]["model"] == "gpt-4o-transcribe"


@pytest.mark.asyncio
async def test_ageneric_api_call_does_not_add_session_model():
"""
A session that never carried a model must not gain one from routing: the underlying function then falls back
to the resolved `model` kwarg itself, and the outgoing session body keeps the caller's shape.
"""
routed: Final = AsyncMock(return_value={"result": "ok"})

router = litellm.Router(
model_list=[
{
"model_name": "my-realtime-group",
"litellm_params": {
"model": "openai/gpt-realtime-2.1-mini",
"api_key": "fake-key",
},
"model_info": {"mode": "realtime"},
}
]
)

await router._ageneric_api_call_with_fallbacks(
model="my-realtime-group",
original_function=routed,
session={"type": "realtime"},
)

sent: Final = routed.call_args.kwargs
assert sent["model"] == "openai/gpt-realtime-2.1-mini"
assert sent["session"] == {"type": "realtime"}


@pytest.mark.parametrize(
"session, expected",
[
({"type": "realtime", "model": "my-realtime-group"}, {"session": {"type": "realtime", "model": "resolved"}}),
({"type": "realtime"}, {}),
(None, {}),
("not-a-session", {}),
],
)
def test_with_router_resolved_session_model(session, expected):
from litellm.router import _with_router_resolved_session_model

assert dict(_with_router_resolved_session_model(session, "resolved")) == expected


def test_router_get_model_access_groups_team_only_models():
"""
Test that Router.get_model_access_groups returns the correct response for team-only models
Expand Down
Loading