mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-22 00:31:44 +00:00
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:
parent
585c32d3f5
commit
849859001f
4 changed files with 82 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue