fix(router): resolve realtime session model to routed deployment

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
mateo 2026-08-13 19:51:34 +00:00
parent 5f2986a1f3
commit 00a2059174
2 changed files with 98 additions and 0 deletions

View file

@ -29,6 +29,7 @@ import anyio
import httpx
import openai
from openai import AsyncOpenAI
from pydantic import TypeAdapter, ValidationError
from typing_extensions import overload
import litellm
@ -342,6 +343,26 @@ 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}})
class RoutingArgs(enum.Enum):
ttl = 60 # 1min (RPM/TPM expire key)
@ -4685,6 +4706,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

@ -1298,6 +1298,82 @@ 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.
"""
captured: dict = {}
async def capture_kwargs(**kwargs):
captured.update(kwargs)
return {"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=capture_kwargs,
session={
"type": "realtime",
"model": "my-realtime-group",
"audio": {"input": {"transcription": {"model": "gpt-4o-transcribe"}}},
},
)
assert captured["model"] == "openai/gpt-realtime-2.1-mini"
assert captured["session"]["model"] == "openai/gpt-realtime-2.1-mini"
assert captured["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.
"""
captured: dict = {}
async def capture_kwargs(**kwargs):
captured.update(kwargs)
return {"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=capture_kwargs,
session={"type": "realtime"},
)
assert captured["model"] == "openai/gpt-realtime-2.1-mini"
assert captured["session"] == {"type": "realtime"}
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