Merge pull request #36811 from BerriAI/litellm_realtime_router_session_model

fix(router): resolve realtime session model to routed deployment
This commit is contained in:
Mateo Wang 2026-09-02 14:16:00 -07:00 • committed by GitHub
commit 719b67114d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 110 additions and 1 deletions

View file

@ -30,7 +30,7 @@ import anyio
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
@ -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
@ -4930,6 +4952,7 @@ class Router:
"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:

View file

@ -6,6 +6,7 @@ import logging
import os
import threading
from types import SimpleNamespace
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
@ -1537,6 +1538,91 @@ async def test_ageneric_api_call_deployment_model_overrides_alias():
), 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