mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
[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 <cursoragent@cursor.com>
This commit is contained in:
parent
f3f7bc1537
commit
0cb7dd7577
5 changed files with 575 additions and 61 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?")
|
||||
|
|
@ -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={},
|
||||
)
|
||||
|
|
@ -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."
|
||||
Loading…
Add table
Reference in a new issue