From 0cb7dd7577db9e74db1dcfea287e6b9cea3636c8 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Wed, 11 Jun 2025 15:36:45 -0700 Subject: [PATCH] [Feat] Add provider specific params for `deepgram/` (#11638) * Checkpoint before follow-up message * Add comprehensive tests for Deepgram transcription functionality * clean up transform * just use 1 test * test cleanup * test fix get_complete_url * test rename file * refactor deepgram URL construction * add logging_obj.pre_call * fix unused imports --------- Co-authored-by: Cursor Agent --- litellm/llms/custom_httpx/llm_http_handler.py | 11 + .../audio_transcription/transformation.py | 97 +++++- ...gram_audio_transcription_transformation.py | 192 ++++++++++++ ...seek_audio_transcription_transformation.py | 57 ---- .../test_deepgram_mock_transcription.py | 279 ++++++++++++++++++ 5 files changed, 575 insertions(+), 61 deletions(-) create mode 100644 tests/test_litellm/llms/deepgram/audio_transcription/test_deepgram_audio_transcription_transformation.py delete mode 100644 tests/test_litellm/llms/deepgram/audio_transcription/test_deepseek_audio_transcription_transformation.py create mode 100644 tests/test_litellm/llms/deepgram/test_deepgram_mock_transcription.py diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index d1c68a6dccd..fa0d4585923 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -1038,6 +1038,17 @@ class BaseLLMHTTPHandler: else: json_data = data + ## LOGGING + logging_obj.pre_call( + input=optional_params.get("query", ""), + api_key=api_key, + additional_args={ + "complete_input_dict": {}, + "api_base": complete_url, + "headers": headers, + }, + ) + try: # Make the POST request response = client.post( diff --git a/litellm/llms/deepgram/audio_transcription/transformation.py b/litellm/llms/deepgram/audio_transcription/transformation.py index f1b18808f79..0011196f452 100644 --- a/litellm/llms/deepgram/audio_transcription/transformation.py +++ b/litellm/llms/deepgram/audio_transcription/transformation.py @@ -4,6 +4,7 @@ Translates from OpenAI's `/v1/audio/transcriptions` to Deepgram's `/v1/listen` import io from typing import List, Optional, Union +from urllib.parse import urlencode from httpx import Headers, Response @@ -126,9 +127,9 @@ class DeepgramAudioTranscriptionConfig(BaseAudioTranscriptionConfig): # Add additional metadata matching OpenAI format response["task"] = "transcribe" - response[ - "language" - ] = "english" # Deepgram auto-detects but doesn't return language + response["language"] = ( + "english" # Deepgram auto-detects but doesn't return language + ) response["duration"] = response_json["metadata"]["duration"] # Transform words to match OpenAI format @@ -163,7 +164,95 @@ class DeepgramAudioTranscriptionConfig(BaseAudioTranscriptionConfig): ) api_base = api_base.rstrip("/") # Remove trailing slash if present - return f"{api_base}/listen?model={model}" + # Build query parameters including the model + all_query_params = {"model": model} + + # Add filtered optional parameters + additional_params = self._build_query_params(optional_params, model) + all_query_params.update(additional_params) + + # Construct URL with proper query string encoding + base_url = f"{api_base}/listen" + query_string = urlencode(all_query_params) + url = f"{base_url}?{query_string}" + + return url + + def _should_exclude_param( + self, + param_name: str, + model: str, + ) -> bool: + """ + Determines if a parameter should be excluded from the query string. + + Args: + param_name: Parameter name + model: Model name + + Returns: + True if the parameter should be excluded + """ + # Parameters that are handled elsewhere or not relevant to Deepgram API + excluded_params = { + "model", # Already in the URL path + "OPENAI_TRANSCRIPTION_PARAMS", # Internal litellm parameter + } + + # Skip if it's an excluded parameter + if param_name in excluded_params: + return True + + # Skip if it's an OpenAI-specific parameter that we handle separately + if param_name in self.get_supported_openai_params(model): + return True + + return False + + def _format_param_value(self, value) -> str: + """ + Formats a parameter value for use in query string. + + Args: + value: The parameter value to format + + Returns: + Formatted string value + """ + if isinstance(value, bool): + return str(value).lower() + return str(value) + + def _build_query_params(self, optional_params: dict, model: str) -> dict: + """ + Builds a dictionary of query parameters from optional_params. + + Args: + optional_params: Dictionary of optional parameters + model: Model name + + Returns: + Dictionary of filtered and formatted query parameters + """ + query_params = {} + + for key, value in optional_params.items(): + # Skip None values + if value is None: + continue + + # Skip excluded parameters + if self._should_exclude_param( + param_name=key, + model=model, + ): + continue + + # Format and add the parameter + formatted_value = self._format_param_value(value) + query_params[key] = formatted_value + + return query_params def validate_environment( self, diff --git a/tests/test_litellm/llms/deepgram/audio_transcription/test_deepgram_audio_transcription_transformation.py b/tests/test_litellm/llms/deepgram/audio_transcription/test_deepgram_audio_transcription_transformation.py new file mode 100644 index 00000000000..af315ea751e --- /dev/null +++ b/tests/test_litellm/llms/deepgram/audio_transcription/test_deepgram_audio_transcription_transformation.py @@ -0,0 +1,192 @@ +import io +import os +import pathlib +import sys + +import pytest + +sys.path.insert( + 0, os.path.abspath("../../../../..") +) # Adds the parent directory to the system path + +import litellm +from litellm.llms.deepgram.audio_transcription.transformation import ( + DeepgramAudioTranscriptionConfig, +) + + +@pytest.fixture +def test_bytes(): + return b"litellm", b"litellm" + + +@pytest.fixture +def test_io_bytes(test_bytes): + return io.BytesIO(test_bytes[0]), test_bytes[1] + + +@pytest.fixture +def test_file(): + pwd = os.path.dirname(os.path.realpath(__file__)) + pwd_path = pathlib.Path(pwd) + test_root = pwd_path.parents[3] + print(f"test_root: {test_root}") + file_path = os.path.join(test_root, "gettysburg.wav") + f = open(file_path, "rb") + content = f.read() + f.seek(0) + return f, content + + +@pytest.mark.parametrize( + "fixture_name", + [ + "test_bytes", + "test_io_bytes", + "test_file", + ], +) +def test_audio_file_handling(fixture_name, request): + handler = DeepgramAudioTranscriptionConfig() + (audio_file, expected_output) = request.getfixturevalue(fixture_name) + assert expected_output == handler.transform_audio_transcription_request( + model="deepseek-audio-transcription", + audio_file=audio_file, + optional_params={}, + litellm_params={}, + ) + + +def test_get_complete_url_basic(): + """Test basic URL generation without optional parameters""" + handler = DeepgramAudioTranscriptionConfig() + url = handler.get_complete_url( + api_base=None, + api_key=None, + model="nova-2", + optional_params={}, + litellm_params={}, + ) + expected_url = "https://api.deepgram.com/v1/listen?model=nova-2" + assert url == expected_url + + +def test_get_complete_url_with_punctuate(): + """Test URL generation with punctuate parameter""" + handler = DeepgramAudioTranscriptionConfig() + url = handler.get_complete_url( + api_base=None, + api_key=None, + model="nova-2", + optional_params={"punctuate": True}, + litellm_params={}, + ) + expected_url = "https://api.deepgram.com/v1/listen?model=nova-2&punctuate=true" + assert url == expected_url + + +def test_get_complete_url_with_diarize(): + """Test URL generation with diarize parameter""" + handler = DeepgramAudioTranscriptionConfig() + url = handler.get_complete_url( + api_base=None, + api_key=None, + model="nova-2", + optional_params={"diarize": True}, + litellm_params={}, + ) + expected_url = "https://api.deepgram.com/v1/listen?model=nova-2&diarize=true" + assert url == expected_url + + +def test_get_complete_url_with_measurements(): + """Test URL generation with measurements parameter""" + handler = DeepgramAudioTranscriptionConfig() + url = handler.get_complete_url( + api_base=None, + api_key=None, + model="nova-2", + optional_params={"measurements": True}, + litellm_params={}, + ) + expected_url = "https://api.deepgram.com/v1/listen?model=nova-2&measurements=true" + assert url == expected_url + + +def test_get_complete_url_with_multiple_params(): + """Test URL generation with multiple query parameters""" + handler = DeepgramAudioTranscriptionConfig() + url = handler.get_complete_url( + api_base=None, + api_key=None, + model="nova-2", + optional_params={ + "punctuate": True, + "diarize": False, + "measurements": True, + "smart_format": True, + }, + litellm_params={}, + ) + # URL should contain all parameters + assert "model=nova-2" in url + assert "punctuate=true" in url + assert "diarize=false" in url + assert "measurements=true" in url + assert "smart_format=true" in url + assert url.startswith("https://api.deepgram.com/v1/listen?") + + +def test_get_complete_url_with_language_parameter(): + """Test that language parameter is excluded from query string (handled separately)""" + handler = DeepgramAudioTranscriptionConfig() + url = handler.get_complete_url( + api_base=None, + api_key=None, + model="nova-2", + optional_params={ + "language": "en", + "punctuate": True, + }, + litellm_params={}, + ) + expected_url = "https://api.deepgram.com/v1/listen?model=nova-2&punctuate=true" + assert url == expected_url + # Language should NOT appear in URL as it's handled separately + assert "language=" not in url + + +def test_get_complete_url_with_custom_api_base(): + """Test URL generation with custom API base""" + handler = DeepgramAudioTranscriptionConfig() + url = handler.get_complete_url( + api_base="https://custom.deepgram.com/v2", + api_key=None, + model="nova-2", + optional_params={"punctuate": True}, + litellm_params={}, + ) + expected_url = "https://custom.deepgram.com/v2/listen?model=nova-2&punctuate=true" + assert url == expected_url + + +def test_get_complete_url_with_string_values(): + """Test URL generation with string parameter values""" + handler = DeepgramAudioTranscriptionConfig() + url = handler.get_complete_url( + api_base=None, + api_key=None, + model="nova-2", + optional_params={ + "tier": "enhanced", + "version": "latest", + "punctuate": True, + }, + litellm_params={}, + ) + # URL should contain all parameters + assert "model=nova-2" in url + assert "tier=enhanced" in url + assert "version=latest" in url + assert "punctuate=true" in url + assert url.startswith("https://api.deepgram.com/v1/listen?") diff --git a/tests/test_litellm/llms/deepgram/audio_transcription/test_deepseek_audio_transcription_transformation.py b/tests/test_litellm/llms/deepgram/audio_transcription/test_deepseek_audio_transcription_transformation.py deleted file mode 100644 index ea035db1195..00000000000 --- a/tests/test_litellm/llms/deepgram/audio_transcription/test_deepseek_audio_transcription_transformation.py +++ /dev/null @@ -1,57 +0,0 @@ -import io -import os -import pathlib -import sys - -import pytest - -sys.path.insert( - 0, os.path.abspath("../../../../..") -) # Adds the parent directory to the system path - -import litellm -from litellm.llms.deepgram.audio_transcription.transformation import ( - DeepgramAudioTranscriptionConfig, -) - - -@pytest.fixture -def test_bytes(): - return b"litellm", b"litellm" - - -@pytest.fixture -def test_io_bytes(test_bytes): - return io.BytesIO(test_bytes[0]), test_bytes[1] - - -@pytest.fixture -def test_file(): - pwd = os.path.dirname(os.path.realpath(__file__)) - pwd_path = pathlib.Path(pwd) - test_root = pwd_path.parents[3] - print(f"test_root: {test_root}") - file_path = os.path.join(test_root, "gettysburg.wav") - f = open(file_path, "rb") - content = f.read() - f.seek(0) - return f, content - - -@pytest.mark.parametrize( - "fixture_name", - [ - "test_bytes", - "test_io_bytes", - "test_file", - ], -) -def test_audio_file_handling(fixture_name, request): - handler = DeepgramAudioTranscriptionConfig() - (audio_file, expected_output) = request.getfixturevalue(fixture_name) - assert expected_output == handler.transform_audio_transcription_request( - model="deepseek-audio-transcription", - audio_file=audio_file, - optional_params={}, - litellm_params={}, - ) diff --git a/tests/test_litellm/llms/deepgram/test_deepgram_mock_transcription.py b/tests/test_litellm/llms/deepgram/test_deepgram_mock_transcription.py new file mode 100644 index 00000000000..27a05199c93 --- /dev/null +++ b/tests/test_litellm/llms/deepgram/test_deepgram_mock_transcription.py @@ -0,0 +1,279 @@ +import io +import json +import os +import sys +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert( + 0, os.path.abspath("../../../..") +) # Adds the parent directory to the system path + +import litellm +from litellm.types.utils import TranscriptionResponse + + +@pytest.fixture +def mock_deepgram_response(): + """Mock Deepgram API response""" + return { + "metadata": { + "transaction_key": "deprecated", + "request_id": "test-request-id", + "sha256": "test-sha", + "created": "2024-01-01T00:00:00.000Z", + "duration": 10.5, + "channels": 1, + "models": ["nova-2"], + }, + "results": { + "channels": [ + { + "alternatives": [ + { + "transcript": "Hello, this is a test transcription.", + "confidence": 0.99, + "words": [ + { + "word": "Hello", + "start": 0.0, + "end": 0.5, + "confidence": 0.99, + }, + { + "word": "this", + "start": 0.6, + "end": 0.8, + "confidence": 0.98, + }, + { + "word": "is", + "start": 0.9, + "end": 1.1, + "confidence": 0.97, + }, + { + "word": "a", + "start": 1.2, + "end": 1.3, + "confidence": 0.96, + }, + { + "word": "test", + "start": 1.4, + "end": 1.8, + "confidence": 0.95, + }, + { + "word": "transcription", + "start": 1.9, + "end": 2.8, + "confidence": 0.94, + }, + ], + } + ] + } + ] + }, + } + + +@pytest.fixture +def test_audio_bytes(): + """Mock audio file bytes""" + return b"fake_audio_data_for_testing" + + +@pytest.fixture +def test_audio_file(): + """Mock audio file object""" + return io.BytesIO(b"fake_audio_data_for_testing") + + +class TestDeepgramMockTranscription: + """Test Deepgram transcription with mocked HTTP requests""" + + @pytest.mark.parametrize( + "optional_params,expected_url", + [ + # Basic transcription without parameters + ({}, "https://api.deepgram.com/v1/listen?model=nova-2"), + # Single parameters + ( + {"punctuate": True}, + "https://api.deepgram.com/v1/listen?model=nova-2&punctuate=true", + ), + ( + {"diarize": True}, + "https://api.deepgram.com/v1/listen?model=nova-2&diarize=true", + ), + ( + {"measurements": True}, + "https://api.deepgram.com/v1/listen?model=nova-2&measurements=true", + ), + ( + {"diarize": False}, + "https://api.deepgram.com/v1/listen?model=nova-2&diarize=false", + ), + # String parameters + ( + {"tier": "enhanced"}, + "https://api.deepgram.com/v1/listen?model=nova-2&tier=enhanced", + ), + ( + {"version": "latest"}, + "https://api.deepgram.com/v1/listen?model=nova-2&version=latest", + ), + # Language parameter should be excluded + ( + {"language": "en", "punctuate": True}, + "https://api.deepgram.com/v1/listen?model=nova-2&punctuate=true", + ), + # Multiple parameters with boolean conversion + ( + {"punctuate": True, "diarize": False}, + "https://api.deepgram.com/v1/listen?model=nova-2&punctuate=true&diarize=false", + ), + # Multiple mixed parameters + ( + { + "punctuate": True, + "diarize": False, + "measurements": True, + "smart_format": True, + "tier": "enhanced", + }, + None, + ), # We'll check contains for this one since order may vary + ], + ) + def test_transcription_url_generation( + self, + mock_deepgram_response, + test_audio_bytes, + optional_params, + expected_url, + ): + """Test transcription URL generation with various parameters""" + + # Create mock response + mock_response = MagicMock() + mock_response.json.return_value = mock_deepgram_response + mock_response.status_code = 200 + mock_response.headers = {"Content-Type": "application/json"} + + with patch( + "litellm.llms.custom_httpx.http_handler.HTTPHandler.post", + return_value=mock_response, + ) as mock_post: + + response: TranscriptionResponse = litellm.transcription( + model="deepgram/nova-2", + file=test_audio_bytes, + api_key="test-api-key", + **optional_params, + ) + + # Verify the HTTP call was made + mock_post.assert_called_once() + call_kwargs = mock_post.call_args.kwargs + + # Verify URL + actual_url = call_kwargs["url"] + if expected_url is None: + # For multiple params, check that all expected parts are present + assert "model=nova-2" in actual_url + assert "punctuate=true" in actual_url + assert "diarize=false" in actual_url + assert "measurements=true" in actual_url + assert "smart_format=true" in actual_url + assert "tier=enhanced" in actual_url + assert actual_url.startswith("https://api.deepgram.com/v1/listen?") + # Ensure language is not included even if it was in optional_params for other tests + assert "language=" not in actual_url + else: + assert ( + actual_url == expected_url + ), f"Expected {expected_url}, got {actual_url}" + + # Verify headers + assert "Authorization" in call_kwargs["headers"] + assert call_kwargs["headers"]["Authorization"] == "Token test-api-key" + + # Verify response + assert response.text == "Hello, this is a test transcription." + assert hasattr(response, "_hidden_params") + + def test_transcription_with_custom_api_base( + self, mock_deepgram_response, test_audio_bytes + ): + """Test transcription with custom API base URL""" + + mock_response = MagicMock() + mock_response.json.return_value = mock_deepgram_response + mock_response.status_code = 200 + mock_response.headers = {"Content-Type": "application/json"} + + with patch( + "litellm.llms.custom_httpx.http_handler.HTTPHandler.post", + return_value=mock_response, + ) as mock_post: + + response: TranscriptionResponse = litellm.transcription( + model="deepgram/nova-2", + file=test_audio_bytes, + api_key="test-api-key", + api_base="https://custom.deepgram.com/v2", + punctuate=True, + ) + + # Verify the HTTP call was made + mock_post.assert_called_once() + call_kwargs = mock_post.call_args.kwargs + + # Verify custom API base is used + expected_url = ( + "https://custom.deepgram.com/v2/listen?model=nova-2&punctuate=true" + ) + assert call_kwargs["url"] == expected_url + + # Verify response + assert response.text == "Hello, this is a test transcription." + + def test_transcription_with_file_object( + self, mock_deepgram_response, test_audio_file + ): + """Test transcription with file-like object""" + + mock_response = MagicMock() + mock_response.json.return_value = mock_deepgram_response + mock_response.status_code = 200 + mock_response.headers = {"Content-Type": "application/json"} + + with patch( + "litellm.llms.custom_httpx.http_handler.HTTPHandler.post", + return_value=mock_response, + ) as mock_post: + + response: TranscriptionResponse = litellm.transcription( + model="deepgram/nova-2", + file=test_audio_file, + api_key="test-api-key", + punctuate=True, + ) + + # Verify the HTTP call was made + mock_post.assert_called_once() + call_kwargs = mock_post.call_args.kwargs + + # Verify URL contains punctuate parameter + expected_url = ( + "https://api.deepgram.com/v1/listen?model=nova-2&punctuate=true" + ) + assert call_kwargs["url"] == expected_url + + # Verify response + assert response.text == "Hello, this is a test transcription."