mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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:
commit
719b67114d
2 changed files with 110 additions and 1 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue