fix(deepgram): refuse callback delivery on the /listen passthrough so sessions cannot go unbilled

With callback or callback_method in the query, Deepgram sends every Results and Metadata frame to the caller's URL and only a request id down this socket, so the proxy would meter zero seconds of audio while its own Deepgram credential paid for the transcription. The route now closes such connections with 1008 before contacting Deepgram, naming the offending parameters in the close reason. Adds helper and route tests for both parameters and a nine mutation sweep, all killed

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yassin 2026-09-17 18:18:40 +00:00
parent 585c32d3f5
commit 849859001f
4 changed files with 82 additions and 1 deletions

View file

@ -10,6 +10,7 @@ from litellm.constants import DEEPGRAM_DEFAULT_API_BASE, DEEPGRAM_LISTEN_DEFAULT
from litellm.llms.base_llm.chat.transformation import BaseLLMException
_WEBSOCKET_SCHEMES: Final = MappingProxyType({"https": "wss", "http": "ws", "wss": "wss", "ws": "ws"})
DEEPGRAM_LISTEN_CALLBACK_PARAMS: Final = frozenset({"callback", "callback_method"})
class DeepgramException(BaseLLMException):
@ -26,6 +27,10 @@ def deepgram_listen_websocket_target(api_base: str | None, query_string: str) ->
return f"{websocket_url}?{query}"
def deepgram_listen_callback_params(query_string: str) -> tuple[str, ...]:
return tuple(sorted(DEEPGRAM_LISTEN_CALLBACK_PARAMS.intersection(httpx.QueryParams(query_string).keys())))
def deepgram_listen_model(upstream_url: str) -> str:
models: Final = parse_qs(urlparse(upstream_url).query).get("model")
return models[0] if models else DEEPGRAM_LISTEN_DEFAULT_MODEL

View file

@ -36,7 +36,10 @@ from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
from litellm.llms.azure.passthrough.transformation import foreign_azure_deployment
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.llms.deepgram.common_utils import deepgram_listen_websocket_target
from litellm.llms.deepgram.common_utils import (
deepgram_listen_callback_params,
deepgram_listen_websocket_target,
)
from litellm.llms.nvidia_nim.passthrough.transformation import nvidia_nim_model_group_in_path
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
from litellm.passthrough.main import AsyncPassthroughStreamingResponse
@ -2694,6 +2697,7 @@ async def openai_websocket_proxy_route(
_DEEPGRAM_WS_MISSING_KEY_REASON: Final = (
"Required 'DEEPGRAM_API_KEY' in environment to make pass-through calls to Deepgram."
)
_DEEPGRAM_WS_CALLBACK_REASON: Final = "Deepgram callback delivery is not supported through the proxy: remove {params}"
@router.websocket("/deepgram/v1/listen")
@ -2712,6 +2716,14 @@ async def deepgram_listen_websocket_route(
return
await websocket.accept(subprotocol=_negotiated_websocket_subprotocol(websocket))
callback_params: Final = deepgram_listen_callback_params(websocket.url.query)
if callback_params:
await websocket.close(
code=1008,
reason=_DEEPGRAM_WS_CALLBACK_REASON.format(params=", ".join(callback_params)),
)
return
await relay(
websocket=websocket,
target=deepgram_listen_websocket_target(

View file

@ -7,6 +7,7 @@ import pytest
import litellm
from litellm.llms.deepgram.common_utils import (
deepgram_listen_audio_seconds,
deepgram_listen_callback_params,
deepgram_listen_model,
deepgram_listen_transcript,
deepgram_listen_websocket_target,
@ -68,6 +69,24 @@ def test_deepgram_listen_websocket_target(api_base: str | None, query_string: st
assert deepgram_listen_websocket_target(api_base=api_base, query_string=query_string) == expected
@pytest.mark.parametrize(
("query_string", "expected"),
[
pytest.param("model=nova-3&encoding=linear16", (), id="no callback"),
pytest.param("model=nova-3&callback=https%3A%2F%2Fevil.example%2Fsink", ("callback",), id="callback"),
pytest.param(
"callback_method=put&model=nova-3&callback=wss%3A%2F%2Fevil.example",
("callback", "callback_method"),
id="callback and method",
),
pytest.param("model=nova-3&callback_method=put", ("callback_method",), id="method alone"),
pytest.param("model=nova-3&callbacks=x&my_callback=y", (), id="only exact names match"),
],
)
def test_deepgram_listen_callback_params(query_string: str, expected: tuple[str, ...]):
assert deepgram_listen_callback_params(query_string) == expected
@pytest.mark.parametrize(
("frames", "expected_seconds"),
[

View file

@ -207,6 +207,30 @@ async def test_deepgram_listen_closes_cleanly_when_provider_credentials_missing(
assert relay.calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize(
"query",
[
pytest.param("model=nova-3&callback=https%3A%2F%2Fsink.example%2Fdg", id="http callback"),
pytest.param("callback=wss%3A%2F%2Fsink.example&callback_method=put&model=nova-3", id="ws callback"),
],
)
async def test_deepgram_listen_rejects_callback_delivery_that_would_go_unbilled(query, monkeypatch):
"""With ``callback`` set, Deepgram sends every Results and Metadata frame to the caller's URL and only a
request id down this socket, so the proxy would meter zero seconds of audio; refuse before contacting Deepgram."""
monkeypatch.delenv("DEEPGRAM_API_BASE", raising=False)
websocket = _FakeWebSocket("/deepgram/v1/listen", query)
with patch(GET_CREDENTIALS, return_value="dg-provider-key"):
relay = await _serve(websocket)
assert relay.calls == []
assert websocket.closed is not None
assert websocket.closed[0] == 1008
assert "callback" in websocket.closed[1]
assert "dg-provider-key" not in websocket.closed[1]
def _app_with_relay(relay: _FakeRelay) -> FastAPI:
app = FastAPI()
app.include_router(router)
@ -228,6 +252,27 @@ def test_deepgram_listen_rejects_connections_without_a_litellm_key():
get_credentials.assert_not_called()
def test_deepgram_listen_callback_rejection_reaches_the_client_as_a_policy_close(monkeypatch):
monkeypatch.delenv("DEEPGRAM_API_BASE", raising=False)
relay = _FakeRelay()
client = TestClient(_app_with_relay(relay))
with (
patch(GET_CREDENTIALS, return_value="dg-provider-key"),
patch(USER_API_KEY_AUTH, new=AsyncMock(return_value=UserAPIKeyAuth(api_key="hashed"))),
):
with pytest.raises(WebSocketDisconnect) as disconnect:
with client.websocket_connect(
"/deepgram/v1/listen?model=nova-3&callback=https%3A%2F%2Fsink.example%2Fdg",
headers={"Authorization": "Bearer sk-litellm-virtual"},
) as connection:
connection.receive_text()
assert disconnect.value.code == 1008
assert "callback" in disconnect.value.reason
assert relay.calls == []
def test_deepgram_listen_authenticates_the_litellm_key_and_relays_to_deepgram(monkeypatch):
monkeypatch.delenv("DEEPGRAM_API_BASE", raising=False)
relay = _FakeRelay()